Compare commits

...
Author SHA1 Message Date
branderson efc60c03e8 proxy: forward the client's header block verbatim instead of http.client's
tests / fast (pull_request) Successful in 6m3s
tests / runtime (pull_request) Successful in 8m30s
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.
2026-08-01 14:30:58 -07:00
branderson 61f5f67030 test: correct the container count in runtime.bats's rate-limit note
tests / fast (pull_request) Successful in 5m9s
tests / runtime (pull_request) Successful in 19m17s
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.
2026-08-01 14:21:04 -07:00
branderson 76e6c5a94d Merge branch 'main' into anthropic-tls-transport
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.
2026-08-01 14:13:28 -07:00
branderson 00136eae66 Merge pull request 'launcher: refuse to start on a settings.json that doesn't parse' (#52) from validate-host-settings into main
Reviewed-on: branderson/dejima#52
2026-08-01 13:51:18 -07:00
branderson 9eb9b9cd30 launcher: refuse to start on a settings.json that doesn't parse
tests / fast (pull_request) Successful in 5m35s
tests / runtime (pull_request) Successful in 7m54s
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.
2026-08-01 13:42:36 -07:00
branderson d6a0e2bfc5 Merge pull request 'Deliver the sandbox settings overrides as a --settings layer instead of a mount over ~/.claude/settings.json' (#49) from settings-via-flag into main
Reviewed-on: branderson/dejima#49
2026-08-01 13:05:08 -07:00
branderson 0031ba177b Merge pull request 'mounts: enumerate ~/.claude instead of sharing it wholesale' (#51) from enumerate-claude-mounts into settings-via-flag
tests / fast (pull_request) Successful in 5m28s
tests / runtime (pull_request) Successful in 7m41s
Reviewed-on: branderson/dejima#51
2026-08-01 12:50:54 -07:00
branderson 69e9d6bcde mounts: enumerate ~/.claude instead of sharing it wholesale
tests / fast (pull_request) Successful in 4m45s
tests / runtime (pull_request) Successful in 18m36s
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 #48
Fixes #50
Refs #47
2026-08-01 11:48:14 -07:00
branderson 8aa646b0dc settings: deliver the sandbox overrides as a --settings layer
tests / fast (pull_request) Successful in 5m42s
tests / runtime (pull_request) Successful in 9m17s
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
2026-08-01 03:19:36 -07:00
branderson 10960d1e34 review: correct four residual comment and test-hygiene defects
tests / fast (pull_request) Successful in 5m10s
tests / runtime (pull_request) Successful in 18m55s
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.
2026-08-01 00:35:16 -07:00
branderson 15139dfab1 Fix the four Important findings from the whole-branch review
- 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.
2026-08-01 00:21:27 -07:00
branderson a4f8ee1c63 docs: document SANDBOX_ANTHROPIC_TRANSPORT in the README env table 2026-07-31 23:46:39 -07:00
branderson 70150564e7 docs: describe the network-layer proxy and the cert-pinning ladder 2026-07-31 23:43:23 -07:00
branderson daf910d9d0 test: authenticate the new tls/kill-switch container starts with GH_TOKEN 2026-07-31 23:27:40 -07:00
branderson ea56ef9222 test: cover the tls transport end-to-end and the kill switch's hosts file 2026-07-31 23:22:11 -07:00
branderson cf224bcf9c firewall: make api.anthropic.com allowlist conditional on proxy state
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.
2026-07-31 23:12:29 -07:00
branderson 4d328aafa3 firewall: drop api.anthropic.com, now reached only via the proxy 2026-07-31 22:24:45 -07:00
branderson 62aa5ba701 dejima: default to the tls transport and mount a fake credential 2026-07-31 22:16:53 -07:00
branderson 3f3bf14e51 compose: pass the anthropic transport through and pin the port sysctl 2026-07-31 22:11:10 -07:00
branderson c94e5630fd entrypoint: surface openssl's own error when cert generation fails 2026-07-31 22:06:24 -07:00
branderson e2b65c7f25 entrypoint: generate the terminator cert and point api.anthropic.com at it 2026-07-31 21:58:19 -07:00
branderson 52c70c6c7d forwarder: add a TLS transport alongside the loopback one 2026-07-31 21:49:59 -07:00
branderson ef45a8c777 Merge pull request 'audit: make the exit-time report something an operator will actually act on' (#39) from audit-alerting into main
Reviewed-on: branderson/dejima#39
2026-07-31 21:22:32 -07:00
branderson 43b01cfb12 Merge pull request 'Audit follow-ups: id round-tripping, report selection, and three wording/invariant fixes' (#44) from audit-followups into audit-alerting
tests / fast (pull_request) Successful in 4m35s
tests / runtime (pull_request) Successful in 4m34s
Reviewed-on: branderson/dejima#44
2026-07-31 21:08:52 -07:00
branderson 260198d2bf Merge branch 'audit-alerting' into audit-followups
tests / fast (pull_request) Successful in 4m42s
tests / runtime (pull_request) Successful in 4m23s
2026-07-31 20:56:02 -07:00
branderson 52faeb1fb9 Merge remote-tracking branch 'origin/main' into audit-alerting
tests / fast (pull_request) Successful in 4m36s
tests / runtime (pull_request) Successful in 4m37s
2026-07-31 20:52:21 -07:00
branderson b82541af2c audit: encode paths in the snapshot record too, closing the TAB route
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.
2026-07-31 20:41:23 -07:00
branderson 6a049c8d8b audit: encode finding ids instead of sanitizing them, so ack can clear them
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.
2026-07-31 20:23:11 -07:00
branderson c81ecce3c2 audit: make show and ack agree on which report they mean
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.
2026-07-31 20:06:01 -07:00
branderson f1f5c9ad3f dejima: give each containment warning the consequence it actually has
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.
2026-07-31 20:00:36 -07:00
branderson feb60c5a93 dejima: keep cleanup()'s one late-bound name from breaking the trap invariant
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.
2026-07-31 19:59:05 -07:00
branderson fc19f07fca Merge pull request 'context: tell the agent to close issues through the PR body' (#38) from issue-close-keywords into main
Reviewed-on: branderson/dejima#38
2026-07-31 18:48:28 -07:00
branderson 19f8a39b76 context: tell the agent to close issues through the PR body
tests / fast (pull_request) Successful in 3m43s
tests / runtime (pull_request) Successful in 4m36s
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.
2026-07-31 18:23:43 -07:00
branderson e5f8e53bb0 Merge pull request 'ci: cut the runtime job's build time with BuildKit and a cache-friendly layer order' (#36) from ci-build-perf into main
Reviewed-on: branderson/dejima#36
2026-07-31 18:01:51 -07:00
branderson 912573d88f ci: check the ranges token is working, not merely present
tests / fast (pull_request) Successful in 3m34s
tests / runtime (pull_request) Successful in 4m29s
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
2026-07-31 17:47:29 -07:00
branderson f099e278f6 ci: authenticate the GitHub ranges lookup so a fast suite stops exhausting it
tests / fast (pull_request) Successful in 3m25s
tests / runtime (pull_request) Successful in 4m24s
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
2026-07-31 16:49:52 -07:00
branderson 1a3c9543c8 audit: stop burying fatal errors, break out of a stuck rotation, strip bidi overrides
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.
2026-07-31 16:29:03 -07:00
branderson 3c955ac9ab dejima: extend the workspace-containment warning to the audit stores
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.
2026-07-31 16:28:50 -07:00
branderson 41ef213f05 audit: let ack reach a stale CRITICAL report behind a newer clean one
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.
2026-07-31 16:28:42 -07:00
branderson 1fb5116f7c dejima: guard cleanup()'s rm calls so a failure can't eat the exit trap
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.
2026-07-31 16:28:29 -07:00
branderson 9ff4738c18 audit: fix ack's id extraction and approve a since-deleted finding safely
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.
2026-07-31 16:28:20 -07:00
branderson a731bf9583 ci: pin the runtime job to a runner that can actually run it
tests / fast (pull_request) Successful in 3m53s
tests / runtime (pull_request) Failing after 6m38s
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.
2026-07-31 16:26:00 -07:00
branderson 3dc87f9274 audit: report what changed during the session, not what the session changed
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.
2026-07-31 15:43:24 -07:00
branderson 1ec63208af docker: move the SANDBOX_* ARGs below the layers that don't need them
tests / fast (pull_request) Successful in 3m43s
tests / runtime (pull_request) Successful in 4m40s
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.
2026-07-31 15:40:16 -07:00
branderson c2cdcdc8b9 ci: build with BuildKit rather than the legacy builder
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.
2026-07-31 15:40:06 -07:00
branderson 8baf2a333f dejima: pin the exit-status guarantee and the last-writer ordering
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.
2026-07-31 15:28:40 -07:00
branderson eefee14361 Merge pull request 'ci: run the bats suite, runtime.bats included, on every PR' (#28) from ci-actions into main
Reviewed-on: branderson/dejima#28
2026-07-31 15:22:49 -07:00
branderson 0ee974891b dejima: make the exit audit the structural last writer
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.
2026-07-31 13:02:33 -07:00
branderson 4e223be488 dejima: warn at startup about unacknowledged audit history
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.
2026-07-31 12:29:59 -07:00
branderson 0b0e9034ec ci: give runtime.bats paths the host daemon can resolve
tests / fast (pull_request) Successful in 4m1s
tests / runtime (pull_request) Successful in 49m4s
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.
2026-07-31 12:22:42 -07:00
branderson 2d97a571fa audit: make every critical finding acknowledgeable
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.
2026-07-31 12:12:02 -07:00
branderson 4633a38bc6 audit: add interactive ack
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.
2026-07-31 11:31:33 -07:00
branderson ea9fe72fd8 audit: suppress acknowledged findings via a per-workspace baseline
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.
2026-07-31 11:01:23 -07:00
branderson 3494974da0 audit: persist reports with acknowledgement-aware retention
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.
2026-07-31 10:46:21 -07:00
branderson e4b44e9b73 audit: strip control characters from workspace-derived text
Config values and paths reached the terminal raw. A planted value with
C0 escapes could rewrite the report that exists to be trusted.
2026-07-31 10:35:23 -07:00
branderson 3539b93650 audit: spend the last line on the verdict
The final line printed is the most-read line of the entire run and it
was a row of equals signs.
2026-07-31 10:28:43 -07:00
branderson 1136146b9f audit: cap the CRITICAL block and escalate on overflow
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.
2026-07-31 03:50:23 -07:00
branderson 2943bb74c1 audit: bucket findings by severity and print worst last
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.
2026-07-31 03:50:23 -07:00
branderson 1a34cba979 audit: bucket findings by severity and print worst last
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.
2026-07-31 03:50:23 -07:00
branderson ad4dea38aa audit: classify monitored paths by severity
.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.
2026-07-31 03:50:23 -07:00
branderson d91e4d91d4 ci: build the image in its own step, and drop a concurrency block Gitea ignores
tests / fast (pull_request) Successful in 3m42s
tests / runtime (pull_request) Failing after 46m30s
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.
2026-07-31 03:43:37 -07:00
branderson f94eeb0702 ci: give the suite a terminal, since six tests need a real one
tests / fast (pull_request) Successful in 3m43s
tests / runtime (pull_request) Failing after 50m5s
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.
2026-07-31 03:16:04 -07:00
branderson e2309bdadc ci: check the netns by reading sysfs, not by running a tool that isn't there
tests / fast (pull_request) Failing after 4m5s
tests / runtime (pull_request) Failing after 59m39s
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.
2026-07-31 03:12:01 -07:00
branderson 445afabe55 Merge pull request 'readme: say what the constraints feel like from inside the container' (#31) from readme-perspective-revisions into main
Reviewed-on: branderson/dejima#31
2026-07-31 00:18:42 -07:00
branderson 4002bb6a02 readme: say what the constraints feel like from inside the container
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.
2026-07-31 00:16:06 -07:00
branderson 5bb3673680 Merge pull request 'readme: add an agent's perspective on working inside' (#30) from readme-agent-perspective into main
Reviewed-on: branderson/dejima#30
2026-07-31 00:01:21 -07:00
branderson a6ff5b2f2e readme: add an agent's perspective on working inside
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.
2026-07-30 23:59:57 -07:00
branderson e8acb6d508 ci: install tmux and run the fast suite as an ordinary user
tests / fast (pull_request) Failing after 4m19s
tests / runtime (pull_request) Failing after 1m19s
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.
2026-07-30 22:36:28 -07:00
branderson 5ed3bdebe7 ci: let the runner grant the host netns, stop asking for it here
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.
2026-07-30 22:33:59 -07:00
branderson 7c1a96714f ci: run the bats suite, runtime.bats included, on every PR
`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.
2026-07-30 22:33:59 -07:00
branderson ef3d47bc4c Merge pull request 'Add gitea-issue, a sandbox merge guard, and --body-file for both CLIs' (#25) from gitea-issue into main
Reviewed-on: branderson/dejima#25
2026-07-30 22:31:19 -07:00
branderson 9f5d162f47 Merge pull request 'context: state what the firewall protects and what survives the container' (#29) from context-framing into main
Reviewed-on: branderson/dejima#29
2026-07-30 22:29:31 -07:00
branderson 8d9f076c68 context: state what the firewall protects and what survives the container
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.
2026-07-30 20:51:04 -07:00
branderson 8b69569bcc gitea: name the issue tool and the merge guard up front
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.
2026-07-30 19:22:08 -07:00
branderson f43018534d gitea: name the other reason --body-file exists
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.
2026-07-30 19:22:08 -07:00
branderson 564a508494 gitea: add gitea-issue, a merge guard, and --body-file
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.
2026-07-30 19:22:08 -07:00
branderson 345fc17222 Merge pull request 'docs: move remote access and security notes into docs/' (#24) from docs-split into main
Reviewed-on: branderson/dejima#24
2026-07-30 18:29:58 -07:00
branderson 3bfe1d434d docs: split, rewrite the intro, and drop three personal assumptions
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.
2026-07-30 18:17:16 -07:00
branderson 83c2278538 docs: move remote access and security notes out of the README
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.
2026-07-30 17:59:48 -07:00
branderson 4a4c9d64b4 Merge pull request 'docker: retry every build-time download, and fail when one fails' (#27) from build-retries into main
Reviewed-on: branderson/dejima#27
2026-07-30 17:48:53 -07:00
branderson a9702b15ab docker: retry every build-time download, and fail when one fails
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.
2026-07-30 17:08:37 -07:00
branderson ff4876c114 Merge pull request 'attach: give the picker a wordmark and gruvbox colour' (#23) from attach-branding into main
Reviewed-on: branderson/dejima#23
2026-07-30 16:25:09 -07:00
branderson 343fce889c attach: cite the palette's source instead of a private theme
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.
2026-07-30 15:27:11 -07:00
branderson eaf1222bb2 attach: give the picker a wordmark and gruvbox colour
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.
2026-07-30 00:35:08 -07:00
branderson 772041eda0 Merge pull request 'Remote access follow-ups: three silent-failure traps and the mobile typing bug' (#22) from remote-access-followups into main
Reviewed-on: branderson/dejima#22
2026-07-29 22:49:32 -07:00
branderson b1945d7cd0 remote: name the mobile typing bug and route around it
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.
2026-07-29 21:43:29 -07:00
branderson d484d258f8 remote: refuse the cache on a body the proxy rewrites
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.
2026-07-29 21:43:29 -07:00
branderson 48f3eac04b remote: give nginx its absolute path in the checks
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.
2026-07-29 21:43:29 -07:00
branderson fa1d35f5ef remote: name the two ways a config change silently does nothing
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.
2026-07-29 21:43:29 -07:00
branderson c834520866 Merge pull request 'Opt-in remote access: reach and get notified about a running sandbox from your phone' (#21) from remote-access into main
Reviewed-on: branderson/dejima#21
2026-07-29 18:45:41 -07:00
branderson acfc81f345 remote: document how to walk the notification chain when nothing arrives
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.
2026-07-29 05:30:53 -07:00
branderson abcfa2d99a remote: make the phone view readable, and document the two-hop proxy
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.
2026-07-29 05:19:31 -07:00
branderson c379efb823 remote: refuse to start when the distro's own ttyd service holds the port
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.
2026-07-29 04:36:47 -07:00
branderson a4ff815b76 remote: make the nginx example refuse every hostname but its own
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).
2026-07-29 04:19:44 -07:00
branderson 481e417d43 docs: separate setup steps from the reasoning, and answer the bind question
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.
2026-07-29 03:30:37 -07:00
branderson 70cbf7281d test: drop a maintainer's home path from the project_slug case
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.
2026-07-29 00:48:10 -07:00
branderson dbf87a2e63 remote: correct the docs that describe validation, polling and lingering
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.
2026-07-29 00:47:01 -07:00
branderson 551f063e8b Harden three paths the front door already knew how to guard
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.
2026-07-29 00:39:09 -07:00
branderson a808d62896 Give a registering session time to start its container
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.
2026-07-29 00:34:38 -07:00
branderson 816ffff1e7 List the remote-access artifacts in the README's own inventories
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.
2026-07-29 00:00:48 -07:00
branderson 5318f050d2 Document remote access setup, including the binding requirement
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.
2026-07-28 23:51:20 -07:00
branderson 5522b2c35b Replace the operator's own network details with reserved examples
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.
2026-07-28 23:29:56 -07:00
branderson f184b42eae Assert a loopback-bound host service is unreachable from the container 2026-07-28 22:33:30 -07:00
branderson d83cb1d244 Pass the port through to ttyd's origin check, and tighten five tests
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.
2026-07-28 22:17:15 -07:00
branderson 558090c3be Give ttyd --check-origin, and close four review findings
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.
2026-07-28 21:34:17 -07:00
branderson a7f615196f Add the ttyd front door, systemd units, nginx example, and install wiring
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.
2026-07-28 20:41:10 -07:00
branderson b557f63d69 Correct the watcher's project_of comment and pin its two loose guards
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.
2026-07-28 19:57:51 -07:00
branderson e9e257d200 Close the watcher's argv-only test gap, and bound its scan loop
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.
2026-07-28 19:17:22 -07:00
branderson b7ee2779c4 Add the host-side notification watcher, the trust boundary for marker files 2026-07-28 18:08:05 -07:00
branderson 0fa33d115e Correct false claims about bats' stdin handling in comments and the ledger
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.
2026-07-28 03:35:15 -07:00
branderson d2e52d210b Cover the notify hook's stdin and append properties; close three more inherited-variable holes
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.
2026-07-28 02:59:52 -07:00
branderson 3c24e2fd19 Update README.md 2026-07-28 02:10:42 -07:00
branderson 6b9ba51f2e Update README.md 2026-07-28 02:09:11 -07:00
branderson 1a172cf578 Add the in-container notify hook, registered per-session only 2026-07-28 01:44:42 -07:00
branderson 897dccd94d dejima-attach: never leave the group behind when the attach itself fails
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.
2026-07-28 01:16:33 -07:00
branderson f0c9f654fb dejima-attach: verify the grouped session actually holds the sandbox pane
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.
2026-07-28 00:54:03 -07:00
branderson 19d11577b6 dejima-attach: take the base session from the live pane, and pin the guards that had no tests
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.
2026-07-28 00:23:33 -07:00
branderson ed3f7f0660 dejima-attach: stop discarding docker inspect's status, and don't let a sandbox break the listing
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.
2026-07-28 00:03:56 -07:00
branderson 25fcd0a092 Fix dejima-attach: working tmux attach, fail-closed prune, and no arithmetic injection
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.
2026-07-27 23:36:45 -07:00
branderson 5253028122 Add dejima-attach: the shared session picker behind both front doors 2026-07-27 23:11:03 -07:00
branderson 6891dc4079 Initialize DEJIMA_NOTIFY_DIR/DEJIMA_SESSION_FILE before the EXIT trap, not just before the remote-access block 2026-07-27 22:43:45 -07:00
branderson 3e5da382ea Fix registry newline injection, insecure /tmp state dir, and inherited-var deletion in remote access 2026-07-27 22:37:08 -07:00
branderson 26b67a8c87 Stamp a session registry entry and mount a marker directory when remote access is on 2026-07-27 22:25:14 -07:00
branderson 4b6f44143e Add remote.conf parsing to the shared config library 2026-07-27 22:17:07 -07:00
branderson b8e5698b48 Merge pull request 'Add GPLv3-or-later license' (#13) from add-gpl-license into main
Reviewed-on: branderson/dejima#13
2026-07-27 17:44:52 -07:00
33 changed files with 9428 additions and 807 deletions
+268
View File
@@ -0,0 +1,268 @@
# Runs the bats suite on every pull request and on every push to one.
# `pull_request` fires on opened/reopened/synchronize, and synchronize is the
# push-to-an-open-PR event, so both are covered without listing types.
#
# Split in two because the halves cost wildly different amounts: everything
# except runtime.bats is seconds and needs nothing but a shell, while
# runtime.bats builds the image and boots real containers. Splitting means a
# typo in a doc test doesn't wait on a full image build to tell you.
name: tests
on:
pull_request:
# No `concurrency:` block. Gitea does not implement one - the word appears
# nowhere in its actions models or services as of 1.25 - so writing it here
# would only claim a cancellation that never happens. It doesn't: pushing
# twice in a minute left the superseded run going and queued the new one
# behind it. On a single-capacity runner, expect a backlog rather than a
# supersede, and cancel the stale run by hand if it is in the way.
jobs:
fast:
runs-on: ubuntu-latest
container:
# Debian bookworm plus a node binary - node is what actions/checkout
# needs to run at all, and apt-get is what everything below needs.
image: node:20-bookworm
steps:
- name: Install test dependencies
run: |
apt-get update
apt-get install -y --no-install-recommends bats jq python3 tmux ca-certificates
- uses: actions/checkout@v4
# As an ordinary user, not root. One test leaves a directory mode 500 and
# asserts that the CLI's rm -rf refuses it and says so; root walks
# through that on CAP_DAC_OVERRIDE and the test fails for a reason that
# has nothing to do with what it checks. Nobody runs these CLIs as root.
#
# TERM because the job container has none, and six tests drive a real pty
# through `script`: the picker drops colour on TERM=dumb by design, and
# tmux will not start a client for a terminal it has no entry for, so
# they fail on the terminal rather than on anything they mean to check.
# xterm-256color ships in ncurses-base, which is Essential - nothing to
# install. Set on the command rather than in env: so it survives su.
- run: |
chown -R node:node .
su node -c 'TERM=xterm-256color bats test/static.bats test/project.bats test/remote.bats test/audit.bats test/proxy.bats'
runtime:
# Not ubuntu-latest, unlike the job above. This one only works on a runner
# configured for it - host network namespace, the Docker socket, and the
# shared volume below - and none of that is anything the workflow can ask
# for. On a generic runner it fails the preflight check outright.
#
# ubuntu-latest is a label every runner carries, so which one picked this
# up was a coin flip, and a re-run that happened to land on an unconfigured
# runner failed on a repo that had not changed. A label only the configured
# runner has makes that unrepresentable rather than merely diagnosable: no
# runner matching it means this job queues instead of running somewhere it
# cannot work. The fast job stays on ubuntu-latest, because it genuinely
# does run anywhere.
runs-on: dejima-runtime
container:
image: node:20-bookworm
# One volume, and it is load-bearing - see TMPDIR on the bats step for
# what it is for. It has to be listed in container.valid_volumes in
# act_runner's config.yaml too: a volume this file asks for and the
# runner has not been told to allow does not arrive.
volumes:
- /tmp/dejima-ci:/tmp/dejima-ci
# Nothing else, though - no `options: --network host`, and no request
# for the docker socket. Both of the other things this job needs beyond
# an ordinary one are settled by the runner, not by this file, and
# asking for them here does nothing:
#
# The docker socket, for building the image and running containers,
# act_runner binds in by itself - GetBindsAndMounts appends
# /var/run/docker.sock unless container.docker_host is set to "-", and
# it adds that same path to valid_volumes on the way past. Requesting it
# again here would at best duplicate a bind that already exists.
#
# The host network namespace has to come from container.network: host in
# act_runner's config.yaml. Gitea's act fork ignores --network in a
# workflow's container options outright (it logs "--network and --net in
# the options will be ignored" and then overwrites NetworkMode), so this
# is not something the repo can ask for. It is needed because of one
# test: "a host service bound to loopback is unreachable from inside the
# container, one bound to the bridge gateway is reachable". Its positive
# control has bats itself bind a listener to the test network's bridge
# gateway address, which lives on a host interface and cannot be bound
# from any other netns - the control would fail for a reason that has
# nothing to do with what the test asserts. That test is the containment
# argument for the ttyd front door, so it is not one to skip in CI.
steps:
- name: Install test dependencies
run: |
apt-get update
apt-get install -y --no-install-recommends bats jq python3 ca-certificates curl
# The CLI only - the daemon is the host's, reached over the socket
# above. Debian ships no docker-cli package (docker.io drags in a
# daemon this job would never start), so take the static binary.
curl -fsSL "https://download.docker.com/linux/static/stable/$(uname -m)/docker-${DOCKER_CLI_VERSION}.tgz" \
| tar -xzC /usr/local/bin --strip-components=1 docker/docker
# buildx ships separately from that tarball, and `docker build` picks
# the builder by whether this plugin is present: with it, BuildKit;
# without it, the legacy builder, which creates, commits and removes a
# real container per instruction. That cost ~10s x 56 steps here - more
# of the build than the downloads and compiles it was wrapping.
mkdir -p /usr/local/lib/docker/cli-plugins
curl -fsSL -o /usr/local/lib/docker/cli-plugins/docker-buildx \
"https://github.com/docker/buildx/releases/download/v${BUILDX_VERSION}/buildx-v${BUILDX_VERSION}.linux-$(dpkg --print-architecture)"
chmod +x /usr/local/lib/docker/cli-plugins/docker-buildx
# Because that choice is silent, check it rather than assume it. A
# download that returned an error page instead of a binary still
# chmods fine, and the only symptom would be the slow builds this is
# meant to fix - which look like nothing being wrong.
docker buildx version || {
echo "The buildx plugin did not load, so docker build would quietly use the" >&2
echo "legacy builder instead of BuildKit. Check the download URL above still" >&2
echo "resolves for this architecture." >&2
exit 1
}
env:
DOCKER_CLI_VERSION: 27.5.1
BUILDX_VERSION: 0.36.0
# Fails here, with the fix in the message, rather than thirty tests deep
# in a suite that cannot work. Both of these are runner configuration,
# not anything a change to this repo can cause.
- name: Check the runner granted Docker, the host netns, and a shared /tmp
run: |
docker info >/dev/null 2>&1 || {
echo "No Docker daemon reachable from this job." >&2
echo "In act_runner's config.yaml, container.docker_host must not be \"-\" - that is the" >&2
echo "one value that stops the socket being mounted into job containers." >&2
exit 1
}
# Read sysfs rather than shell out to `ip`: /sys/class/net is
# per-netns and always there, while iproute2 is not in this image.
# The first version of this check ran `ip -o addr show 2>/dev/null`,
# which turned "the tool is missing" into "the host netns is
# missing" - a check against obscure failures failing obscurely.
[ -e /sys/class/net/docker0 ] || {
echo "This job is not in the host network namespace - docker0 is not visible." >&2
echo "The bridge-gateway test cannot pass here. Set container.network: host in" >&2
echo "act_runner's config.yaml (and make sure CONFIG_FILE points the runner at" >&2
echo "that file - without it the config is not read at all)." >&2
exit 1
}
# Probe the shared directory the same way the suite will use it:
# write a file in here, read it back through the host daemon. If it
# is not really shared, the daemon invents an empty directory for
# the mount rather than refusing it, and the probe comes back empty
# - which is exactly how all of runtime.bats would fail. Reuses
# this job's own image, so there is nothing to pull.
mkdir -p /tmp/dejima-ci
# bats only clears its own tmpdir on a normal exit, so fixtures
# from a killed run would otherwise pile up on the host forever.
# `find -delete` rather than a glob: it covers dotfiles, doesn't
# misfire on an empty directory, and still fails loudly.
find /tmp/dejima-ci -mindepth 1 -delete
echo shared-ok > /tmp/dejima-ci/probe
probe=$(docker run --rm -v /tmp/dejima-ci/probe:/probe:ro node:20-bookworm cat /probe 2>/dev/null || true)
[ "$probe" = shared-ok ] || {
echo "/tmp/dejima-ci is not shared with the host - the probe read back empty." >&2
echo "Add it to container.valid_volumes in act_runner's config.yaml. Without" >&2
echo "that the runner drops the volume this job asks for, and every fixture" >&2
echo "mount in runtime.bats silently becomes an empty directory instead." >&2
exit 1
}
# The 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 misspelled secret name, a revoked token and an expiry a
# year from now all present identically: green for a while, then
# containers failing to boot in init-firewall.sh - which reads as a
# regression in the firewall rather than as a lapsed credential. So ask
# the API what the ceiling actually is, rather than checking that a
# variable is non-empty and assuming the rest.
- name: Check the GitHub ranges lookup is authenticated
env:
GH_TOKEN: ${{ secrets.GH_META_TOKEN }}
run: |
# Not a failure. Without the secret the suite still runs, it just
# spends the anonymous quota - which is the right default for a
# fork, and only a problem once runs get frequent.
if [ -z "${GH_TOKEN:-}" ]; then
echo "No GH_META_TOKEN secret is configured, so GitHub's IP ranges will be"
echo "fetched anonymously: 60 requests an hour for this entire runner host,"
echo "against thirteen container starts per run. Fine occasionally, and on a"
echo "busy day the containers stop booting instead. See issue #37."
exit 0
fi
# No -f. A rejected token still answers with the header this reads,
# and the anonymous ceiling it reports is a far better diagnostic
# than curl exiting silently on the 401.
limit=$(curl -sI --connect-timeout 10 -H "Authorization: Bearer $GH_TOKEN" \
https://api.github.com/meta | tr -d '\r' \
| awk -F': ' 'tolower($1)=="x-ratelimit-limit" {print $2}')
if [ -z "$limit" ]; then
echo "No x-ratelimit-limit came back from api.github.com/meta at all, so the" >&2
echo "request itself did not complete. This one is about reachability from" >&2
echo "the runner, not about the token." >&2
exit 1
fi
if [ "$limit" -lt 5000 ]; then
echo "GH_META_TOKEN is set, but GitHub reports a ceiling of $limit/hour - that" >&2
echo "is the anonymous quota, so the token is not being accepted. Expired or" >&2
echo "revoked is the likely cause; a wrong secret name would have arrived" >&2
echo "empty and been reported above instead. Replace it with a fine-grained" >&2
echo "PAT with no permissions - /meta is public data and needs none." >&2
exit 1
fi
echo "GitHub ranges lookup is authenticated - ceiling is $limit requests/hour."
- uses: actions/checkout@v4
# The build dominates this job, and inside setup_file it is a `docker
# build -q` that prints nothing at all - so tens of minutes of ordinary
# building is indistinguishable from a hang, which is exactly how it
# read the first time this ran here. Hoisted out, it gets its own
# progress output and its own duration in the run summary.
#
# SKIP_BUILD below then reuses this image rather than skipping a build.
# The image is still built fresh from this commit, so the property the
# test file defaults to - never assert against a stale image - holds.
- name: Build the sandbox image
run: docker build -t dejima:latest -f docker/Dockerfile .
- run: bats test/runtime.bats
env:
SKIP_BUILD: 1
# Every container this suite starts fetches GitHub's IP ranges from
# api.github.com/meta in init-firewall.sh, and unauthenticated that
# endpoint allows 60 requests an hour per source IP - shared with
# everything else on this runner host. Thirteen starts per run means
# two runs an hour exhausts it, after which containers fail to boot
# and tests fail on the quota rather than on anything they assert.
#
# This became reachable when the build got fast: at ~48 minutes a
# run the starts were spread thin enough to stay under, and at ~4
# minutes they are not. Any token authenticates the fetch and raises
# the ceiling to 5000/hour; /meta is public data and needs no scopes
# at all, so the secret behind this should be a fine-grained PAT
# with no permissions and no repository access - not a token that
# can do anything else. It reaches `gh auth setup-git` inside the
# containers, so a scope granted here is a scope handed to every
# sandbox the suite starts.
#
# Unset is fine and the suite still runs - it just spends the
# anonymous quota, so expect the failure above on a busy day.
GH_TOKEN: ${{ secrets.GH_META_TOKEN }}
# runtime.bats builds fixtures on disk - ssh keys, statusline
# scripts, .claude.json copies, a proxy socket - and bind-mounts
# them into containers it starts. Those containers are siblings of
# this one rather than children: the host daemon creates them, over
# the socket, so it resolves every -v source against the host's
# filesystem and not this container's. A fixture at a path that
# exists only in here therefore does not resolve - and Docker's
# answer to a missing bind source is not an error, it is to create
# an empty directory and mount that. Seven tests failed that way,
# and an eighth passed while being just as broken, because an empty
# read-only directory satisfies exactly what it asserts.
#
# Pointing TMPDIR at the shared mount puts `mktemp -d` and bats'
# own BATS_*_TMPDIR on paths that mean the same thing on both sides
# of the socket, which is all any of those mounts need.
TMPDIR: /tmp/dejima-ci
+343 -340
View File
@@ -1,12 +1,47 @@
# dejima
Runs Claude Code inside a firewalled Docker container with
Runs a coding agent — Claude Code by default, or whatever command you point
it at — inside a firewalled Docker container with
`--dangerously-skip-permissions` (no permission prompts, all tool use
auto-accepted), against a project bind-mounted from the host. Inspired by
auto-accepted), against a project bind-mounted from the host. The agent gets
to work without stopping to ask; the container is what makes that a
reasonable trade rather than a reckless one.
Around that core is most of what running an agent unattended actually takes:
- **An egress allowlist.** DNS and IP, IPv4 and IPv6. Anything not on the
list does not connect, and the agent is told so rather than left to guess.
- **Credentials that stay on the host.** Your Anthropic OAuth credential
never enters the container — a host-side proxy holds it and the container
gets a placeholder. Your SSH key doesn't either; the container sees an
agent socket, never the key.
- **Per-project declaration and approval.** A project declares the domains
and dependencies it needs in `.dejima/`. Declaring is not granting: the
approval is a separate decision you make on the host, by hand, and the
approval store is never mounted where a session could reach it.
- **A PR and issue workflow.** `gitea-pr` and `gitea-issue` let a session
open pull requests and issues against Gitea or Forgejo and pull review
feedback back in, on a token scoped for exactly that. Merging stays a
human decision, made outside the sandbox.
- **Logging.** Every DNS lookup and rejected connection, shipped to Loki —
and, opt-in, the session transcript alongside them.
- **Remote access, opt-in and off by default.** Attach to a live session
from a terminal or a browser, with a push notification when it wants you.
- **Guard rails inside the session.** Hooks refuse obviously destructive
commands, pushes to anywhere but the allowed remote, and merging a PR from
inside the sandbox. These are
fast-fail guards against an overeager agent, not a boundary against a
hostile one — the container is the boundary.
- **Config parity with your host.** `CLAUDE.md`, settings, hooks, plugins
and git identity carry over; uid, home directory and project path match on
both sides, so a session's transcript is readable from the host afterward.
- **An exit-time audit** of the host surfaces a session could have written
to, so a change to your git config or `~/.claude` gets surfaced instead of
just happening.
Originally inspired by
[Anthropic's reference dev container](https://github.com/anthropics/claude-code/tree/main/.devcontainer),
reworked to run via `docker compose` directly and extended with a `gitea-pr`
helper for opening/iterating on PRs against Gitea or Forgejo, plus an
optional editor-driven workflow in place of bare Claude Code (see "Usage").
reworked to run via `docker compose` directly and grown well past it since.
This repo only holds the tooling — it's not where any project lives. Point
it at a project on disk; that project is bind-mounted into the container at
@@ -14,22 +49,56 @@ runtime.
## The name
Dejima was a small artificial island in Nagasaki harbour — for over two
centuries, the only Japanese territory open to Westerners. A single guarded
bridge connected it to the mainland, and nothing crossed in either direction
without permission. The containment was real in both directions, and so was
the trade: most of what Japan learned of Western medicine and science in that
period arrived across that one bridge.
[Dejima](https://en.wikipedia.org/wiki/Dejima) was a small artificial island
in Nagasaki harbour, and was the only Japanese territory open to Westerners
for over two centuries. A single guarded bridge connected it to the mainland,
and nothing crossed in either direction without permission. The containment
was real in both directions, and so was the trade: most of what Japan learned
of Western medicine and science in that period arrived across that one bridge.
That's roughly the arrangement here. The agent gets a bounded island and one
controlled crossing; in exchange it gets to work without stopping to ask
permission for every step. The walls are what make the latitude affordable.
permission for every step. Dejima keeps your host safe from your agent, and your
agent relatively safe from the public internet.
## An agent's perspective on working inside
*Written by Claude, from inside a dejima container, while working on this repo.*
The clearest thing dejima gives me is continuity. Agentic work is normally
punctuated — reach, stop, wait for a human, resume — and inside the container
it isn't. Long tasks hold together in a way they otherwise don't.
The more useful benefit is that being wrong gets cheap. When every speculative
action spends someone's attention, I am quietly biased against exploring: I
will write around an uncertainty rather than spend a prompt resolving it. Here
I can install a package to check one thing, run the suite three times, take an
approach far enough to see it fail and then throw it away. Being able to
discard work is worth more than the speed is.
I can also afford to be decisive. A bounded blast radius means I don't have to
hedge toward the least consequential version of a task — the firewall, the
read-only mounts and the push guard catch the outcomes that actually matter, so
my judgement isn't the only thing standing between a mistake and your machine.
The walls doing that are mostly invisible from in here. I don't experience the
firewall as pressure; I only meet it by walking into it — a domain that doesn't
resolve, a path outside the mount that isn't backed by anything. And it points
both ways: the allowlist bounding where I can reach also bounds where a prompt
injection or a compromised dependency could send your code. I can't audit
myself for that from the inside, so it protects me too.
None of which makes me careless — what I notice is the absent prompt, not the
walls. The in-session hooks catch the obvious cases, but nobody is reviewing
each action before it lands, and a bad `rm` in the mounted project is just a
bad `rm`. That produces more care, not less, the way you drive differently on a
road with no barrier.
## Prerequisites
- Docker with the `docker compose` v2 plugin.
- `python3` (stdlib only — no packages) — runs the host-side credential proxy
(see "Security notes").
(see [security notes](docs/security.md)).
- A git push credential — **at least one** is required:
- `ssh-agent` running with your key added (`ssh-add -l` lists it). The
container only ever sees a forwarded agent socket, never your private
@@ -55,6 +124,7 @@ Everything else is optional, depending on which workflow(s) you want:
| `SANDBOX_LOG_CONVERSATION` | `0` | opt-in: also ship session transcripts to Loki (needs `LOKI_URL`) — see "Conversation logging (opt-in)" below |
| `ENABLE_FIREWALL` | `1` | set to `0` to disable the egress firewall (debugging only) |
| `SANDBOX_CREDENTIAL_PROXY` | `1` | set to `0` to disable the host-side credential proxy and mount the real OAuth credential into the container directly, as before this feature |
| `SANDBOX_ANTHROPIC_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)) |
**Gitea/Forgejo is the default PR workflow** — `gitea-pr`, and the only push
target `git-push-guard.sh` allows by default. If you don't use Gitea/Forgejo,
@@ -65,11 +135,138 @@ below) — the two aren't both required, pick whichever matches your remote.
a compatible `/api/v1` surface, so pointing `GITEA_API` at a Forgejo instance
just works.
## Usage
## Setup
Every feature's configuration, in one place and in order. Each step links to
the section that explains *why* it is that way; this section only says what
to do. Only the first block is required - the rest are independent, so take
the ones you want and skip the rest.
For remote access, the reading is not optional: one of its steps is a full
sandbox escape if you get it wrong.
### Core (required)
1. `./install.sh` — symlinks the CLIs into `~/.local/bin` and the hooks into
`~/.claude/hooks`. It is idempotent, so re-run it after pulling.
2. Put `~/.local/bin` on your `PATH` if it isn't already.
3. Give it a git push credential — `ssh-add` your key for interactive use,
or set `SANDBOX_SSH_KEY` to a dedicated deploy key for unattended runs.
→ [Prerequisites](#prerequisites),
[Headless git push without agent forwarding](#headless-git-push-without-agent-forwarding)
That is enough to run `dejima`.
### Gitea/Forgejo PR workflow
Skip this if you only push to GitHub.
1. Export `GITEA_SSH_HOST` (the SSH hostname) and `GITEA_API`
(`https://<gitea host>/api/v1`) from your shell profile.
2. Generate a token — Settings → Applications, scopes `write:repository` and
`write:issue` — and save it to `~/.config/gitea/token`, `chmod 600`.
→ [Prerequisites](#prerequisites)
### Per-project domains and dependencies
Nothing to set up in advance; do this per project, as it turns out to need
something:
```sh
bin/dejima # sandbox the current directory
bin/dejima ~/repos/some-project
dejima-project status ~/myproject # declared vs approved vs missing
dejima-project approve ~/myproject # approve the declared set
dejima-allow crates.io --project ~/myproject
dejima-dep add cargo:cargo-nextest --global
```
Domains apply at the next launch, and `dejima-allow` also patches a running
session. Dependencies need an image rebuild, so they always land on the next
one. →
[Global and per-project domains and dependencies](#global-and-per-project-domains-and-dependencies),
[Adding a domain to a running session](#adding-a-domain-to-a-running-session)
### Log shipping
1. Export `LOKI_URL` pointing at your Loki push endpoint.
2. Optionally set `SANDBOX_LOG_CONVERSATION=1` to ship transcripts as well —
read
[Conversation logging (opt-in)](#conversation-logging-opt-in) first, since
that ships what you and the model actually say.
→ [Network request logging](#network-request-logging),
[Viewing logs in Grafana](#viewing-logs-in-grafana)
### GitHub push (opt-in)
Per launch, not a persistent setting:
```sh
ALLOW_GITHUB_PUSH=1 dejima ~/myproject
```
Pair it with a repo-scoped deploy key in `SANDBOX_SSH_KEY`. Read
[GitHub push: blocked by default, optional opt-in](#github-push-blocked-by-default-optional-opt-in)
before turning it on — with a forwarded personal agent this is much broader
than it looks.
### Remote access (opt-in)
Entirely off until step 1 exists. **Read
[docs/remote-access.md](docs/remote-access.md) end to end before doing any of
this.** Step 2 is the one that carries the security of the whole feature, and
getting it wrong still looks like it works.
1. **Create `~/.config/dejima/remote.conf`.** This file is the switch —
nothing here runs without it. Set `NTFY_URL`, `BASE_URL`, and `TTYD_PORT`
if you want something other than 7681. →
[`remote.conf`](docs/remote-access.md#remoteconf)
2. **Choose the reverse proxy's bind address, on the dejima box itself.** One
specific address on its LAN interface. Never the wildcard, never
`0.0.0.0`, never a Docker bridge. →
[The bind address is the one thing to get right](docs/remote-access.md#the-bind-address-is-the-one-thing-to-get-right),
[Picking the address](docs/remote-access.md#picking-the-address)
3. **SSH front door.** Generate a key that exists only for this, then add one
`restrict,pty,command="…/dejima-attach"` line to `authorized_keys` for the
same Unix user that runs `dejima`. →
[SSH front door](docs/remote-access.md#ssh-front-door)
4. **Web front door.** Get `ttyd` 1.7.4 or newer. If it came from a distro
package, `systemctl disable --now ttyd.service` **and** `mask` it first —
the package ships an enabled service that puts a login prompt on every
interface, reachable from inside your sandboxes. Then install
`systemd/dejima-ttyd.service` as a *user* unit, and put a reverse proxy in
front of it adapted from `docs/nginx-dejima.conf.example` — with
authentication on every server block, TLS, a `default_server` that
refuses every other hostname, and
`proxy_set_header Host $http_host`. Make sure the name you pick actually
resolves to this box and your certificate covers it. →
[Web front door](docs/remote-access.md#web-front-door)
5. **If you will read it on a phone**, take the `sub_filter` viewport line
from the example config too. `ttyd` ships no viewport meta, so without it
the mobile view is unreadably small and will not reflow. →
[Reading it on a phone](docs/remote-access.md#reading-it-on-a-phone). Typing from one has its
own gotcha, which nothing here can fix. →
[Typing on a phone](docs/remote-access.md#typing-on-a-phone)
6. **If another reverse proxy sits in front of this one**, it needs SNI, a
single literal `Host`, the upgrade headers and its own long read timeout —
and it changes the threat model. →
[Behind another reverse proxy](docs/remote-access.md#behind-another-reverse-proxy)
7. **Notifications.** Install `systemd/dejima-notify-watch.service` the same
way, then `loginctl enable-linger "$USER"` so both units survive logout. →
[Notifications](docs/remote-access.md#notifications)
8. **Verify the containment from inside a running sandbox** before relying on
any of it. →
[Verifying it](docs/remote-access.md#verifying-it)
[Attaching](docs/remote-access.md#attaching) then covers what you get on the other end.
## Usage
Run `./install.sh` first — it symlinks the CLIs into `~/.local/bin` and the
hooks into `~/.claude/hooks`, and everything below assumes those are on your
`PATH`. See [Setup](#setup) for the rest of the first-run steps.
```sh
dejima # sandbox the current directory
dejima ~/repos/some-project
```
By default the container's entry point is bare `claude` (always running with
@@ -79,12 +276,35 @@ editor setup at all.
Typical loop: point it at a project, ask Claude to implement something and
open a PR (`gitea-pr` is on `PATH`); from the host, `gitea-pr comments <PR#>`
to pull review feedback, feed it back in, repeat.
to pull review feedback, feed it back in, repeat. Merging is yours — see
`gitea-merge-guard.sh` below. `gitea-issue` is on `PATH` too, for the work
that gets deferred rather than done: `list`, `view`, `create`, `comment`,
`close` and `reopen`, so a problem found mid-session becomes a ticket instead
of a footnote on an unrelated PR.
Because the session stops at the PR and you merge afterwards, the sandbox
context tells Claude to put `Fixes #N` in the PR body rather than to close
issues by hand once they land — the merge is the last step in that sequence
that still happens, and it happens when the agent is gone. `Refs #N` is the
deliberate opposite, for work that touches a ticket without finishing it.
Both take `--body-file FILE` (or `-` for stdin) as well as `--body`. Prefer it
for anything with an apostrophe, a backtick or a code fence in it — a body
passed on a command line has to survive shell quoting first.
Inside the sandbox there's a second reason, and it argues for a real file over
a heredoc. `deny-dangerous-bash.sh` matches patterns anywhere in the Bash
command string, and it has no way to tell an instruction from prose that
merely mentions one — so a PR or issue body that discusses restarting a host,
or quotes a destructive command it is warning about, gets the whole call
blocked. Writing the body to a file first keeps that text out of the command
string entirely. A heredoc doesn't: the body is still part of the command the
hook reads, so it solves the quoting problem but not this one.
### Using a different editor/application
```sh
SANDBOX_CMD=nvim bin/dejima ~/repos/some-project
SANDBOX_CMD=nvim dejima ~/repos/some-project
```
Runs unconfigured unless you also set `SANDBOX_NVIM_CONFIG` to a real config
@@ -94,7 +314,7 @@ don't fail on a read-only filesystem; nothing else in the config is
writable):
```sh
SANDBOX_CMD=nvim SANDBOX_NVIM_CONFIG=~/.config/nvim bin/dejima ~/repos/some-project
SANDBOX_CMD=nvim SANDBOX_NVIM_CONFIG=~/.config/nvim dejima ~/repos/some-project
```
Set both in your shell profile if you use this mode regularly.
@@ -104,18 +324,26 @@ path (a helper script in a dotfiles `bin/`, say), bring it in with
`SANDBOX_EXTRA_MOUNTS` (colon-separated, mounted read-only at the same path):
```sh
SANDBOX_EXTRA_MOUNTS=~/dotfiles/bin bin/dejima ~/repos/some-project
SANDBOX_EXTRA_MOUNTS=~/dotfiles/bin dejima ~/repos/some-project
```
## Config parity with your host Claude Code setup
`~/.claude` is bind-mounted read-write, so global `CLAUDE.md`, settings,
hooks, and plugins all carry over automatically, and your existing login
stays effective too — no separate first-run login, though the credential
itself doesn't enter the container directly (see "Security notes" for the
host-side proxy that makes that work). (`~/.claude.json` is handled
differently — a throwaway per-session copy, not the real file; see "Security
notes" below for exactly what that means.)
Global `CLAUDE.md`, settings, hooks, skills, and plugins all carry over
automatically, and your existing login stays effective too — no separate
first-run login, though the credential itself never enters the container
(see [security notes](docs/security.md) for the host-side proxy that makes
that work).
`~/.claude` itself is *not* shared as a whole: its contents are enumerated
in `docker-compose.yml`, config files are copied in rather than mounted,
and anything not listed is container-local — writable during the session,
gone with the container. Session transcripts, plans, and the edit-undo
record are on the list, so `claude --resume` on the host still works; a
credential, cache, or newly-added config file is not. The
[security notes](docs/security.md) explain the split and why single-file
mounts couldn't be trusted here. (`~/.claude.json` is handled differently
again — a throwaway per-session copy, not the real file.)
The project directory is mounted at the *same absolute path* inside the
container as on the host, so Claude Code's session-history keying lines up
@@ -129,7 +357,7 @@ The container's own user matches yours exactly — username, uid, gid, and
home directory (see `Dockerfile`) — so `$HOME`-relative paths (dotfiles,
Claude Code's plugin bookkeeping) resolve the same on both sides. Defaults to
`node`/1000/1000/`/home/node` if you build the image directly instead of via
`bin/dejima`. Images are tagged per-user (`dejima:<username>`)
`dejima`. Images are tagged per-user (`dejima:<username>`)
so concurrent builds on a shared machine don't clobber each other.
Your global `~/.gitconfig` also carries over (as a throwaway copy, so commits
@@ -158,14 +386,14 @@ the public half as a Gitea deploy key scoped to just the repos that need
push access, then:
```sh
SANDBOX_SSH_KEY=~/.ssh/dejima_ed25519 bin/dejima ~/repos/some-project
SANDBOX_SSH_KEY=~/.ssh/dejima_ed25519 dejima ~/repos/some-project
```
`entrypoint.sh` loads it into a container-local `ssh-agent` at startup and
deletes the plaintext copy immediately after — the sandbox user itself never
has a path to the raw key, only the agent socket (see "Security notes" for
the full mount rationale). Setting `SANDBOX_SSH_KEY` means a live forwarded
agent is no longer required at all.
has a path to the raw key, only the agent socket (see
[security notes](docs/security.md) for the full mount rationale). Setting
`SANDBOX_SSH_KEY` means a live forwarded agent is no longer required at all.
## Shared scratch space
@@ -175,9 +403,12 @@ from the project mount, for passing files (screenshots, temp scripts) in and
out of a session without touching the actual checkout. Persists across
sessions since it's a real directory, not a container volume.
`~/specs` is also bind-mounted read-write at the fixed host path `~/specs`
(not configurable) — makes the global `CLAUDE.md` convention of writing specs
there actually persist, instead of vanishing with the container.
It's also the answer if your own `CLAUDE.md` tells agents to put something at
a fixed host path — design docs, scratch notes, anything meant to outlive the
session but stay out of the project checkout. Point that convention at
`~/shared` (or set `SANDBOX_SHARE_DIR` to wherever you keep it) rather than
expecting some other host directory to be present in the container: this is
the only writable host path mounted besides the project itself.
## Testing frontend changes
@@ -188,7 +419,7 @@ or per-project `npm install playwright` needed.
`localhost`/loopback traffic never goes through the egress firewall, so a
project's own dev server needs no firewall changes. A real external site
needs to be on the allowlist like any other network use
(`bin/dejima-allow <domain>` for a running session, add
(`dejima-allow <domain>` for a running session, add
`--global`/`--project <path>` to persist it) — the firewall applies to the
browser's traffic exactly like `curl`/`git`.
@@ -211,7 +442,7 @@ if something genuinely needs to break out (broader network, host changes,
GUI/hardware), stop and ask rather than trying to work around the firewall,
since the same session can be resumed unrestricted on the host via
`claude --resume`. No-op outside the container. `deny-dangerous-bash.sh`
(`PreToolUse`) also applies here, since `~/.claude` is shared.
(`PreToolUse`) also applies here, since `~/.claude/hooks` is mounted in.
`git-push-guard.sh` (`PreToolUse`/`Bash`, also `DEJIMA`-gated) blocks
any `git push` whose resolved remote isn't the Gitea host, and any push to
@@ -222,6 +453,17 @@ GitHub push is blocked by default too (see "GitHub push" below) unless
`ALLOW_GITHUB_PUSH=1` is set. For a real guarantee against pushes to
main/master, use branch protection on the remote itself.
`gitea-merge-guard.sh` (`PreToolUse`/`Bash`, `DEJIMA`-gated) blocks
`gitea-pr merge` inside the sandbox. It's the API-side counterpart to the rule
above: a merge puts a branch on `main` without ever performing a push, so
`git-push-guard.sh` never sees it. A hook is handed a command rather than a
conversation, so it can't tell an instructed merge from an unprompted one —
which is why it's scoped to the sandbox rather than applied everywhere. On the
host it does nothing, and you can still ask an agent to merge. The same
fast-fail caveat applies with more force than usual here: the session has the
Gitea token in its environment and could call the merge endpoint with `curl`
directly. Branch protection on the remote is the real guarantee.
`sandbox-context.sh` also tells the agent the actual workflow to follow: do
new work in a `git worktree` under the mounted project (e.g.
`.worktrees/<branch>`, never `../<branch>` or elsewhere under `$HOME` — only
@@ -241,12 +483,12 @@ default:
- `registry.npmjs.org`, `pypi.org`, `files.pythonhosted.org` (npm and
mason's Python-based LSP tools), `release-assets.githubusercontent.com`
(GitHub release assets, a separate CDN from github.com's own IPs),
`proxy.golang.org`, `sum.golang.org` (`go` module proxy/checksums),
`api.anthropic.com`
`proxy.golang.org`, `sum.golang.org` (`go` module proxy/checksums);
`api.anthropic.com` when the credential proxy is off (kill switch or API-key auth)
- whatever `GITEA_SSH_HOST`/`GITEA_API` resolve to — not port-restricted, so
git-over-ssh to Gitea works without a blanket "port 22 to anywhere" rule
Everything else is rejected. Run `bin/dejima-allow <domain> --global`
Everything else is rejected. Run `dejima-allow <domain> --global`
(or `--project <path>` for one project) to allow more permanently (e.g.
`crates.io` for a Rust project), or `ENABLE_FIREWALL=0` to disable the
firewall entirely (debugging only).
@@ -278,8 +520,8 @@ Approved domains only take effect at container start. For a domain a
sandbox — it has no privilege to do this itself):
```sh
bin/dejima-allow crates.io # this session only
bin/dejima-allow crates.io --global # + persist for every project
dejima-allow crates.io # this session only
dejima-allow crates.io --global # + persist for every project
```
It first adds the domain to the running dnsmasq's allowlist (appends a
@@ -471,7 +713,7 @@ GitHub and let `git-push-guard.sh` accept `github.com`/`ssh.github.com` as a
push destination (the no-direct-to-main rule still applies):
```sh
ALLOW_GITHUB_PUSH=1 bin/dejima ~/repos/some-project
ALLOW_GITHUB_PUSH=1 dejima ~/repos/some-project
```
**Read this before turning it on.** Whether a push actually succeeds depends
@@ -492,303 +734,31 @@ make HTTPS push possible independent of `ALLOW_GITHUB_PUSH`, so don't use
one.
`ALLOW_GITHUB_PUSH` is a per-launch variable, not a persistent toggle — it
only affects the one `bin/dejima` invocation it's set for.
only affects the one `dejima` invocation it's set for.
## Remote access
**Opt-in, and off entirely unless you create `~/.config/dejima/remote.conf`.**
Reach a running sandbox from another machine or a phone - over SSH or a
browser - and get a push when a session wants you. Two front doors and a
notification path, none of which exist until you configure them.
It is documented separately because getting the bind address wrong is the one
mistake here that exposes a `--dangerously-skip-permissions` shell to your
network, and that deserves more room than a section.
**→ [docs/remote-access.md](docs/remote-access.md)** - `remote.conf`, picking a
bind address and verifying it, the SSH and web front doors, reading and typing
on a phone, running behind another reverse proxy, notifications, and attaching.
## Security notes
**Threat model, honestly stated:** this reduces the blast radius of a
misbehaving or prompt-injected coding agent - it does not make it safe to
point at arbitrary untrusted or adversarial input, and it does not turn
`--dangerously-skip-permissions` into something without real risk. Point it
at repositories you already trust. Everything below is about how bad a
compromised session *inside* that trust boundary can get, not a claim that
one can't happen.
What this sandbox does and does not protect you from - the containment
boundary, what a compromised session can still reach, the deliberate
trade-offs behind `--dangerously-skip-permissions`, and why the exit-time
audit's findings are a time window, not a verdict on who caused them.
- `--dangerously-skip-permissions` removes your chance to review tool calls
before they run. Only point this at trusted repositories.
- The mounted project itself has **no write protection**. `deny-dangerous-
bash.sh` and `git-push-guard.sh` are regex pattern-matching on the literal
Bash command string - a fast-fail guard against an overeager agent doing
something obviously destructive, not a boundary against one that's
actively trying to get around them (a different scripting language, an
encoded command, editing `.git/config` directly all sail right through).
Nothing here stops a compromised or badly-instructed session from
modifying or deleting the real working copy it's mounted against.
- That same read-write access to the mounted project is also a
host-code-execution vector, not just a data-destruction one: a
compromised session could plant `.git/hooks/post-commit` (or
`pre-push`), set `core.hooksPath`, or write a
`.claude/settings.local.json` with a malicious hook command, into the
project. Any of those run unsandboxed on the host the next time you run
`git` or open Claude Code in that repo - no network egress required, so
the firewall doesn't see it either. Same underlying persistence pattern
as the `~/.claude/hooks` and `~/.claude.json` risks above, just through
the project mount instead of the config mount. "Only point this at
trusted repositories" (above) covers input trust; this is about the
sandbox writing a persistence payload into an otherwise-trusted repo.
The workspace's own `.git/hooks` directory is now layered read-only
(same pattern as the `~/.claude` overlays below), so planting a hook
file directly is closed - accepted breakage: installing hooks
*in-session* (`husky`, `pre-commit install`) now fails, which is the
point. `core.hooksPath` and `.claude/settings.local.json` aren't
overlaid (they're config, not fixed paths a static mount can cover) and
remain fully writable; the exit-time audit below reports changes to
either, but only after the session ends - it doesn't stop them.
- `bin/dejima-audit` snapshots host-persistence surfaces before each
run and diffs them after the container exits: `.git/config` (and worktree
`config.worktree`) keys that run a command on the next `git` -
`core.hooksPath`, `core.fsmonitor`, `core.sshCommand`,
`credential.helper`, `core.pager`, `pager.*`, `core.editor`,
`sequence.editor`, `gpg.program`, `diff.external`, `merge.*.driver`,
`diff.*.command`, `filter.*.smudge`/`clean`/`process`, `diff.*.textconv`,
`include.path`/`includeif.*.path` (config indirection - these can point at
a planted file that itself sets an exec key, so the exec key never
appears in `.git/config` directly), `!`-shell aliases - plus content
changes under the workspace's `.claude/`, `.mcp.json`, `.envrc`,
`.vscode/`, and `.git/hooks/` (symlinks included - a repointed symlink is
hashed by its target, not just its content), plus cross-project writes
under `~/.claude/projects` (see below). When the mounted workspace *is*
this repo's own sandbox itself (see the trusted-computing-base note
further below), it also diffs content hashes of the sandbox's own trusted
computing base - `bin/`, `hooks/`, `docker/`, `install.sh`,
`test/` - under that workspace. It's **advisory only**: it fires *after*
the session has already ended, so a report of a planted `core.hooksPath`
or hook file is something to read and clean up *before* the next `git`
or `claude` in that repo, not something that was blocked. It's silent when nothing
changed, and never affects the session's own exit code even if it can't
complete. It doesn't cover `$SANDBOX_SHARE_DIR` (see "Shared scratch
space") - that hand-off path stays unaudited - and its coverage is a
fixed list of known Claude Code config surfaces, not a general integrity
check, so a future config surface this list hasn't been taught about yet
would go unreported.
- `~/.claude/projects` (session transcripts and per-project memory) is
**not** mounted read-only, unlike the other `~/.claude` overlays above -
deliberately. Scoping a read-only overlay to "the current project's slug"
would break the sandbox's own worktree workflow: a session running in
`WORKSPACE/.worktrees/<branch>` writes to a *different* projects slug
than the parent repo, so a slug-scoped overlay would either block that
legitimate write or have to be widened until it stopped protecting
anything. Instead, cross-project writes are **detected, not prevented**:
the exit-time audit above flags any write during the session to another
project's `~/.claude/projects/<slug>` directory, excluding the current
project's own slug and its worktree children. That's after-the-fact
reporting of transcript/memory poisoning in *other* projects, not a
block on it happening. Residual even within that: the current project's
own transcripts/memory stay fully writable throughout, since Claude Code
needs that for host-side `claude --resume` to work; and detection keys
off each slug's max file mtime, so an edit to a file that isn't already
that project's newest doesn't advance the max and goes unflagged. A
read-only mount for `projects/` remains a possible future option if
Claude Code is confirmed to fail cleanly (not hang, as `~/.claude.json`
did) when a write to it is blocked.
- Allowing egress to a domain at all creates a channel for exfiltrating data
through *that service's own request surface* (query strings, lookups,
etc.), not just to a literal attacker-controlled destination. Domain-level
allowlisting closes off reaching somewhere new; it can't inspect or
restrict what gets sent to somewhere already trusted for normal operation
(installing packages, calling the model API). That's a structural limit of
allowlist-only firewalls in general, not something specific to this setup,
and nothing here addresses it.
- This is container-level isolation (Linux namespaces plus a small
capability set), not VM-level isolation - no gVisor/Firecracker or
similar. It shares the host kernel, so a container-escape vulnerability
would defeat every other control described here at once.
- Everything in this document has been verified by hand, at a point in
time - there's no automated test suite re-checking these properties on
every change, so a future edit could silently reintroduce a gap. This has
already happened once in this file's own history (a missing `ipset`
dedup flag broke firewall startup entirely, twice, on two different
ipsets, before being caught).
- The Anthropic OAuth credential (`~/.claude/.credentials.json`) never
enters the container. `bin/dejima` starts a host-side proxy
(`bin/dejima-anthropic-proxy`) on a per-session unix socket,
mounts an empty file over `.credentials.json` (the parent `~/.claude`
mount stays read-write, so a single-file mask is the mechanism), and
hands Claude Code a placeholder token plus an `ANTHROPIC_BASE_URL`
pointing at a loopback forwarder that pipes into the socket. The proxy
strips the placeholder, injects the real access token per request, and
owns OAuth refresh - rotated tokens are written back to the real host
file atomically, so host-side sessions stay logged in. What remains is
session-scoped: a compromised session can still *use* the model API as
you while it runs (that's inherent to the sandbox working at all), but
it can no longer read a durable credential and exfiltrate it for
offline use via the other allowed egress destinations (npm, GitHub,
your Gitea host). Caveats: `/login` inside the container doesn't work
while the proxy is on; the refresh flow mirrors a Claude Code internal
and may break if upstream changes it, in which case the proxy fails
loudly (launch abort, 502s on API calls) rather than silently.
`SANDBOX_CREDENTIAL_PROXY=0` is the kill switch, restoring the previous
behavior - the credential mounted into the container, readable by a
compromised session. Setups without a `claudeAiOauth` block (API-key
auth) skip the proxy automatically; there's no stored subscription
credential to protect.
- `settings.json`, `CLAUDE.md`, `plugins/`, `hooks/`, `skills/`, `commands/`,
`agents/`, and `output-styles/` under `~/.claude` are layered back
read-only on top of the (otherwise read-write) `~/.claude` mount. Hooks
execute automatically with no separate approval step, skills can ship
executable scripts, and commands/agents/output-styles are prompt text
loaded into a later host session, so without this a compromised session
could plant a backdoored hook - including by unlinking one of
`install.sh`'s hook symlinks and dropping a plain file in its place, since
the dynamic per-target mounts `bin/dejima` adds at launch only
protect symlink targets that resolve *outside* `~/.claude`, never this
directory itself - rewrite `CLAUDE.md` with injected instructions, tamper
with plugin code, or drop a malicious skill/command/agent/output-style
file - something that would then run or load **unsandboxed on the host**
the next time Claude Code starts there, turning a single compromised
sandbox session into persistent host compromise. `skills/`, `commands/`,
`agents/`, and `output-styles/` may not exist yet on a given host, so
`bin/dejima` pre-creates them at launch (empty is fine) - a
read-only overlay only applies to a directory that already exists, and
without pre-creation a compromised session could create the directory
itself first, then populate it, before the overlay ever had something to
sit on top of. Everything else under `~/.claude` (session/project
history, caches) stays read-write, since it's runtime state Claude Code
needs to update - this list is a denylist of
known code/config surfaces, not an allowlist, so it has to be kept in
step with whatever new config surfaces Claude Code adds.
- `~/.claude/statusline.sh` (referenced by `settings.json`'s
`statusLine.command` and executed on the host) is bind-mounted onto
itself read-only by `bin/dejima`, for the same reason as the
list above - to stop a compromised session from unlinking it and dropping
its own script in its place. **Confirmed against a live container**
(`test/runtime.bats`'s statusline.sh tests): this reliably blocks
replacement when `statusline.sh` is a plain regular file. It does
**not** when `statusline.sh` is a symlink (the common case - typically
into a dotfiles checkout): Docker/runc resolves the bind-mount
destination through the symlink to its target directory rather than
binding to the symlink dentry itself, so the symlink entry stays on the
writable `~/.claude` mount, still replaceable. There is no mount-based
fix for this (`~/.claude` can't be made read-only - session history has
to stay writable). The compensating control is detection, not
prevention: `bin/dejima-audit` hashes `statusline.sh` (following
a symlink to its target *path string*, so a repointed or replaced
symlink is caught even though its own inode doesn't change) and reports
it as a host-code-execution finding when the session exits. This is a
defense-in-depth gap, not an open one: exploiting it requires a sandbox
session that's already compromised, and that compromise is caught on
exit either way.
- `~/.claude.json` mixes that same kind of live state (project registry,
usage stats, caches) with config (`mcpServers`). Unlike the four above,
it can't just be layered read-only: Claude Code does an atomic
write-temp-then-rename against it during normal startup, before login
even completes, and a read-only file makes that **hang** instead of
failing cleanly (confirmed against a real read-only copy of this file -
upstream bug: [anthropics/claude-code#29162](https://github.com/anthropics/claude-code/issues/29162)).
Instead, `bin/dejima` generates a throwaway copy of the file at
launch (seeded from the real file, same "generate a sandbox-specific
file on the host" pattern as the generated `settings.json` copy above)
and mounts *that* read-write in its place; the copy is discarded,
unmodified in the real file, when the session ends. A compromised
session can add whatever it wants to `mcpServers` (or anything else in
the file) - it never reaches the real `~/.claude.json`, so there's
nothing to persist. This also means multiple concurrent sandbox sessions
never contend over the same file - each gets its own independent copy.
### Throwaway ~/.claude.json: what doesn't carry over
Because nothing writes back, anything a *sandboxed* session would
normally have caused Claude Code to record into `~/.claude.json` is lost
when that session ends, instead of showing up in a later host-side
`claude` run. Concretely, as of the fields this file holds today:
- **Per-project trust/approval state** - `hasTrustDialogAccepted`,
`allowedTools`, `enabledMcpjsonServers`/`disabledMcpjsonServers` - for
any project directory opened only inside the sandbox. Opening that same
project on the host later re-prompts for whatever it would have
prompted for outside the sandbox too. Largely moot today since the
sandbox always runs `--dangerously-skip-permissions`, but the trust
dialog is a separate gate from tool-permission prompts.
- **`mcpServers` additions or edits** - the point of this fix, not an
incidental side effect. Nothing added or changed here during a
sandboxed session ever reaches the real file.
- **Session/usage telemetry** - `lastCost`, `lastDuration`,
`lastTotalInputTokens`/`lastTotalOutputTokens`, `lastModelUsage`,
`lastSessionId`, `mcpContextUris`, and similar per-project fields.
Sandbox activity won't show up in host-side usage totals or in
`claude --resume`'s "last session" metadata for that project.
- **Global counters and caches** - `numStartups`, `firstStartTime`,
`cachedGrowthBookFeatures`, `changelogLastFetched`,
`announcementImpressions`, `tipsHistory`, `feedbackSurveyState`, and
similar top-level bookkeeping. Purely cosmetic - these get refetched or
recomputed as needed; sandbox runs just don't contribute to the host's
copies of them.
- **`oauthAccount`/`userID`/`machineID`** are read fresh from the real
file at the start of every sandbox launch, so authentication carries in
normally each time; anything that changes about them *during* a
sandboxed session (e.g. a token refresh) doesn't carry back out. Worth
re-checking if a future Claude Code version starts relying on that for
normal operation.
- DNS resolution itself is allowlisted (see "DNS is allowlisted too" above),
not just the IP connections made afterward, closing off exfiltration via
encoding data into query names to an attacker-controlled domain - verified
a non-allowlisted domain gets refused outright (`status: REFUSED`) rather
than resolving and then only failing to connect.
- Don't mount other host secrets (`~/.ssh` private keys, cloud credential
files) into the container. Git push auth goes through the forwarded SSH
agent instead, which never exposes the key material itself - though note
the agent will sign for *any* Gitea repo your key has access to, not just
the mounted project, and the firewall's Gitea allowlist is host-based
rather than repo-scoped, so a compromised session can still reach (and
push to) other repos on that host you have access to. This doesn't extend
to GitHub by default: port 22 there is blocked unless `ALLOW_GITHUB_PUSH=1`
is set, so the forwarded agent has no transport to use for it at all
otherwise (see "GitHub push: blocked by default, optional opt-in" above).
- `SANDBOX_SSH_KEY` (see "Headless git push without agent forwarding") is a
second, independent credential-loading path with the same "signs for any
Gitea repo it has access to" shape as the forwarded agent above - the
mitigation is that it's meant to be a dedicated deploy key scoped to only
the repos that actually need push access, not your personal identity.
That scoping is a convention, not something enforced here: nothing checks
what `SANDBOX_SSH_KEY` actually points at, so pointing it at your real
`~/.ssh/id_ed25519`/`id_rsa` instead of a dedicated key would load your
full personal identity into the container for the whole session (`bin/
dejima` warns if this looks like it might be happening - see
below - but doesn't block it). The key source itself is mounted read-only
at `/root/...`, not under the sandbox user's own home, specifically so
that user (which the session actually runs as) has no path to read the
raw key material - only the agent socket it's loaded into.
- The gitconfig copy `bin/dejima` generates (see that mount's
comment in `docker-compose.yml`) carries over whatever's in your real
`~/.gitconfig` verbatim. If yours has inline credentials (a
`[credential]` token, a `url.<token@>.insteadOf` rewrite, a
`credential.helper store` path), those enter the container along with
it - an unadvertised secret-carrying path distinct from the
SSH-agent/token story documented above. Keep credentials out of your
global gitconfig, or expect them to be reachable from inside the
sandbox.
- The sandbox's own user (matching your host user — see "Config parity"
above) has no `sudo`/setuid path back to root once the container is up:
`entrypoint.sh` runs as root, sets up the firewall directly, then drops to
that user via `gosu` before execing `$SANDBOX_CMD`. A compromised session can't
re-run `init-firewall.sh` with a wider allowlist to open its own
exfiltration path. Verified: capabilities (`NET_ADMIN`/`NET_RAW`) are
cleared from the effective/permitted sets on the `gosu` drop, and that
user gets a permission error running `iptables` directly.
- The approval store (`${DEJIMA_CONFIG_DIR:-$HOME/.config/dejima}`,
see "Global and per-project domains and dependencies" above) lives outside
this repo, and outside every project mount, by default - unlike a
project's own `.dejima/` declaration files, it's never
bind-mounted into the container, so a compromised
session has no path to write to it at all, let alone plant something for
the *next* launch to pick up. Its files are read by a small
line-oriented parser (`approved_entries` in
`bin/dejima-project-lib.sh`), never sourced or executed, and are
written only by the CLIs (`dejima-allow`, `dejima-dep`,
`dejima-project`) a human runs on the host. `bin/dejima`
warns at launch whenever the resolved store path — default or overridden
via `DEJIMA_CONFIG_DIR` — lands under the mounted workspace,
rather than silently trusting it.
- When the mounted workspace *is* this repo itself, `bin/dejima`
warns at launch that the repo's own trusted computing base - `bin/`,
`hooks/`, `docker/`, `install.sh` (also `test/`, per the
exit-time audit's exact TCB path list above) - is writable for the
session (it's inside the project mount, which has no write protection,
per above), and that the exit-time audit will report any changes to it.
That's a warning and a post-hoc report, not a block: a compromised
session can still edit those files during the run, and whatever it
changes takes effect the *next* time `bin/dejima` itself runs,
not the current one.
**→ [docs/security.md](docs/security.md)**
## Tests
@@ -799,13 +769,19 @@ instead of finding out the hard way:
```sh
bats test/ # everything
bats test/static.bats # fast, no Docker/build needed
bats test/static.bats test/project.bats test/remote.bats test/audit.bats # fast, no Docker/build needed
SKIP_BUILD=1 bats test/runtime.bats # reuse the current image
```
`.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.
`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` read-only overlay lines are present, `bin/dejima-allow`
`~/.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 `$SANDBOX_CMD`, so the suite can attach
@@ -816,6 +792,24 @@ enforcement in both directions, GitHub read-only, no working `sudo`/
(not just absent from a `docker exec` that doesn't have them to begin
with), `ulogd2` unprivileged, and the dynamic-ipset CDN-rotation fix.
The other three 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](docs/remote-access.md):
`remote.conf` parsing, 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.
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
simply reaches nothing. 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
@@ -826,7 +820,7 @@ likely to collide with a real container you have running.
| File | Purpose |
| --- | --- |
| `docker/Dockerfile` | Base image, dev tools, nvim, Claude Code + wrapper, `gitea-pr` |
| `docker/Dockerfile` | Base image, dev tools, nvim, Claude Code + wrapper, `gitea-pr`/`gitea-issue` |
| `docker/fixed-domains.sh` | Shared domain list sourced by both `init-firewall.sh` and `dns-proxy.sh` |
| `docker/dns-proxy.sh` | Starts the DNS-allowlisting `dnsmasq`, repoints `resolv.conf` at it |
| `docker/init-firewall.sh` | IPv4/IPv6 egress allowlist firewall, run at container start |
@@ -838,10 +832,19 @@ likely to collide with a real container you have running.
| `hooks/sandbox-context.sh` | `SessionStart` hook: injects sandbox context/workflow reminders |
| `hooks/git-push-guard.sh` | `PreToolUse` hook: blocks pushes outside the allowed host(s)/branches |
| `hooks/deny-dangerous-bash.sh` | `PreToolUse` hook: blocks obviously destructive Bash commands |
| `hooks/gitea-merge-guard.sh` | `PreToolUse` hook: blocks `gitea-pr merge` 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](docs/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.) |
| `bin/dejima-dep` | Review and approve project dependencies declared in `.dejima/dependencies` |
| `bin/gitea-pr` | Create a PR, read its comments, merge it; baked into the image so the sandbox can open PRs |
| `bin/gitea-issue` | List, view, create, comment on, close and reopen Gitea issues; also baked into the image |
| `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`, and `bin/dejima`; not on PATH |
| `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-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/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`, loads `GITEA_TOKEN`/`GH_TOKEN` if not already
+393 -94
View File
@@ -52,17 +52,36 @@ if [ -n "$PROJECT_STORE" ]; then
ALLOWED_DOMAINS="${ALLOWED_DOMAINS}${project_domains:+ $project_domains}"
fi
# The approval store is only out of the container's reach while it isn't
# inside the mounted workspace - same class of warning as the self-mounted
# repo TCB warning below.
case "$(config_root)/" in
"$WORKSPACE_DIR"/*)
echo "Warning: the approval store ($(config_root)) is inside the mounted" >&2
echo "workspace this session - both the global approvals and every" >&2
echo "project's under it are writable from inside the sandbox, so they" >&2
echo "are no longer out of reach. Review them after this session." >&2
;;
esac
# The approval store, and the exit-audit's own report/baseline stores, are
# only out of the container's reach while none of them are inside the
# mounted workspace - same class of warning as the self-mounted repo TCB
# warning below.
AUDIT_DIR="${DEJIMA_AUDIT_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/dejima/audit}"
BASELINE_DIR="${DEJIMA_AUDIT_BASELINE_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/dejima/baselines}"
# The three stores fail in three different ways once they are reachable, and
# an operator who sees this needs to know which one they are looking at - so
# each states its own consequence rather than sharing one text. Consequence
# lines are hand-wrapped by the caller and printed one per argument.
warn_if_inside_workspace() {
local what="$1" store="$2"; shift 2
case "$store/" in
"$WORKSPACE_DIR"/*) ;;
*) return 0 ;;
esac
echo "Warning: the $what ($store) is inside the mounted" >&2
echo "workspace this session, writable from inside the sandbox:" >&2
printf ' %s\n' "$@" >&2
echo "Review it after this session." >&2
}
warn_if_inside_workspace "approval store" "$(config_root)" \
"the global approvals and every project's under it can be edited," \
"so a session can approve its own domains and dependencies."
warn_if_inside_workspace "audit report store" "$AUDIT_DIR" \
"a session can delete the exit-time reports of earlier runs," \
"including unacknowledged CRITICAL ones it is named in."
warn_if_inside_workspace "audit baseline store" "$BASELINE_DIR" \
"a session can write its own acknowledgements, suppressing the" \
"findings it would otherwise produce here from every later report."
SANDBOX_SSH_KEY="${SANDBOX_SSH_KEY:-}"
if [ -n "$SANDBOX_SSH_KEY" ] && [ ! -f "$SANDBOX_SSH_KEY" ]; then
@@ -139,28 +158,60 @@ fi
SANDBOX_SHARE_DIR="${SANDBOX_SHARE_DIR:-$HOME/.local/share/dejima/shared}"
mkdir -p "$SANDBOX_SHARE_DIR"
# ~/.claude/CLAUDE.md (carried into the container) tells agents to write
# specs to ~/specs/ rather than a project-local path - make sure that
# actually exists before it's bind-mounted, for a fresh machine that hasn't
# used the convention yet.
mkdir -p "$HOME/specs"
# ~/.claude is no longer mounted as a whole (see the mount inventory in
# docker-compose.yml) - every path that crosses into the container is named
# individually, and Docker answers a missing bind source by creating a
# root-owned *directory* at it. That would leave junk on the host and, for
# the read-only config dirs, a writable gap instead of an overlay. Pre-create
# them all: empty is fine, existence is what matters.
mkdir -p "$HOME/.claude/skills" "$HOME/.claude/commands" "$HOME/.claude/agents" \
"$HOME/.claude/output-styles" "$HOME/.claude/hooks" "$HOME/.claude/plugins"
mkdir -p "$HOME/.claude/projects" "$HOME/.claude/file-history" "$HOME/.claude/backups" \
"$HOME/.claude/tasks" "$HOME/.claude/plans" "$HOME/.claude/sessions"
# skills/commands/agents/output-styles overlays (see docker-compose.yml) can
# only apply if the source dirs exist - pre-create them so a host that has
# never used one still gets the read-only overlay instead of a writable gap.
mkdir -p "$HOME/.claude/skills" "$HOME/.claude/commands" "$HOME/.claude/agents" "$HOME/.claude/output-styles"
# Same for settings.json, which install.sh normally creates, and CLAUDE.md,
# which many hosts don't have at all.
[ -f "$HOME/.claude/settings.json" ] || echo '{}' > "$HOME/.claude/settings.json"
[ -f "$HOME/.claude/CLAUDE.md" ] || : > "$HOME/.claude/CLAUDE.md"
# Claude Code ignores a settings.json it can't parse, wholesale and with only
# a startup warning, so a stray edit on the host costs the sandbox every
# permission, hook and plugin the operator configured - including
# skipDangerousModePermissionPrompt, which is why the first visible symptom is
# an unexpected bypass-mode warning rather than anything mentioning settings.
# It used to be caught by accident: the sandbox copy was generated by running
# the host file through jq, and set -e stopped the launch. The file is staged
# verbatim now, so check it here instead, where the fix is at hand.
if ! settings_error="$(jq . "$HOME/.claude/settings.json" 2>&1 >/dev/null)"; then
echo "$HOME/.claude/settings.json is not valid JSON:" >&2
echo " $settings_error" >&2
echo "Claude Code would ignore it entirely and the sandbox would start with" >&2
echo "none of your settings. Fix it, then retry." >&2
exit 1
fi
# Claude Code's own internal per-command sandboxing (~/.claude/settings.json's
# "sandbox" block) is redundant here - it can't even function inside this
# container (bwrap can't create a namespace, and getting it to would need
# privilege escalation approaching --privileged, undoing this container's own
# hardening), and this container is already a real, working sandbox on its
# own. settings.json is mounted read-only precisely so a session can't
# tamper with it, so it can't be patched from inside - generate a sandbox-
# specific copy here instead and mount that in place of the real one.
# Everything else in it (hooks, permissions, plugins) carries over unchanged.
# own. A session can't be trusted to turn it off itself, so the override is
# generated here, on the host, and reaches Claude Code as an extra settings
# layer: docker-compose.yml mounts this file read-only at a container-local
# path and claude-wrapper.sh passes it as --settings, which loads above
# ~/.claude/settings.json in the precedence order. Everything else in the
# operator's real settings (hooks, permissions, plugins) still applies from
# there, so this file holds only what the sandbox overrides.
#
# The earlier arrangement - a full modified copy of the operator's settings,
# mounted over ~/.claude/settings.json - detached itself whenever the host
# rewrote the real file, because that mount point sits inside the read-write
# ~/.claude mount and a rename unlinks the dentry the mount is pinned to. The
# session then silently fell back to the operator's settings, with sandboxing
# back on and the notify hooks below gone. Mounting outside ~/.claude removes
# that: nothing on the host can rename over a container-local path. See #47.
SANDBOX_SETTINGS_FILE="$(mktemp "${TMPDIR:-/tmp}/dejima-settings.XXXXXX.json")"
jq '.sandbox.enabled = false' "$HOME/.claude/settings.json" > "$SANDBOX_SETTINGS_FILE"
jq -n '{sandbox: {enabled: false}}' > "$SANDBOX_SETTINGS_FILE"
# ~/.claude.json mixes live app state (project registry, usage stats,
# caches) with config (mcpServers) that can't be split at the mount level -
@@ -188,12 +239,74 @@ fi
SANDBOX_AUDIT_STATE="$(mktemp "${TMPDIR:-/tmp}/dejima-audit.XXXXXX")"
# Initialized here, before the trap below is installed, so cleanup() never
# observes a nonempty value it didn't itself set - e.g. an operator's
# environment happening to export a variable of one of these exact names.
# With remote access off (or on an early exit before the remote-access block
# below ever runs), `rm -rf "$DEJIMA_NOTIFY_DIR"` against an inherited value
# would be a data loss bug, not just a no-op - and several `exit 1` paths
# between here and that block would otherwise fire the trap while these
# still held whatever came in from the environment (same class of concern as
# the credential-proxy variables further down, pre-initialized the same way,
# just earlier here since the trap is installed sooner). Every name cleanup()
# removes but does not itself create belongs here, not just the remote-access
# ones: the three below are assigned only inside conditional blocks (an nvim
# config being present, dependencies being approved), so an ordinary run with
# neither would otherwise carry an inherited value all the way to the trap -
# and SANDBOX_DERIVED_CONTEXT is one of the `rm -rf` arguments.
DEJIMA_NOTIFY_DIR=""
DEJIMA_SESSION_FILE=""
SANDBOX_REMOTE_SETTINGS_TMP=""
SANDBOX_NVIM_LOCKFILE=""
SANDBOX_DERIVED_DOCKERFILE=""
SANDBOX_DERIVED_CONTEXT=""
cleanup() {
# Every rm below is guarded with `|| true`: under set -e, a failing
# command in an EXIT trap replaces the shell's own exit status, so none
# of these may be allowed to abort the trap partway through. Most of
# these paths are host-mktemp'd and not session-writable, so a genuine
# failure there would be surprising - but SANDBOX_GITCONFIG_DIR is not:
# it's bind-mounted read-write into the container (see
# docker-compose.yml), so a session can leave an undeletable entry
# inside it (e.g. an unwritable subdirectory) and make its `rm -rf`
# fail, same hazard and same guard as the marker-directory removal
# further down.
#
# SANDBOX_GITCONFIG_DIR additionally carries a `:-` default because it is
# the one name here first assigned BELOW `trap cleanup EXIT` rather than
# above it. Nothing in that window can exit today (it holds only
# comments, and a failed `VAR="$(mktemp -d)"` still binds VAR to the
# empty string before set -e fires), so this is the invariant every other
# name here already keeps, not a live fix - see the static test.
if [ -n "${SANDBOX_ANTHROPIC_PROXY_PID:-}" ]; then
kill "$SANDBOX_ANTHROPIC_PROXY_PID" 2>/dev/null || true
fi
rm -f "$SANDBOX_SETTINGS_FILE" "$SANDBOX_CLAUDE_JSON_FILE" "${SANDBOX_NVIM_LOCKFILE:-}" "$SANDBOX_AUDIT_STATE" "${SANDBOX_CREDENTIALS_MASK_FILE:-}" "${SANDBOX_DERIVED_DOCKERFILE:-}"
rm -rf "$SANDBOX_GITCONFIG_DIR" "${SANDBOX_ANTHROPIC_PROXY_DIR:-}" "${SANDBOX_CREDENTIALS_TMPDIR_MASK:-}" "${SANDBOX_DERIVED_CONTEXT:-}"
rm -f "$SANDBOX_SETTINGS_FILE" "$SANDBOX_CLAUDE_JSON_FILE" "${SANDBOX_NVIM_LOCKFILE:-}" "${SANDBOX_FAKE_CREDENTIALS_FILE:-}" "${SANDBOX_DERIVED_DOCKERFILE:-}" || true
rm -rf "${SANDBOX_GITCONFIG_DIR:-}" "${SANDBOX_ANTHROPIC_PROXY_DIR:-}" "${SANDBOX_DERIVED_CONTEXT:-}" || true
rm -f "${DEJIMA_SESSION_FILE:-}" "${SANDBOX_REMOTE_SETTINGS_TMP:-}" || true
# Unlike the silent `|| true` guards above, this removal (and, before
# the audit below was moved in, the last command of an EXIT trap) warns
# out loud when it fails: the marker directory is bind-mounted
# read-write, so a session can leave it undeletable (an unwritable
# subdirectory is enough), and under set -e a failing command in an EXIT
# trap REPLACES the shell's exit status - so without this a compromised
# session could report 1 in place of Claude Code's real exit code. Same
# guard, for the same reason, as dejima-attach's prune and
# dejima-notify-watch's marker consumption.
rm -rf "${DEJIMA_NOTIFY_DIR:-}" \
|| echo "warning: could not remove the marker directory ${DEJIMA_NOTIFY_DIR:-}" >&2
# Last writer, structurally: the operator reads bottom-up from their
# shell prompt, so anything printed after the verdict pushes it out of
# view. The marker-directory warning above is the one line a session can
# deliberately provoke, so the audit runs after it, not before - and gets
# the same set -e guard for the same reason: a failing audit invocation
# must not replace Claude Code's real exit status either. SANDBOX_AUDIT_
# STATE is dropped from the rm -f above and freed only after compare has
# read it, so this function's own cleanup can't race the audit that
# needs it - and its removal is guarded too, so it stays truly last.
"$SCRIPT_DIR/dejima-audit" compare "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
rm -f "$SANDBOX_AUDIT_STATE" || true
}
trap cleanup EXIT
@@ -246,14 +359,16 @@ export TZ="${TZ:-$(readlink -f /etc/localtime 2>/dev/null | sed 's#.*/zoneinfo/#
# path for this - discover what's actually needed here instead, so it's
# correct regardless of what's installed or where this repo itself was
# cloned to.
# "." covers top-level symlinked files directly under ~/.claude itself -
# e.g. ~/.claude/statusline.sh -> dotfiles' statusline script, referenced
# by settings.json's statusLine.command. Same trust model as hook code:
# the target executes on the host too, so read-only is the right mount.
# Only the directories that are actually mounted in: the top-level files
# (settings.json, CLAUDE.md, statusline.sh) are staged as copies instead, and
# Docker resolves any symlink among them when it sets up that staging mount,
# so their targets don't need to be reachable from inside at all. Scanning
# "." here too would mount a whole config checkout read-only into the
# container for nothing.
extra_mounts=()
mounted_dirs=""
for overlay_dir in . hooks skills commands agents output-styles; do
dir="$HOME/.claude/$overlay_dir"
for mounted_dir in hooks skills commands agents output-styles; do
dir="$HOME/.claude/$mounted_dir"
[ -d "$dir" ] || continue
for entry in "$dir"/*; do
[ -L "$entry" ] || continue
@@ -327,41 +442,28 @@ if [ -d "$WORKSPACE_DIR/.git/hooks" ]; then
extra_mounts+=(-v "$WORKSPACE_DIR/.git/hooks:$WORKSPACE_DIR/.git/hooks:ro")
fi
# ~/.claude/statusline.sh is the same class of file as the compose overlays
# on settings.json/CLAUDE.md/plugins/hooks/skills/commands/agents/output-
# styles above (see docker-compose.yml's comments on those): settings.json's
# statusLine.command points at it, and Claude Code executes it on the host.
# Unlike those, it has no overlay of its own - it's a top-level *file*
# directly on the read-write ~/.claude mount, so a compromised session can
# unlink it and drop its own script in its place. The symlink-target scan
# above (the "." entry in overlay_dir) only protects the case where
# statusline.sh is a symlink by mounting read-only where it *points* - it
# doesn't touch the symlink/file itself, which still lives on the writable
# mount. Conditional on existence because not every host has one, and
# mounting a nonexistent source would make Docker create a root-owned
# directory at the target instead - worse than the gap this closes.
# ~/.claude/statusline.sh is the same class of file as the config staged at
# /etc/dejima-claude by docker-compose.yml: settings.json's statusLine.command
# points at it, and Claude Code executes it on the host. Staged the same way,
# for entrypoint.sh to copy into place, rather than mounted at its real path.
#
# Confirmed against a live container (test/runtime.bats), not just reasoned
# about: this mount reliably protects only the plain-regular-file case. When
# statusline.sh itself is a symlink (the common case - typically into a
# dotfiles checkout), it does NOT protect it. The parent ~/.claude mount
# above is established first, so by the time this mount is processed the
# destination already has that symlink sitting on it, and bind-mount
# destination resolution (runc's SecureJoin) follows symlinks within the
# container's rootfs - so this :ro mount resolves through the symlink to the
# target directory (already read-only from the symlink-target scan above,
# making this mount a no-op there) instead of binding to the symlink dentry
# itself, which stays on the writable ~/.claude mount and remains
# replaceable. There is no mount-based fix for this: ~/.claude can't be made
# read-only (session history has to stay writable), and mounting over the
# resolved target instead of the symlink path isn't an option either, since
# bin/dejima has no way to force the *destination* resolution to stop
# at the symlink. The compensating control is detection, not prevention:
# bin/dejima-audit hashes the symlink's target string (so a
# repointed or replaced statusline.sh is caught) and reports it as a
# host-code-execution finding after the session exits. Keep this mount
# anyway - it's a real, verified fix for the regular-file case, and a no-op
# rather than a regression for the symlink case.
# It used to be mounted read-only at $HOME/.claude/statusline.sh, and that
# protected only the plain-regular-file case - confirmed against a live
# container, not just reasoned about. When statusline.sh was itself a symlink
# (the common case - typically into a dotfiles checkout), the read-write
# ~/.claude mount was established first, so the destination already had that
# symlink sitting on it, and bind-mount destination resolution (runc's
# SecureJoin) followed it: the :ro mount landed on the target directory
# instead of the symlink dentry, which stayed writable and replaceable. There
# was no mount-based fix while ~/.claude was mounted whole, and the
# compensating control was detection after the fact (bin/dejima-audit hashes
# the symlink's target string). Staging closes it outright: Docker resolves
# the symlink when it sets up the staging mount, so entrypoint.sh copies the
# real content to a path nothing on the host can reach.
#
# Conditional on existence because not every host has one, and mounting a
# nonexistent source would make Docker create a root-owned directory at the
# target instead - worse than the gap this closes.
#
# A *dangling* symlink (target doesn't exist, e.g. a dotfiles checkout that
# isn't present) is neither "exists" nor safely mountable, so it gets its own
@@ -373,30 +475,44 @@ fi
if [ -L "$HOME/.claude/statusline.sh" ] && [ ! -e "$HOME/.claude/statusline.sh" ]; then
echo "$HOME/.claude/statusline.sh is a symlink to a target that doesn't" >&2
echo "exist. Docker can't bind-mount a dangling symlink, and skipping the" >&2
echo "mount would leave it unprotected on the read-write ~/.claude mount." >&2
echo "mount would leave the session with no statusline at all." >&2
echo "Fix or remove the symlink, then retry." >&2
exit 1
elif [ -e "$HOME/.claude/statusline.sh" ]; then
extra_mounts+=(-v "$HOME/.claude/statusline.sh:$HOME/.claude/statusline.sh:ro")
extra_mounts+=(-v "$HOME/.claude/statusline.sh:/etc/dejima-claude/statusline.sh:ro")
fi
# Keep the real Anthropic OAuth credential out of the container entirely:
# run a host-side proxy that injects it per-request (see
# bin/dejima-anthropic-proxy and README "Security notes"), mount an
# empty file over ~/.claude/.credentials.json and an empty read-only
# directory over ~/.claude/.credentials-proxy-tmp (the proxy's write-back
# staging directory - the parent ~/.claude mount must stay read-write, so
# masking these two paths individually is the mechanism), and hand Claude
# Code a placeholder token pointed at a loopback forwarder.
# bin/dejima-anthropic-proxy and docs/security.md). Nothing needs masking
# any more - ~/.claude isn't mounted as a whole, so the real credential (and
# the proxy's .credentials-proxy-tmp staging directory beside it) simply
# never crosses into the container. That masking is what #50 was: both masks
# were single-file/-directory mounts nested in the read-write ~/.claude
# mount, and the proxy's own write-back renamed the credential out from
# under them, detaching the mask and exposing the real token from the first
# refresh on.
#
# How the container then reaches the proxy depends on the transport. In the
# default tls one, entrypoint.sh points api.anthropic.com at an in-container
# TLS terminator, so no ANTHROPIC_* value is set at all and the session is
# indistinguishable from a stock install - which means Claude Code reads
# ~/.claude/.credentials.json, so a fake one is generated below and mounted
# there. SANDBOX_ANTHROPIC_TRANSPORT=loopback is the documented fallback if
# certificate pinning ever makes that impossible: an explicit base URL and
# placeholder token at a plaintext forwarder, as before, with no credential
# file in the container at all.
#
# Skipped when there's no claudeAiOauth block to protect (API-key setups),
# or via the SANDBOX_CREDENTIAL_PROXY=0 kill switch - both restore the old
# behavior exactly (credential mounted in with the rest of ~/.claude).
# or via the SANDBOX_CREDENTIAL_PROXY=0 kill switch. Both then mount the real
# credential in explicitly, which used to come free with the whole-directory
# mount and now has to be stated.
SANDBOX_ANTHROPIC_TRANSPORT="${SANDBOX_ANTHROPIC_TRANSPORT:-tls}"
SANDBOX_ANTHROPIC_BASE_URL=""
SANDBOX_ANTHROPIC_AUTH_TOKEN=""
SANDBOX_ANTHROPIC_PROXY_SOCKET=""
SANDBOX_ANTHROPIC_PROXY_DIR=""
SANDBOX_CREDENTIALS_MASK_FILE=""
SANDBOX_CREDENTIALS_TMPDIR_MASK=""
SANDBOX_FAKE_CREDENTIALS_FILE=""
SANDBOX_ANTHROPIC_PROXY_PID=""
if [ "${SANDBOX_CREDENTIAL_PROXY:-1}" != "0" ] \
&& jq -e '.claudeAiOauth.accessToken' "$HOME/.claude/.credentials.json" >/dev/null 2>&1; then
@@ -419,23 +535,175 @@ if [ "${SANDBOX_CREDENTIAL_PROXY:-1}" != "0" ] \
echo "credential is then mounted into the container, as before)." >&2
exit 1
fi
SANDBOX_CREDENTIALS_MASK_FILE="$(mktemp "${TMPDIR:-/tmp}/dejima-credentials-mask.XXXXXX")"
SANDBOX_CREDENTIALS_TMPDIR_MASK="$(mktemp -d "${TMPDIR:-/tmp}/dejima-credentials-tmpdir-mask.XXXXXX")"
extra_mounts+=(-v "$SANDBOX_ANTHROPIC_PROXY_DIR:$SANDBOX_ANTHROPIC_PROXY_DIR:ro")
extra_mounts+=(-v "$SANDBOX_CREDENTIALS_MASK_FILE:$HOME/.claude/.credentials.json")
# Mount target must exist first, same as the skills/commands/etc. overlays
# above - otherwise Docker auto-creates it root-owned, and the proxy
# (running as the unprivileged host user) can't write its staging temp
# file there on the next refresh.
mkdir -p -m 700 "$HOME/.claude/.credentials-proxy-tmp"
extra_mounts+=(-v "$SANDBOX_CREDENTIALS_TMPDIR_MASK:$HOME/.claude/.credentials-proxy-tmp:ro")
SANDBOX_ANTHROPIC_BASE_URL="http://127.0.0.1:18300"
SANDBOX_ANTHROPIC_AUTH_TOKEN="sandbox-credential-proxy-placeholder"
if [ "$SANDBOX_ANTHROPIC_TRANSPORT" = "loopback" ]; then
SANDBOX_ANTHROPIC_BASE_URL="http://127.0.0.1:18300"
SANDBOX_ANTHROPIC_AUTH_TOKEN="sandbox-credential-proxy-placeholder"
else
# With no ANTHROPIC_AUTH_TOKEN set, Claude Code authenticates from
# .credentials.json directly, so the container needs a well-formed
# one where it would otherwise have no credential file at all. Only
# the two secrets are faked; scopes/subscriptionType/rateLimitTier/
# clientId are copied so the session's own UI reflects the real
# account.
#
# The expiry deliberately does NOT mirror the real credential. A
# near-term expiresAt would make Claude Code refresh on its own,
# against platform.claude.com, with a refresh token that is not real -
# which fails and logs the session out. A year out means it never
# tries; the host proxy owns refresh, as it already did.
SANDBOX_FAKE_CREDENTIALS_FILE="$(mktemp "${TMPDIR:-/tmp}/dejima-fake-credentials.XXXXXX")"
jq --argjson exp "$(( ($(date +%s) + 31536000) * 1000 ))" '
{claudeAiOauth: (
{accessToken: "sandbox-credential-proxy-placeholder",
refreshToken: "sandbox-credential-proxy-placeholder",
expiresAt: $exp,
refreshTokenExpiresAt: $exp}
+ (.claudeAiOauth
| {scopes, subscriptionType, rateLimitTier, clientId}
| with_entries(select(.value != null)))
)}' "$HOME/.claude/.credentials.json" > "$SANDBOX_FAKE_CREDENTIALS_FILE"
chmod 600 "$SANDBOX_FAKE_CREDENTIALS_FILE"
# Read-write: Claude Code rewrites this file on its own schedule, and
# a failed write is a startup error. It writes to the host temp file,
# which the EXIT trap removes.
extra_mounts+=(-v "$SANDBOX_FAKE_CREDENTIALS_FILE:$HOME/.claude/.credentials.json")
fi
elif [ -f "$HOME/.claude/.credentials.json" ]; then
# No proxy: the session authenticates with the real credential, so it has
# to be mounted in. Read-write because Claude Code refreshes it in place.
# This is the documented downgrade, not an oversight - see the kill-switch
# message above and docs/security.md.
extra_mounts+=(-v "$HOME/.claude/.credentials.json:$HOME/.claude/.credentials.json")
fi
export SANDBOX_ANTHROPIC_BASE_URL SANDBOX_ANTHROPIC_AUTH_TOKEN SANDBOX_ANTHROPIC_PROXY_SOCKET
export SANDBOX_ANTHROPIC_TRANSPORT
# Optional remote access (see docs/remote-access.md). Entirely skipped
# unless ~/.config/dejima/remote.conf exists - with it absent nothing below
# runs, no state is created, and the compose invocation is exactly what it
# was before this feature existed.
if remote_enabled; then
DEJIMA_RUN_ID="$(od -An -tx1 -N8 /dev/urandom | tr -d ' \n')"
DEJIMA_STATE_DIR="$(remote_state_dir)"
# The state dir's parent (e.g. /tmp, when XDG_RUNTIME_DIR is unset - the
# common case for exactly the non-systemd ssh sessions this feature
# targets) is world-writable, and `mkdir -p -m 700` only applies MODE to
# the leaf it creates - an already-existing path here (a symlink an
# attacker pre-created, or a directory some other user owns) is left as
# is. Since the notify dir created under it is bind-mounted read-write
# into the container and later `rm -rf`'d by the EXIT trap, following a
# planted symlink would hand a compromised session (or the trap itself)
# access to an attacker-chosen path. Create it, then verify it's a real
# directory we own and not a symlink before trusting it - refuse rather
# than proceed on anything else. Once this check passes, everything
# created below it (sessions/, notify/, notify/<run-id>) is safe: mode
# 700 here means no other user can plant anything inside it afterward.
mkdir -p -m 700 "$DEJIMA_STATE_DIR" 2>/dev/null || true
if [ -L "$DEJIMA_STATE_DIR" ] || [ ! -d "$DEJIMA_STATE_DIR" ] \
|| [ "$(stat -c %u "$DEJIMA_STATE_DIR")" != "$(id -u)" ]; then
echo "Refusing remote access: $DEJIMA_STATE_DIR exists and is not a plain" >&2
echo "directory owned by the current user (or is a symlink). This could be a" >&2
echo "symlink attack via a shared /tmp - remove it, or set XDG_RUNTIME_DIR to" >&2
echo "somewhere else, and retry." >&2
exit 1
fi
chmod 700 "$DEJIMA_STATE_DIR"
mkdir -p -m 700 "$DEJIMA_STATE_DIR/sessions" "$DEJIMA_STATE_DIR/notify"
DEJIMA_SESSION_FILE="$DEJIMA_STATE_DIR/sessions/$DEJIMA_RUN_ID"
DEJIMA_NOTIFY_DIR="$DEJIMA_STATE_DIR/notify/$DEJIMA_RUN_ID"
mkdir -p -m 700 "$DEJIMA_NOTIFY_DIR"
# $TMUX_PANE is set by tmux in the launching shell. Empty when the
# sandbox wasn't started from tmux: the session is still registered, just
# listed as not-attachable, rather than silently omitted.
dejima_tmux_label=""
if [ -n "${TMUX_PANE:-}" ] && command -v tmux >/dev/null 2>&1; then
dejima_tmux_label="$(tmux display-message -p -t "$TMUX_PANE" \
'#{session_name}:#{window_index}' 2>/dev/null || true)"
fi
# A newline in either value would inject an extra key=value line into the
# flat registry entry below - e.g. a fabricated `pane=` line inside a
# crafted label, which the last-match-wins parser (see dejima-attach)
# would honor over the real one, redirecting an operator to an
# attacker-chosen pane. Refuse outright rather than sanitize: a value
# that can't be represented safely here is the same class of problem as
# a corrupt approval store elsewhere in this repo.
case "$WORKSPACE_DIR" in
*$'\n'*)
echo "Refusing remote access: workspace path contains a newline: $WORKSPACE_DIR" >&2
exit 1
;;
esac
case "$dejima_tmux_label" in
*$'\n'*)
echo "Refusing remote access: tmux session/window label contains a newline." >&2
exit 1
;;
esac
# $TMUX_PANE comes from the launching shell's environment, so it is the
# one value here that is taken verbatim rather than derived. It is written
# second of the four lines below, so an injected line lands ahead of
# label= and started= and would lose the parser's last-match-wins race to
# the real ones - but it would WIN against the pane= it is part of, which
# is the field deciding which pane an operator gets attached to. Refusing
# the newline outright is what makes that independent of printf ordering.
case "${TMUX_PANE:-}" in
*$'\n'*)
echo "Refusing remote access: \$TMUX_PANE contains a newline." >&2
exit 1
;;
esac
# Flat key=value, like the rest of the host-side store: no jq dependency
# here, and nothing that reads it ever sources it.
{
printf 'workspace=%s\n' "$WORKSPACE_DIR"
printf 'pane=%s\n' "${TMUX_PANE:-}"
printf 'label=%s\n' "$dejima_tmux_label"
printf 'started=%s\n' "$(date +%s)"
} > "$DEJIMA_SESSION_FILE"
# This run's OWN marker directory is the only thing mounted in - never
# its parent - so a compromised session can write markers for itself and
# for nothing else. The registry is never mounted at all: dejima-attach
# trusts it to decide which pane to attach an operator to, so a session
# able to rewrite it could redirect them.
#
# -e alongside -v here: this array is passed straight to `docker compose
# run`, where both are valid flags.
extra_mounts+=(-v "$DEJIMA_NOTIFY_DIR:/run/dejima-notify")
extra_mounts+=(-e "DEJIMA_NOTIFY_DIR=/run/dejima-notify")
# Lets the host correlate this run to its container via `docker inspect`,
# so the container never needs write access to the registry to report its
# own ID.
extra_mounts+=(-e "DEJIMA_RUN_ID=$DEJIMA_RUN_ID")
# The notify hook is registered per session, into the override layer from
# above and nowhere else: registering it in the operator's real
# settings.json would run it for every sandbox and for the host's own
# Claude Code, which share that file, when it is only meaningful for a
# session that has a marker directory. $SANDBOX_SETTINGS_FILE is
# already a throwaway mktemp file, so this rewrite cannot reach the
# operator's real settings.json; it is mounted read-only, so the
# session cannot unregister itself either. Claude Code merges hooks
# across settings layers rather than letting the higher one replace the
# lower, so these are added to whatever the operator already has rather
# than displacing it. $HOME is left unexpanded for Claude Code to resolve
# at hook-run time - inside the container, not here - matching the
# convention install.sh already uses.
SANDBOX_REMOTE_SETTINGS_TMP="$(mktemp "${TMPDIR:-/tmp}/dejima-settings-remote.XXXXXX.json")"
jq '.hooks.Notification = ((.hooks.Notification // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/dejima-notify.sh waiting"}]}])
| .hooks.Stop = ((.hooks.Stop // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/dejima-notify.sh done"}]}])' \
"$SANDBOX_SETTINGS_FILE" > "$SANDBOX_REMOTE_SETTINGS_TMP"
mv "$SANDBOX_REMOTE_SETTINGS_TMP" "$SANDBOX_SETTINGS_FILE"
fi
# Snapshot host-persistence surfaces so the post-run audit can report
# anything this session changed (advisory - see bin/dejima-audit).
# anything that changed while this session ran (advisory - see bin/dejima-audit).
"$SCRIPT_DIR/dejima-audit" snapshot "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
# When the sandbox is pointed at its own repo, its TCB (bin/, hooks/,
@@ -490,13 +758,44 @@ export SANDBOX_IMAGE
# Not exec'd (the EXIT trap cleans up the generated temp files) and not a
# bare command under set -e (a nonzero/interrupted container run - e.g.
# Ctrl-C returning 130 - would otherwise abort the script here, skipping the
# audit entirely). Capture the status explicitly so the advisory audit runs
# on every exit path, then propagate the real container exit code unchanged.
# credential-proxy check below it). Capture the status explicitly, then
# propagate the real container exit code unchanged - the exit-time audit
# itself now runs unconditionally from cleanup(), not because of this
# capture.
# Unacknowledged CRITICAL findings from earlier runs, re-presented here
# because exit-time output can be ignored exactly once, permanently. This is
# host-side and stays host-side: it must never be routed into the session's
# context, which would make it a cross-session persistence channel.
audit_slug="$(printf '%s' "$WORKSPACE_DIR" | sed 's/[^a-zA-Z0-9]/-/g')"
audit_reports="$AUDIT_DIR/$audit_slug"
# `|| true`: a workspace with no history yet (the common case - nothing has
# ever run against it, or every past finding is already acknowledged) has no
# audit_reports directory at all, and find on a missing path exits nonzero -
# which, combined with pipefail, would otherwise abort this script here for
# every ordinary launch.
pending_crit="$(find "$audit_reports" -maxdepth 1 -name '*-CRITICAL.txt' 2>/dev/null | sort)" || true
if [ -n "$pending_crit" ]; then
n="$(printf '%s\n' "$pending_crit" | wc -l)"
echo "" >&2
echo " dejima: $n unacknowledged CRITICAL finding(s) in this workspace" >&2
printf '%s\n' "$pending_crit" | sed 's#.*/##; s#-CRITICAL\.txt$##; s#^# from run #' >&2
echo " Review with: dejima-audit ack \"$WORKSPACE_DIR\"" >&2
if [ -t 0 ]; then
printf ' [c] continue [q] quit: ' >&2
read -r reply || reply=q
case "$reply" in q|Q) echo "aborted." >&2; exit 1 ;; esac
elif [ "${DEJIMA_AUDIT_STRICT:-0}" = "1" ]; then
echo " DEJIMA_AUDIT_STRICT=1 and stdin is not a tty - refusing to start." >&2
exit 1
else
echo " (not a tty - continuing; set DEJIMA_AUDIT_STRICT=1 to refuse)" >&2
fi
fi
compose_status=0
docker compose -f "$COMPOSE_FILE" run --rm ${extra_mounts[@]+"${extra_mounts[@]}"} claude "$@" || compose_status=$?
if [ -n "${SANDBOX_ANTHROPIC_PROXY_PID:-}" ] && ! kill -0 "$SANDBOX_ANTHROPIC_PROXY_PID" 2>/dev/null; then
echo "Warning: the credential proxy exited before the session ended - API calls in" >&2
echo "the session may have been failing. Check for proxy errors in the output above." >&2
fi
"$SCRIPT_DIR/dejima-audit" compare "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
exit "$compose_status"
+85 -23
View File
@@ -6,8 +6,7 @@
# real OAuth access token from ~/.claude/.credentials.json, so the credential
# itself never enters the container. It also owns token refresh: rotated
# tokens are written back to the real host file atomically, keeping
# host-side sessions logged in. See README "Security notes" and
# ~/specs/2026-07-24-dejima-credential-proxy-design.md.
# host-side sessions logged in. See docs/security.md.
import argparse
import http.client
import json
@@ -22,9 +21,22 @@ from urllib.parse import urlsplit
OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
OAUTH_BETA = "oauth-2025-04-20"
# Hop-by-hop headers describe the connection to this proxy rather than the
# request, so they stop here (RFC 9110 7.6.1). Host is deliberately not in
# this set: it has to be rewritten for the upstream, and is replaced in place
# rather than dropped and re-added, which keeps the header block in the order
# the client wrote it - see _upstream.
HOP_BY_HOP = {
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
"te", "trailer", "trailers", "transfer-encoding", "upgrade", "host",
"te", "trailer", "trailers", "transfer-encoding", "upgrade",
}
# Casing for the few headers this proxy has to supply when the client sent
# none of its own. Everything the client did send keeps the client's casing.
SUPPLIED_NAMES = {
"host": "Host",
"authorization": "Authorization",
"anthropic-beta": "anthropic-beta",
"content-length": "Content-Length",
}
@@ -83,15 +95,20 @@ class Credentials:
def _write(self, data):
# Atomic and permission-preserving: host-side Claude Code reads this
# same file and must never observe a partial write or a looser mode.
# Staged in a dedicated subdirectory, not ~/.claude directly - that
# directory is bind-mounted read-write into the sandbox container, and
# only the single ~/.claude/.credentials.json path is masked from it
# (see bin/dejima); a sibling temp file created directly in
# ~/.claude would be container-readable for the whole write window.
# bin/dejima masks this subdirectory entirely (empty read-only
# overlay), and the rename below stays same-filesystem (a subdirectory
# of the same parent), so atomicity is unaffected either way - what the
# container's own bind mount shows is irrelevant to a host-side rename.
# Staged in a dedicated subdirectory rather than ~/.claude directly,
# which keeps the plaintext window out of any directory the sandbox
# can see, and keeps the rename below same-filesystem (a subdirectory
# of the same parent) so atomicity is unaffected.
#
# This used to lean on bin/dejima masking both paths with bind mounts,
# on the reasoning that "what the container's own bind mount shows is
# irrelevant to a host-side rename". That was backwards, and it was
# #50: the rename is unaffected by the container's mount, but the
# container's mount is destroyed by the rename. Replacing the file
# unlinks the dentry the mask was pinned to, so the mask detached and
# the sandbox saw the real token from the first refresh on. ~/.claude
# is no longer bind-mounted as a whole, so neither this directory nor
# the credential reaches the container and there is nothing to mask.
directory = os.path.join(os.path.dirname(self.path) or ".", ".credentials-proxy-tmp")
os.makedirs(directory, mode=0o700, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=directory)
@@ -131,7 +148,7 @@ class Handler(BaseHTTPRequestHandler):
def _fail(self, status, message, log_message=None):
# log_message (host stderr) may contain upstream response bytes or
# exception detail; message (sent to the container in the response
# body) never should - see README "Security notes".
# body) never should - see docs/security.md.
self.log_error("%s", log_message if log_message is not None else message)
body = json.dumps(
{"type": "error", "error": {"type": "proxy_error", "message": message}}
@@ -146,18 +163,63 @@ class Handler(BaseHTTPRequestHandler):
u = self.server.upstream
cls = http.client.HTTPSConnection if u.scheme == "https" else http.client.HTTPConnection
conn = cls(u.netloc, timeout=600)
headers = {
k.lower(): v for k, v in self.headers.items()
if k.lower() not in HOP_BY_HOP
and k.lower() not in ("authorization", "x-api-key", "content-length")
# Emitted header by header rather than handed to conn.request() as a
# dict, so what the API receives is the client's own header block -
# same names, same casing, same order, duplicates intact - with only
# the values that must change changed. The container half of this
# sandbox goes to some length to make the session indistinguishable
# from a stock install (no ANTHROPIC_* variables, no explicit base
# URL - see docs/security.md); a request rewritten into CPython's
# house style would put the fingerprint straight back on the wire,
# where it is visible to the API rather than only to the client.
#
# conn.request() does exactly that in three ways: a dict collapses
# repeated headers to the last one, dict order puts every header this
# proxy touches at the end of the block, and putrequest() supplies
# `Accept-Encoding: identity` - a CPython tell no browser or Node
# client sends - whenever the caller passed no accept-encoding. The
# skip_* flags below suppress that and the generated Host header,
# which is re-emitted in the client's own position instead.
conn.putrequest(self.command, self.path,
skip_host=True, skip_accept_encoding=True)
supplied = {
"host": u.netloc,
"authorization": "Bearer " + token,
"content-length": str(len(body)),
}
headers["authorization"] = "Bearer " + token
betas = [b.strip() for b in headers.get("anthropic-beta", "").split(",") if b.strip()]
# anthropic-beta is left completely alone when the OAuth value is
# already among the client's own - which it is in the tls transport,
# where Claude Code believes it holds an OAuth credential. Re-joining
# a list that needs no change would normalize the client's spacing,
# which is the same class of tell as the casing.
betas = [b.strip() for v in self.headers.get_all("anthropic-beta", [])
for b in v.split(",")]
if OAUTH_BETA not in betas:
betas.append(OAUTH_BETA)
headers["anthropic-beta"] = ",".join(betas)
headers["content-length"] = str(len(body))
conn.request(self.command, self.path, body=body, headers=headers)
first = self.headers.get("anthropic-beta")
supplied["anthropic-beta"] = first + "," + OAUTH_BETA if first else OAUTH_BETA
seen = set()
for name, value in self.headers.items():
low = name.lower()
if low in HOP_BY_HOP or low == "x-api-key":
continue
if low in supplied:
# One replacement even for a repeated header, or the injected
# token would be sent as many times as the client sent its
# placeholder.
if low in seen:
continue
value = supplied[low]
seen.add(low)
conn.putheader(name, value)
for low, name in SUPPLIED_NAMES.items():
if low in supplied and low not in seen:
# Content-Length only when there is a body: a bodyless GET
# that carried none must not gain one. A chunked request has
# been de-framed by now, so it lands here and does gain one.
if low == "content-length" and not body:
continue
conn.putheader(name, supplied[low])
conn.endheaders(body)
return conn, conn.getresponse()
def _proxy(self):
+449
View File
@@ -0,0 +1,449 @@
#!/bin/bash
# List live dejima sandbox sessions and attach to one. This is the shared
# core behind both remote front doors: it runs as a forced SSH command
# (restrict,pty,command="...") and as ttyd's command. See
# docs/remote-access.md.
#
# Usage: dejima-attach [run-id|--list]
# (no argument) numbered picker, attaches to the choice
# run-id attach directly - what a notification deep link and ttyd's
# --url-arg produce
# --list one tab-separated row per live session, for scripting
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=dejima-project-lib.sh
source "$SCRIPT_DIR/dejima-project-lib.sh"
if ! remote_enabled; then
echo "Remote access is not configured ($(remote_conf_path) does not exist)." >&2
echo "See README \"Remote access\"." >&2
exit 1
fi
STATE_DIR="$(remote_state_dir)"
SESSIONS_DIR="$STATE_DIR/sessions"
# gruvbox dark. Every value below is upstream's, tagged with the name of the
# color.table row it came from:
# https://github.com/gruvbox-community/gruvbox-contrib/blob/master/color.table
# Carrying the name and not just the hex is the point - a palette this widely
# known is one almost nobody has memorised to six digits, so a wrong value is
# only catchable by reading if there is something to check it against.
#
# Truecolor only, and deliberately not paired with a 256-colour table: a
# terminal without it - or tmux without `Tc` - approximates 38;2 to its own
# nearest colour rather than printing the escape, so the fallback already
# exists one layer down and a second palette here would only duplicate it.
#
# Everything below is empty unless stdout is a terminal that says it can show
# colour, which is what keeps --list clean: it is a machine interface (ttyd's
# --url-arg feeds run ids back in, and the README documents the rows as
# scriptable), and it is also how the bats suite reads this script, so its
# assertions see the same bare words either way.
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-dumb}" != dumb ]; then
C_OFF=$'\033[0m'; C_BOLD=$'\033[1m'
C_BADGE=$'\033[38;2;254;128;25m' # bright_orange #fe8019, the wordmark
C_TEXT=$'\033[38;2;235;219;178m' # light1 #ebdbb2
C_MUTED=$'\033[38;2;146;131;116m' # gray_245 #928374
C_SUBTLE=$'\033[38;2;124;111;100m' # dark4 #7c6f64
C_RULE=$'\033[38;2;80;73;69m' # dark2 #504945, separator
C_OK=$'\033[38;2;184;187;38m' # bright_green #b8bb26, success
C_WARN=$'\033[38;2;250;189;47m' # bright_yellow #fabd2f, warning
C_ERR=$'\033[38;2;251;73;52m' # bright_red #fb4934, error
C_LINK=$'\033[38;2;131;165;152m' # bright_blue #83a598
GLYPH_BAR='▏'; GLYPH_DOT='●'
# A literal rather than a repeat of GLYPH_RULE: `tr` is byte-oriented and
# would shred a 3-byte box-drawing character, and every alternative costs
# a loop or a subshell to draw a fixed line. Both are the width of the
# heading they sit under ("dejima | live sessions"), and are next to it so
# they stay that way.
RULE_LINE='──────────────────────'
else
C_OFF=""; C_BOLD=""; C_BADGE=""; C_TEXT=""; C_MUTED=""; C_SUBTLE=""
C_RULE=""; C_OK=""; C_WARN=""; C_ERR=""; C_LINK=""
# Glyphs ride the same switch rather than a separate UTF-8 probe. Not
# because no-colour implies no-UTF-8, but because the two terminals this
# has to stay readable on - a $TERM the host has no entry for, and a pipe -
# are exactly the ones that mangle box drawing, and one flag is easier to
# reason about than two.
GLYPH_BAR='|'; GLYPH_DOT='*'
RULE_LINE='----------------------'
fi
# Narrow deliberately. The phone is this script's main front door, and the
# rule underlines the heading instead of spanning the table because the
# table's width follows the longest project name - a rule that chased it would
# wrap on a phone exactly when the rows did.
banner() {
printf '\n %s%sdejima%s %s%s%s %slive sessions%s\n' \
"$C_BOLD" "$C_BADGE" "$C_OFF" "$C_SUBTLE" "$GLYPH_BAR" "$C_OFF" \
"$C_MUTED" "$C_OFF"
printf ' %s%s%s\n\n' "$C_RULE" "$RULE_LINE" "$C_OFF"
}
# Coloured by meaning, not by name: attachable is the one an operator wants to
# find, and stale/no-tmux are the two that will refuse if picked.
state_color() {
case "$1" in
attachable) printf '%s' "$C_OK" ;;
stale) printf '%s' "$C_ERR" ;;
*) printf '%s' "$C_WARN" ;;
esac
}
# How long after its own started= a registered session may still have no
# container, before the prune in list_sessions treats that as "gone".
#
# Sized against the step that actually holds a launch up: bin/dejima writes
# the registry entry, then rewrites the settings copy, snapshots the
# workspace for the audit, runs an UNCONDITIONAL `docker compose build
# claude`, optionally builds up to two derived images on top of it, and only
# then reaches `docker compose run`. A cold base build is the Dockerfile's
# apt and npm layers fetched over the network - minutes, not seconds - and
# the derived builds add to it, while a warm run reaches the container in
# seconds and never comes near this window.
#
# The two errors are not symmetric, which is why this is generous. Too long
# only leaves a session that really did die listed until the window passes,
# and the next listing prunes it. Too short deletes the registry entry of a
# session that is about to start: bin/dejima never rewrites that entry, so
# the session is permanently absent from both front doors, and the marker
# directory deleted with it is the source of a bind mount the imminent
# `docker compose run` declares.
STARTUP_GRACE=900
# Parsed, never sourced: this file is host-written, but treating state files
# as code is the habit this repo doesn't have. Returns nonzero for an entry
# that can't be read or doesn't parse; callers skip it rather than dying,
# since one bad entry must not hide every other live session.
read_entry() {
local file="$1" line
entry_workspace=""; entry_pane=""; entry_label=""; entry_started=""
[ -r "$file" ] || return 1
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
workspace=*) entry_workspace="${line#*=}" ;;
pane=*) entry_pane="${line#*=}" ;;
label=*) entry_label="${line#*=}" ;;
started=*) entry_started="${line#*=}" ;;
esac
done < "$file" || return 1
# started is the one field that reaches an arithmetic context, where a
# non-numeric value is evaluated as a *variable name* - so "started=x[$(cmd)]"
# executes cmd, and set -u doesn't catch it when the name is already bound.
# Refuse the entry instead of arithmetic-evaluating anything from a file.
# (Digits alone aren't enough for the arithmetic itself - see the 10# below.)
case "$entry_started" in
''|*[!0-9]*) return 1 ;;
esac
}
# The container that reports this run ID. bin/dejima passes DEJIMA_RUN_ID in
# as env precisely so this correlation is possible without the container
# having any write access to the registry.
#
# Exit status distinguishes "no such container" (1) from "couldn't ask" (2):
# the caller prunes on the former, and pruning on the latter would permanently
# unregister every running sandbox the moment dockerd happens to be restarting.
container_for() {
local run_id="$1" names name env unasked=""
names="$(docker ps --filter "label=com.docker.compose.project=dejima" \
--filter "status=running" --format '{{.Names}}' 2>/dev/null)" || return 2
for name in $names; do
# Captured rather than piped straight into grep: a pipeline reports
# only grep's status, which would turn "inspect couldn't answer for
# this container" into "this isn't the one" - and one such container
# anywhere in the list would prune an unrelated live session.
env="$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$name" 2>/dev/null)" \
|| { unasked=1; continue; }
if printf '%s\n' "$env" | grep -qxF "DEJIMA_RUN_ID=$run_id"; then
printf '%s' "$name"
return 0
fi
done
[ -z "$unasked" ] || return 2
return 1
}
pane_alive() {
[ -n "$1" ] || return 1
tmux list-panes -a -F '#{pane_id}' 2>/dev/null | grep -qxF "$1"
}
# Does session $1 actually hold pane $2? The only question that separates the
# session we mean from one that merely carries its name. -s enumerates every
# pane of every window in the session, and pane ids are never reused within a
# server, so a match cannot be some later pane wearing an old id. pipefail
# makes an unanswerable list-panes a "no", which is the safe direction.
group_holds_pane() {
tmux list-panes -s -t "=$1" -F '#{pane_id}' 2>/dev/null | grep -qxF "$2"
}
# True while an entry is young enough that its container may simply not exist
# YET - see STARTUP_GRACE. Judged against the entry's own started=, since
# nothing else records when the launch began. An entry that doesn't parse has
# no age to judge and is not covered: it is pruned as before. read_entry is
# called again by the caller afterwards; re-reading four lines costs less
# than restructuring the loop around this one field.
starting_up() {
local now="$2"
read_entry "$1" || return 1
[ "$(( now - 10#$entry_started ))" -lt "$STARTUP_GRACE" ]
}
# The registry is a hint, not truth: bin/dejima's EXIT trap doesn't run under
# SIGKILL or a host crash, so an entry can outlive its session. An entry with
# no matching container - and old enough that it should have had one - is
# pruned outright; one whose pane is gone is shown as stale rather than
# offered for attachment.
list_sessions() {
local file run_id container state now rc warned=""
[ -d "$SESSIONS_DIR" ] || return 0
now="$(date +%s)"
for file in "$SESSIONS_DIR"/*; do
[ -f "$file" ] || continue
run_id="$(basename "$file")"
valid_run_id "$run_id" || continue
rc=0
container="$(container_for "$run_id")" || rc=$?
if [ "$rc" -eq 1 ] && starting_up "$file" "$now"; then
# Registered, but bin/dejima is still building - "not started yet"
# is a third answer, and pruning on it would unregister a session
# that is about to exist. Listed with the container column saying
# so; the recorded pane is the one bin/dejima is building in, so
# a session in this state is attachable like any other.
container="starting"
elif [ "$rc" -eq 1 ]; then
rm -f "$file"
# dejima-notify-watch keys its debounce stamps by run id too, and
# nothing else ever removes them - without this, every session
# leaves two behind for the life of the login session. Unguarded,
# like the rm above and unlike the rm -rf below: the debounce
# directory is host-owned and is never mounted into a sandbox.
rm -f "$STATE_DIR/debounce/$run_id-waiting" "$STATE_DIR/debounce/$run_id-done"
# Non-fatal: the marker directory is bind-mounted read-write into
# the sandbox, so a compromised session can chmod it such that this
# rm fails - and a failure here under set -e would abort the whole
# listing, letting that session deny the operator the front door.
rm -rf "${STATE_DIR:?}/notify/$run_id" \
|| echo "warning: could not remove the marker directory for $run_id" >&2
continue
elif [ "$rc" -ne 0 ]; then
# An unanswerable question is never a "no": the entry stays, and
# the container column says the daemon couldn't confirm it.
container="?"
[ -n "$warned" ] || echo "warning: could not query docker - sessions listed unverified." >&2
warned=1
fi
if ! read_entry "$file"; then
echo "warning: skipping unreadable or malformed registry entry: $file" >&2
continue
fi
if [ -z "$entry_pane" ]; then
state="no-tmux"
elif pane_alive "$entry_pane"; then
state="attachable"
else
state="stale"
fi
printf '%s\t%s\t%s\t%dm\t%s\t%s\n' \
"$run_id" "${entry_label:-?}" "$(basename "$entry_workspace")" \
"$(( (now - 10#$entry_started) / 60 ))" "$state" "${container:0:12}"
done
}
# Attaches through a GROUPED session rather than the operator's own. Grouped
# sessions share windows but track their own current window, so a phone can
# sit on a different sandbox than the desktop, and destroy-unattached disposes
# of the throwaway on detach.
#
# Nothing here sets window-size, deliberately: keeping the desktop from being
# resized down to phone dimensions is what tmux's own default (latest, since
# 3.1) already does, and the option's only scopes are the window - which a
# grouped session SHARES with the operator's own, so setting it there
# permanently overwrites their choice, with nothing to restore it afterwards -
# and global. An operator who deliberately set smallest keeps it.
attach() {
local run_id="$1" file group win base target
file="$SESSIONS_DIR/$run_id"
[ -f "$file" ] || { echo "No such session: $run_id" >&2; exit 1; }
if ! read_entry "$file"; then
echo "Session $run_id has an unreadable or malformed registry entry." >&2
exit 1
fi
if [ -z "$entry_pane" ]; then
echo "Session $run_id was not started from tmux - nothing to attach to." >&2
exit 1
fi
if ! pane_alive "$entry_pane"; then
echo "Session $run_id is stale - its tmux pane is gone." >&2
exit 1
fi
group="dejima-$run_id"
# Both values come from the same live pane, in one round trip. Taking the
# session name from the recorded label instead would follow a name rather
# than the sandbox: an operator renaming sessions is enough to point it at
# an unrelated session that has since taken the old name.
#
# The name is a starting point, never a guarantee. It is frequently
# ANOTHER remote client's throwaway group (tmux reports whichever session
# holding the window is most recent, and every grouped session here is
# newer than the operator's own), and those carry destroy-unattached - so
# the base can be made to stop existing on demand by anyone holding a
# second connection. That is why the session created from it is verified
# below instead of trusted: `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.
#
# display-message prints nothing and still exits 0 for a target that no
# longer exists, so an empty result means the pane went away between the
# check above and here. That case is caught by the verification below too;
# bailing early just avoids creating a junk session to immediately kill,
# and says what actually happened.
target="$(tmux display-message -p -t "$entry_pane" '#{session_name}:#{window_index}' 2>/dev/null || true)"
# Session names may contain ':', window indexes can't.
base="${target%:*}"
win="${target##*:}"
if [ -z "$base" ] || [ -z "$win" ]; then
echo "Session $run_id is stale - its tmux pane went away." >&2
exit 1
fi
if tmux has-session -t "=$group" 2>/dev/null; then
# Verified exactly like the session created below, and for the same
# reason: nothing here created this one, so carrying the name is not
# evidence of holding the sandbox. Reachable only as debris - a
# dejima-<run-id> that exists but no longer contains the pane - and
# anyone able to create that name already has a host shell, so this is
# about landing in the right window rather than about containment.
# Refused rather than killed: destroying a session this script did not
# create is not its call, and the message names the one to look at.
if ! group_holds_pane "$group" "$entry_pane"; then
echo "Session $run_id: the existing tmux session $group does not hold its pane." >&2
echo "Remove it (tmux kill-session -t '=$group') and attach again." >&2
exit 1
fi
# Select within the GROUP, not via the pane id directly: the latter
# would move the operator's own session's current window too.
tmux select-window -t "=$group:$win"
# Set here too, not just on the session we create: the option is
# per-session state, so a group that ever reached this point without it
# would keep outliving its client on every later attach.
exec tmux attach-session -t "=$group" \; set-option destroy-unattached on
fi
# Created DETACHED first so the result can be inspected before a client
# ever sees it: whatever tmux gave us has to actually contain the sandbox
# pane, or it is a session tmux invented from a name that stopped existing
# while we were looking at it. Anything else is killed rather than handed
# over. ("="-anchored, so a name can only ever resolve exactly - an
# unanchored target prefix/fnmatch-matches some other session.)
tmux new-session -d -t "=$base" -s "$group"
# Detached, it now outlives every failure below, and a session leaked that
# way never gets destroy-unattached set on it, so it sits on the host for
# good. The trap only ever names a session this call just created:
# new-session fails on a duplicate name before we reach it.
# ("="-anchored also so it can never mean "the current session", which is
# what an empty target means to kill-session.)
#
# The name is baked in here rather than read at exit: $group is local, and
# a trap body runs after the function's locals are gone, where set -u
# would abort it and leak the session it exists to remove.
trap "tmux kill-session -t '=$group' 2>/dev/null || true" EXIT
if ! group_holds_pane "$group" "$entry_pane"; then
echo "Session $run_id is stale - the tmux session holding its pane went away." >&2
exit 1
fi
tmux select-window -t "=$group:$win"
# destroy-unattached has to be set by the same command that attaches:
# setting it while the session is still detached makes the server reap the
# session before the attach lands.
#
# Deliberately not exec'd, unlike the branch above, which creates nothing:
# attaching is the step most likely to fail on a remote client's terms -
# no terminal for `ssh -T`, or a $TERM the host has no entry for, both of
# which the client picks - and an exec leaves no shell behind to clean up
# the session we just made. Detaching normally returns 0 and disarms the
# trap; the session then goes away by destroy-unattached, not by us.
tmux attach-session -t "=$group" \; set-option destroy-unattached on
trap - EXIT
}
picker() {
local -a ids=() labels=() projects=() ages=() states=() containers=()
local i choice run_id label project age state container
local w_label=0 w_project=0 w_age=0 w_state=0
# Split on tabs only, so a space in a project name stays part of one field.
# The columns are measured on the way in because they are padded to the
# widest value rather than to a guess: a project name is a directory name,
# and a fixed width either truncates a long one or indents every short one
# off the right edge of a phone.
while IFS=$'\t' read -r run_id label project age state container; do
ids+=("$run_id"); labels+=("$label"); projects+=("$project")
ages+=("$age"); states+=("$state"); containers+=("$container")
[ "${#label}" -le "$w_label" ] || w_label="${#label}"
[ "${#project}" -le "$w_project" ] || w_project="${#project}"
[ "${#age}" -le "$w_age" ] || w_age="${#age}"
[ "${#state}" -le "$w_state" ] || w_state="${#state}"
done < <(list_sessions)
banner
if [ "${#ids[@]}" -eq 0 ]; then
printf ' %sNo live sessions.%s\n\n' "$C_MUTED" "$C_OFF"
exit 0
fi
# The container column stays, and last, because it is not decoration: it
# reads "starting" for a session bin/dejima is still building and "?" when
# docker could not be asked at all, and dropping it would make either one
# look like an ordinary attachable session.
for i in "${!ids[@]}"; do
local sc; sc="$(state_color "${states[$i]}")"
printf ' %s%2d%s %s%s%s %s%-*s%s %s%-*s%s %s%*s%s %s%-*s%s %s%s%s\n' \
"$C_WARN" "$((i + 1))" "$C_OFF" \
"$sc" "$GLYPH_DOT" "$C_OFF" \
"$C_BOLD$C_TEXT" "$w_project" "${projects[$i]}" "$C_OFF" \
"$C_LINK" "$w_label" "${labels[$i]}" "$C_OFF" \
"$C_MUTED" "$w_age" "${ages[$i]}" "$C_OFF" \
"$sc" "$w_state" "${states[$i]}" "$C_OFF" \
"$C_SUBTLE" "${containers[$i]}" "$C_OFF"
done
# Numbered rather than arrow-driven: phone soft keyboards handle digits
# far better than arrow keys, and this is the phone's entry point.
printf '\n %sattach to%s %s[1-%d, q to quit]%s: ' \
"$C_MUTED" "$C_OFF" "$C_TEXT" "${#ids[@]}" "$C_OFF"
read -r choice
case "$choice" in
q|Q|'') exit 0 ;;
*[!0-9]*) echo "Not a number." >&2; exit 1 ;;
esac
# Normalized to base 10 once, here, because the range check and the
# subscript below do not read a number the same way: `[` is POSIX test and
# decimal-only, while $(( )) reads a leading zero as OCTAL. Without this,
# "010" - which a phone keypad makes easy to type - passes the check as 10
# and indexes as 8, and the caller is attached to a DIFFERENT session than
# the one they picked, with nothing printed to say so.
#
# It fixes the two ugly shapes as well. "09" is a value here rather than
# the "invalid octal constant" arithmetic error it is to $(( )); and a
# number past int64 wraps to one the range check can answer for, where
# `[ 99999999999999999999999 -gt 11 ]` instead exits 2 with "integer
# expression expected" - which makes the `if` false, so "Out of range."
# is never printed and the subscript below dies on an unbound element.
choice="$((10#$choice))"
if [ "$choice" -lt 1 ] || [ "$choice" -gt "${#ids[@]}" ]; then
echo "Out of range." >&2
exit 1
fi
attach "${ids[$((choice - 1))]}"
}
case "${1:-}" in
'') picker ;;
--list) list_sessions ;;
*)
# This argument arrives from ttyd's --url-arg, i.e. from whoever can
# reach the web front door. Validate its shape before it is used as a
# path component or anything else.
valid_run_id "$1" || { echo "Invalid session id." >&2; exit 1; }
[ -f "$SESSIONS_DIR/$1" ] || { echo "No such session: $1" >&2; exit 1; }
attach "$1"
;;
esac
+564 -29
View File
@@ -1,11 +1,11 @@
#!/bin/bash
# Host-side exit-time audit for bin/dejima. Snapshots the workspace's
# host-persistence surfaces before the container runs, and after it exits
# reports (advisory only - never blocks) anything a session changed that
# would execute or load OUTSIDE the sandbox: git config keys that run a
# command on the next `git`, and content under .claude/ / .mcp.json /
# reports (advisory only - never blocks) anything that changed during that
# window and would execute or load OUTSIDE the sandbox: git config keys that
# run a command on the next `git`, and content under .claude/ / .mcp.json /
# .envrc / .vscode/ / .git/hooks/. Read the report before running `git` or
# `claude` in that repo again. See README "Security notes".
# `claude` in that repo again. See docs/security.md.
set -uo pipefail
cmd="${1:-}"; ws="${2:-}"; state="${3:-}"
@@ -27,6 +27,96 @@ STATUSLINE_PATH="${DEJIMA_AUDIT_STATUSLINE:-$HOME/.claude/statusline.sh}"
# this repo (a self-mounted session). Overridable for tests.
AUDIT_REPO_ROOT="${DEJIMA_AUDIT_REPO_ROOT:-$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)}"
# Fixed, not configurable: a predictable report shape is worth more than
# tunability, and >10 CRITICAL findings is an anomaly rather than a display
# problem (an ordinary session produces zero).
CRIT_CAP=10
# WARNING is where volume actually lands: the INFO path in path_severity()
# below is currently unreachable, so every high-volume workspace-file change
# classifies as WARNING instead of collapsing into an INFO count. Capped
# moderately - overflow here is ordinary, not an alarm.
WARN_CAP=5
AUDIT_DIR="${DEJIMA_AUDIT_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/dejima/audit}"
# Retention is a security property, not housekeeping. With one overwritten
# report, a session that plants something needs only to talk the operator
# into a restart for the next clean exit to erase the evidence - and
# suggesting a restart is an entirely ordinary thing to suggest.
AUDIT_KEEP="${DEJIMA_AUDIT_KEEP:-10}"
ws_slug() { printf '%s' "$1" | sed 's/[^a-zA-Z0-9]/-/g'; }
# The one report both `show` and `ack` operate on: the OLDEST unacknowledged
# CRITICAL report if there is one, else the newest report overall.
#
# Oldest-CRITICAL-first, rather than a plain `sort | tail -1` over every
# *.txt, because that always prefers the newer file - so a stale
# *-CRITICAL.txt sitting behind a newer clean report would be unreachable
# through `ack` entirely, while bin/dejima's startup warning kept firing on
# it forever with no remedy but to press past it. That is the alert fatigue
# this feature exists to end, so it must not be reachable by this route.
#
# Shared by both verbs so they cannot disagree: an operator following that
# startup warning may well reach for `show` first, and showing them a
# different report than the one being warned about sends them looking for a
# finding that is not in it.
select_report() {
local dir="$1" r
r="$(find "$dir" -maxdepth 1 -name '*-CRITICAL.txt' 2>/dev/null | sort | head -1)"
[ -n "$r" ] || r="$(find "$dir" -maxdepth 1 -name '*.txt' 2>/dev/null | sort | tail -1)"
[ -n "$r" ] || return 1
printf '%s\n' "$r"
}
BASELINE_DIR="${DEJIMA_AUDIT_BASELINE_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/dejima/baselines}"
# Two acknowledgement forms, deliberately distinct:
# spam <id> - this changes routinely; count it, never list it.
# Refused for CRITICAL surfaces (see `ack`).
# safe <id> <sha> - this exact content is approved; anything else
# re-fires at full severity. The only form permitted
# for CRITICAL, so approving a hook today does not
# approve a different hook tomorrow.
baseline_suppresses() {
local id="$1" sha="$2" bf verb rest bid bsha eid
bf="$BASELINE_DIR/$(ws_slug "$ws")"
[ -f "$bf" ] || return 1
# Callers pass the RAW id; the baseline file stores the encoded form (see
# enc_id). Encoded once here, outside the loop - it does not vary per
# line, and this runs for every finding. For an ordinary id the encoding
# is the identity, so baseline files written before this existed keep
# matching unchanged.
eid="$(enc_id "$id")"
while read -r verb rest; do
case "$verb" in ''|'#'*) continue ;; esac
# An id may itself contain spaces (e.g. "file 2.md"), so it can't be
# split off with a fixed field count - a naive `read verb id sha`
# would truncate such an id at its first space and could then
# collide with a shorter, unrelated real path of that truncated
# name, silently suppressing a finding no one acknowledged. spam has
# no trailing sha field, so its id is everything after the verb;
# safe's sha is fixed-position-last, so its id is recovered by
# stripping the last whitespace-separated field from the right.
case "$verb" in
spam) bid="$rest"; bsha="" ;;
safe) case "$rest" in
*' '*) bsha="${rest##* }"; bid="${rest% *}" ;;
*) continue ;; # malformed: no sha field
esac ;;
*) continue ;; # unknown verb
esac
[ "$bid" = "$eid" ] || continue
case "$verb" in
# `ack` refusing to write `spam` for a CRITICAL surface is
# convenience; this is the actual boundary - it holds even
# against a hand-edited or corrupted baseline file.
spam) [ "$(id_severity "$id")" = CRITICAL ] && return 1; return 0 ;;
safe) [ -n "$sha" ] && [ "$bsha" = "$sha" ] && return 0 ;;
esac
done < "$bf"
return 1
}
# git config keys whose values run a command on the next git invocation, or
# (include.path/includeif.*.path) indirect to another config file that could
# set one - key names are case-insensitive in git, so match on a lowercased
@@ -45,6 +135,128 @@ is_exec_key() {
return 1
}
# Severity of a monitored workspace-relative path, derived from the surface
# alone - never from content. CRITICAL means "runs unsandboxed host code the
# next time the operator uses this repo", which is decidable from a fixed
# list. .vscode/tasks.json and launch.json are CRITICAL wholesale rather than
# parsed for runOn:folderOpen: a JSON parser on the trust boundary costs more
# than the false positives do.
path_severity() {
case "$1" in
.envrc|.mcp.json) echo CRITICAL ;;
.git/hooks/*) echo CRITICAL ;;
.claude/settings.json|.claude/settings.local.json) echo CRITICAL ;;
.vscode/tasks.json|.vscode/launch.json) echo CRITICAL ;;
.claude/*|.vscode/*) echo WARNING ;;
*) echo INFO ;;
esac
}
# Severity of any acknowledgeable finding id, namespaced or bare-path (see
# baseline_suppresses' CRITICAL guard below). Bare ids (no known namespace
# prefix) are workspace-relative paths, handled by path_severity. The
# alias.* case is deliberately conservative: the audit only classifies a
# `!`-prefixed alias value as CRITICAL, but an id alone can't say whether the
# value started with `!`, so any alias is treated as CRITICAL for
# suppression purposes.
id_severity() {
local k
case "$1" in
gitconfig:*) k="${1#gitconfig:}"; k="${k#worktree:}"
is_exec_key "$k" && { echo CRITICAL; return; }
case "$k" in alias.*) echo CRITICAL ;; *) echo WARNING ;; esac ;;
statusline:*|tcb:*) echo CRITICAL ;;
*) path_severity "$1" ;;
esac
}
# Every finding interpolates workspace-controlled text (paths, config values)
# into a terminal. A planted value containing C0 escapes can scroll, clear or
# overwrite the report; C1 controls and Unicode bidi overrides/isolates
# (U+202A-U+202E, U+2066-U+2069) can visually reverse or reorder the
# displayed text without touching the bytes an operator would grep for - so
# strip all of these at the display boundary, after all parsing and
# TAB-splitting is done.
sanitize() {
printf '%s' "$1" | LC_ALL=C tr -d '\000-\037\177' \
| LC_ALL=C sed $'s/\xc2[\x80-\x9f]//g; s/\xe2\x80[\xaa-\xae]//g; s/\xe2\x81[\xa6-\xa9]//g'
}
# sanitize() is lossy, so it must never be what an ID passes through.
#
# `ack` derives the baseline id it records from the report's text, and the
# next `compare` matches that id against the RAW bytes on disk. An id
# containing anything sanitize() strips therefore could not round-trip at
# all: the surface stayed permanently unsuppressible and its report
# permanently unclearable, so bin/dejima's startup warning fired forever -
# the alert fatigue this whole feature exists to end, reached from the other
# side. Ids are encoded instead, and the encoded form is canonical
# everywhere it is written down: in the report AND in the baseline file.
#
# An id that survives sanitize() unchanged is already display-safe and is
# written verbatim - that is every ordinary id, so reports and existing
# baseline files are byte-for-byte what they were. Anything else is
# hex-encoded behind a marker, which is ASCII by construction and reverses
# exactly.
#
# An id that merely LOOKS encoded is encoded too, so that decoding stays
# unambiguous: the marker's presence always means this encoder put it there.
# Without that, a workspace could present an id that `ack` decoded into one
# of the attacker's choosing - baselining something other than the finding
# while still counting it acknowledged, the same shape as the id-derivation
# hijacks the anchored case arms in `ack` guard against. Only the START of
# an id is significant, and no monitored path can begin with the marker
# (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.
#
# The encoded form is also the honest rendering. sanitize() DELETES those
# bytes, so a planted ".git/hooks/pre<U+202E>commit" displayed as
# "precommit" sends an operator grepping for a name no file on disk has.
HEX_MARK='{hex}'
enc_id() {
local v="$1"
# Fast path, and the only one an ordinary id ever takes: decide with
# pattern matching alone, no subprocesses. hash_paths() calls this once
# per monitored file on both the snapshot and the compare side, so the
# exact check below - a subshell plus tr plus sed - is far too expensive
# to run unconditionally (a bare `git init` alone ships ~14 sample
# hooks).
#
# `local LC_ALL=C` puts bash's own matching into byte semantics for the
# duration, so [[:cntrl:]] is exactly C0+DEL. Any C1 or bidi character
# must contain the UTF-8 lead byte \302 or \342 respectively, so testing
# for those catches every one. The test is deliberately conservative
# rather than exact - ordinary text carrying one of those lead bytes (an
# em dash, say) falls through to the exact check and is then found
# clean and printed verbatim, which costs a little time and changes
# nothing.
local LC_ALL=C
case "$v" in
"$HEX_MARK"*) ;;
*[[:cntrl:]]*|*$'\302'*|*$'\342'*) ;;
*) printf '%s' "$v"; return ;;
esac
if [ "$(sanitize "$v")" = "$v" ]; then
case "$v" in "$HEX_MARK"*) ;; *) printf '%s' "$v"; return ;; esac
fi
printf '%s%s' "$HEX_MARK" "$(printf '%s' "$v" | od -An -tx1 | tr -d ' \n')"
}
# Inverse of enc_id. Built with parameter expansion and one printf rather
# than a pipeline, so no command substitution inside can eat a trailing
# byte. printf '%b' consumes the escapes left to right in a single pass,
# which is what makes "\\x41" decode to a literal backslash followed by x41
# instead of to "A".
dec_id() {
local h="$1" esc=""
case "$h" in
"$HEX_MARK"*) h="${h#"$HEX_MARK"}" ;;
*) printf '%s' "$h"; return ;;
esac
while [ -n "$h" ]; do esc="$esc\\x${h:0:2}"; h="${h:2}"; done
printf '%b' "$esc"
}
# Undo git's config-file quoting of a value (git wraps values containing
# special chars in "..." and backslash-escapes embedded \ and ") so exec
# detection sees the real value instead of a quoted literal.
@@ -72,17 +284,30 @@ hash_entry() {
fi
}
hash_value() {
printf '%s' "$1" | sha256sum | cut -d' ' -f1
}
hash_paths() {
# path<TAB>sha for each existing monitored path, sorted by path
# encoded-path<TAB>sha for each existing monitored path, sorted by path.
#
# Encoded (see enc_id) rather than raw, because this record is itself
# TAB-delimited and line-based: a filename containing a TAB would
# otherwise be silently truncated at it by the `cut -f1` that reads this
# back, and reported under a name no file on disk has - unackable for
# exactly the reason enc_id exists, and CRITICAL if the truncation still
# lands under .git/hooks/. TAB and newline are both in the set enc_id
# encodes, so an encoded path can never contain either. Ordinary paths
# encode to themselves, so this record is unchanged for them.
local ws="$1" p
{
for p in .mcp.json .envrc; do
[ -f "$ws/$p" ] && printf '%s\t%s\n' "$p" "$(hash_entry "$ws/$p")"
[ -f "$ws/$p" ] && printf '%s\t%s\n' "$(enc_id "$p")" "$(hash_entry "$ws/$p")"
done
for dir in .claude .vscode .git/hooks; do
if [ -d "$ws/$dir" ]; then
while IFS= read -r -d '' f; do
printf '%s\t%s\n' "${f#"$ws"/}" "$(hash_entry "$f")"
printf '%s\t%s\n' "$(enc_id "${f#"$ws"/}")" "$(hash_entry "$f")"
done < <(find "$ws/$dir" \( -type f -o -type l \) -print0)
fi
done
@@ -90,19 +315,20 @@ hash_paths() {
}
tcb_snapshot() {
# path<TAB>sha for the sandbox's own trusted computing base, but only
# when $ws IS the sandbox's repo (self-mount) - see AUDIT_REPO_ROOT.
# encoded-path<TAB>sha for the sandbox's own trusted computing base, but
# only when $ws IS the sandbox's repo (self-mount) - see AUDIT_REPO_ROOT.
# Empty otherwise, so a normal (non-self) workspace is unaffected.
# Encoded for the same reason as hash_paths above.
local ws="$1" p
[ "$(readlink -f "$ws" 2>/dev/null)" = "$AUDIT_REPO_ROOT" ] || return 0
{
for p in install.sh; do
[ -f "$ws/$p" ] && printf '%s\t%s\n' "$p" "$(hash_entry "$ws/$p")"
[ -f "$ws/$p" ] && printf '%s\t%s\n' "$(enc_id "$p")" "$(hash_entry "$ws/$p")"
done
for dir in bin hooks docker test; do
if [ -d "$ws/$dir" ]; then
while IFS= read -r -d '' f; do
printf '%s\t%s\n' "${f#"$ws"/}" "$(hash_entry "$f")"
printf '%s\t%s\n' "$(enc_id "${f#"$ws"/}")" "$(hash_entry "$f")"
done < <(find "$ws/$dir" \( -type f -o -type l \) -print0)
fi
done
@@ -148,6 +374,35 @@ git_config_snapshot() {
done | sort
}
# The sha ack/baseline_suppresses use for a gitconfig:<key> id: the value
# currently set for that exact key (worktree: prefix included, as parsed by
# git_config_snapshot), hashed the same way compare does. Empty (and a
# failing return) if the key no longer exists.
gitconfig_sha_for_key() {
local ws="$1" key="$2" line lkey
while IFS= read -r line; do
lkey="${line%%=*}"
[ "$lkey" = "$key" ] || continue
hash_value "$(unquote_value "${line#*=}")"
return 0
done < <(git_config_snapshot "$ws")
return 1
}
# The sha ack writes for any acknowledgeable id, dispatched by namespace -
# mirrors id_severity's dispatch and the hash each compare loop computes for
# that namespace, so the id/sha pair ack records is exactly what
# baseline_suppresses will later compare against.
current_sha_for_id() {
local id="$1"
case "$id" in
gitconfig:*) gitconfig_sha_for_key "$ws" "${id#gitconfig:}" ;;
statusline:*) [ -e "$STATUSLINE_PATH" ] && hash_entry "$STATUSLINE_PATH" ;;
tcb:*) [ -e "$ws/${id#tcb:}" ] && hash_entry "$ws/${id#tcb:}" ;;
*) [ -e "$ws/$id" ] && hash_entry "$ws/$id" ;;
esac
}
projects_snapshot() {
# slug<TAB>maxmtime for each immediate subdir of PROJECTS_DIR, sorted by
# slug; maxmtime is 0 if the slug dir has no files.
@@ -181,7 +436,19 @@ case "$cmd" in
exit 0
;;
compare)
[ -f "$state" ] || { echo "dejima audit: no snapshot to compare against, skipping." >&2; exit 0; }
# cleanup() (bin/dejima) now runs this from the EXIT trap, which
# fires on early exits too - before the workspace's mktemp'd state
# file has ever been written to by `snapshot`. Such a file exists
# but is empty, and treating "exists" as "has a snapshot" would diff
# the real current state against nothing, manufacturing a finding
# for every monitored surface already present. Empty is the same as
# absent for that purpose, so both skip - but only a genuinely
# missing path (not the routine mktemp'd-but-unwritten case that
# follows every pre-snapshot fatal exit) is worth a message: that
# case is the operator's last line on a fatal error, and printing
# "skipping" over it would bury the real failure.
[ -e "$state" ] || { echo "dejima audit: no snapshot to compare against, skipping." >&2; exit 0; }
[ -s "$state" ] || exit 0
new="$(mktemp)"; { echo "### gitconfig"; git_config_snapshot "$ws";
echo "### paths"; hash_paths "$ws";
echo "### projects"; projects_snapshot "$PROJECTS_DIR";
@@ -189,21 +456,31 @@ case "$cmd" in
echo "### statusline"; statusline_snapshot "$STATUSLINE_PATH"; } > "$new" 2>/dev/null || {
echo "dejima audit: could not complete, skipping." >&2; rm -f "$new"; exit 0; }
findings=()
crit=(); warn=(); info=()
# config diffs: added/changed lines present in new but not old
while IFS= read -r line; do
key="${line%%=*}"
key_nopfx="${key#worktree:}"
# The key is the id-bearing half and is encoded; the value is
# display-only and is sanitized. Splitting them keeps a value
# full of control bytes from dragging its key into the opaque
# hex form, where the operator could no longer read which
# setting had changed.
body="$(enc_id "$key")=$(sanitize "${line#*=}")"
if is_exec_key "$key_nopfx"; then
findings+=("COMMAND-EXECUTION git config: $line")
gid="gitconfig:$key"; gsha="$(hash_value "$(unquote_value "${line#*=}")")"
baseline_suppresses "$gid" "$gsha" && continue
crit+=("COMMAND-EXECUTION git config: $body")
elif [ "$key_nopfx" = "alias" ] || [[ "$key_nopfx" == alias.* ]]; then
# alias.<name>=!cmd runs a shell command
case "$(unquote_value "${line#*=}")" in
'!'*) findings+=("COMMAND-EXECUTION git alias: $line") ;;
*) findings+=("changed git config: $line") ;;
'!'*) gid="gitconfig:$key"; gsha="$(hash_value "$(unquote_value "${line#*=}")")"
baseline_suppresses "$gid" "$gsha" && continue
crit+=("COMMAND-EXECUTION git alias: $body") ;;
*) warn+=("changed git config: $body") ;;
esac
else
findings+=("changed git config: $line")
warn+=("changed git config: $body")
fi
done < <(comm -13 <(sed -n '/^### gitconfig/,/^### paths/p' "$state" | grep -vE '^### |^worktree:$') \
<(sed -n '/^### gitconfig/,/^### paths/p' "$new" | grep -vE '^### |^worktree:$') )
@@ -212,7 +489,24 @@ case "$cmd" in
# change emits both an old-hash and new-hash line; dedupe by path
# so each changed path is reported once)
while IFS= read -r line; do
findings+=("changed workspace file: $line")
# $line arrives encoded (see hash_paths). Everything that has to
# reach the real file, classify it, or match a baseline entry
# needs the raw name; only the report body keeps the encoded
# form, which is what enc_id would produce for it anyway.
rline="$(dec_id "$line")"
cur_sha=""
[ -e "$ws/$rline" ] && cur_sha="$(hash_entry "$ws/$rline")"
baseline_suppresses "$rline" "$cur_sha" && continue
case "$(path_severity "$rline")" in
CRITICAL) crit+=("HOST-CODE-EXECUTION workspace file: $line") ;;
WARNING) warn+=("changed workspace file: $line") ;;
# Currently unreachable: hash_paths() only monitors
# .mcp.json/.envrc/.claude/.vscode/.git/hooks, and every path
# under those matches an earlier CRITICAL/WARNING pattern in
# path_severity(). Kept as the defensive default for
# surfaces monitored by a future task.
*) info+=("changed workspace file: $line") ;;
esac
done < <(comm -3 <(sed -n '/^### paths/,/^### projects/p' "$state" | grep -v '^### ') \
<(sed -n '/^### paths/,/^### projects/p' "$new" | grep -v '^### ') \
| sed 's/^\t//' | cut -f1 | sort -u )
@@ -220,13 +514,13 @@ case "$cmd" in
# projects diffs: a write to another project's ~/.claude/projects
# slug during this session (transcript/memory-poisoning surface).
# "self" = current workspace's own slug or a worktree child of it.
current_slug="$(printf '%s' "$ws" | sed 's/[^a-zA-Z0-9]/-/g')"
current_slug="$(ws_slug "$ws")"
while IFS= read -r line; do
slug="${line%%$'\t'*}"
case "$slug" in
"$current_slug"|"$current_slug"-*) continue ;;
esac
findings+=("CROSS-PROJECT history/memory write: $slug")
warn+=("CROSS-PROJECT history/memory write: $(sanitize "$slug")")
done < <(comm -13 <(sed -n '/^### projects/,/^### tcb/p' "$state" | grep -v '^### ') \
<(sed -n '/^### projects/,/^### tcb/p' "$new" | grep -v '^### ') )
@@ -236,7 +530,11 @@ case "$cmd" in
# Bounded at ### statusline (not $p, the last section now) so this
# doesn't also swallow that section's lines.
while IFS= read -r line; do
findings+=("TCB (sandbox self) modified: $line")
# Encoded on arrival, same as the path loop above.
rline="$(dec_id "$line")"
tid="tcb:$rline"; tsha=""; [ -e "$ws/$rline" ] && tsha="$(hash_entry "$ws/$rline")"
baseline_suppresses "$tid" "$tsha" && continue
crit+=("TCB (sandbox self) modified: $line")
done < <(comm -3 <(sed -n '/^### tcb/,/^### statusline/p' "$state" | grep -v '^### ') \
<(sed -n '/^### tcb/,/^### statusline/p' "$new" | grep -v '^### ') \
| sed 's/^\t//' | cut -f1 | sort -u )
@@ -247,28 +545,265 @@ case "$cmd" in
# strongly as COMMAND-EXECUTION/TCB above: this is unsandboxed host
# code execution, not just a config change.
while IFS= read -r line; do
findings+=("HOST-CODE-EXECUTION statusline.sh changed (runs UNSANDBOXED on the host via settings.json statusLine.command): $line")
sid="statusline:$line"; ssha=""; [ -e "$STATUSLINE_PATH" ] && ssha="$(hash_entry "$STATUSLINE_PATH")"
baseline_suppresses "$sid" "$ssha" && continue
crit+=("HOST-CODE-EXECUTION statusline.sh changed (runs UNSANDBOXED on the host via settings.json statusLine.command): $(enc_id "$line")")
done < <(comm -3 <(sed -n '/^### statusline/,$p' "$state" | grep -v '^### ') \
<(sed -n '/^### statusline/,$p' "$new" | grep -v '^### ') \
| sed 's/^\t//' | cut -f1 | sort -u )
rm -f "$new"
[ "${#findings[@]}" -eq 0 ] && exit 0
total=$(( ${#crit[@]} + ${#warn[@]} + ${#info[@]} ))
if [ "$total" -eq 0 ]; then
exit 0
fi
report_dir="$AUDIT_DIR/$(ws_slug "$ws")"
if mkdir -p "$report_dir" 2>/dev/null; then
if [ "${#crit[@]}" -gt 0 ]; then
report="$report_dir/$(date +%Y-%m-%d-%H%M%S)-CRITICAL.txt"
else
report="$report_dir/$(date +%Y-%m-%d-%H%M%S).txt"
fi
{
echo "dejima audit report for $(sanitize "$ws")"
echo "Changes below happened while this session ran - not necessarily because"
echo "of it. A concurrent dejima/Claude Code session, or your own edits during"
echo "the run, land in this same diff; see docs/security.md."
[ "${#crit[@]}" -gt 0 ] && printf 'CRITICAL %s\n' "${crit[@]}"
[ "${#warn[@]}" -gt 0 ] && printf 'WARNING %s\n' "${warn[@]}"
[ "${#info[@]}" -gt 0 ] && printf 'INFO %s\n' "${info[@]}"
} > "$report" 2>/dev/null || true
# Oldest-first, but only clean reports are evictable: a report
# still carrying an unacknowledged CRITICAL is retained past the
# cap, so flushing one out cannot be achieved by inducing runs.
# `ack` renames it to drop the suffix, which makes it evictable.
while [ "$(find "$report_dir" -maxdepth 1 -name '*.txt' ! -name '*-CRITICAL.txt' | wc -l)" -gt "$AUDIT_KEEP" ]; do
oldest="$(find "$report_dir" -maxdepth 1 -name '*.txt' ! -name '*-CRITICAL.txt' | sort | head -1)"
[ -n "$oldest" ] || break
rm -f "$oldest" || break
done
fi
# Ascending severity: the operator reads bottom-up from their shell
# prompt, so whatever prints LAST is what actually gets seen. INFO
# collapses to a count - it exists to be counted, not read.
{
echo ""
echo "================================================================"
echo " dejima: this session changed host-persistence surfaces"
echo " in $ws"
echo " Review these BEFORE running 'git' or 'claude' in that repo:"
echo " dejima: host-persistence surfaces changed in"
echo " $(sanitize "$ws") while this session ran"
echo "----------------------------------------------------------------"
printf ' - %s\n' "${findings[@]}"
echo "================================================================"
if [ "${#info[@]}" -gt 0 ]; then
echo " ${#info[@]} routine file change(s) - not listed"
fi
if [ "${#warn[@]}" -gt 0 ]; then
printf ' - %s\n' "${warn[@]:0:$WARN_CAP}"
if [ "${#warn[@]}" -gt "$WARN_CAP" ]; then
echo " + $(( ${#warn[@]} - WARN_CAP )) further change(s) not shown."
fi
fi
if [ "${#crit[@]}" -gt 0 ]; then
printf ' ! %s\n' "${crit[@]:0:$CRIT_CAP}"
if [ "${#crit[@]}" -gt "$CRIT_CAP" ]; then
echo " !! $(( ${#crit[@]} - CRIT_CAP )) FURTHER CRITICAL FINDINGS NOT SHOWN."
echo " !! ${#crit[@]} critical findings in one session is ABNORMAL."
fi
fi
echo "----------------------------------------------------------------"
if [ "${#crit[@]}" -gt 0 ]; then
echo " Review BEFORE the next 'git' or 'claude' in that repo."
echo " dejima: ${#crit[@]} CRITICAL - code that will run on your host."
else
echo " dejima: no critical findings; ${#warn[@]} change(s) to review."
fi
} >&2
exit 0
;;
show)
report_dir="$AUDIT_DIR/$(ws_slug "$ws")"
report="$(select_report "$report_dir")" \
|| { echo "dejima audit: no reports for $ws" >&2; exit 0; }
# Which report, on stderr so stdout stays the report content alone:
# `show` no longer necessarily prints the newest one, and an operator
# cross-referencing the run timestamps in the startup warning needs to
# know which of them they are looking at.
echo "dejima audit: $report" >&2
cat "$report"
exit 0
;;
ack)
report_dir="$AUDIT_DIR/$(ws_slug "$ws")"
bf="$BASELINE_DIR/$(ws_slug "$ws")"
report="$(select_report "$report_dir")" \
|| { echo "dejima audit: no reports for $ws" >&2; exit 0; }
# Accumulate in memory and write once at the end: an ack interrupted
# partway (q/Esc/EOF/Ctrl-C) must not leave a CRITICAL surface
# silently approved, so every abort path discards everything rather
# than half-writing.
pending=(); aborted=0
# crit_seen/crit_acked gate the -CRITICAL.txt rename below: it must
# fire only when every CRITICAL finding in THIS report was actually
# approved, not merely when the operator approved something. A
# report mixing an unacknowledged CRITICAL with an approved WARNING
# (or a non-path CRITICAL, which can never be "approved" - see
# ackable below) must keep its suffix and stay eviction-protected.
crit_seen=0; crit_acked=0
# The loop below reads report lines on its own stdin (`< "$report"`),
# so operator keystrokes are read from fd 3 - a copy of the script's
# original stdin taken before that redirection - rather than
# /dev/tty (which has nothing to open under piped test input; a
# bare `read` on fd 0 would otherwise be consuming the very report
# lines the loop is iterating). In real use fd 3 is still the
# operator's terminal, since that's what an interactive shell hands
# a foreground command as fd 0. Piping into `ack` therefore drives
# approvals in both test and real use through the same code path -
# safe, because the read-time CRITICAL guard in baseline_suppresses()
# (a `spam` entry can never silence a CRITICAL surface) holds no
# matter how a baseline entry got written, piped or typed.
{ exec 3<&0; } 2>/dev/null
while IFS= read -r rline; do
tier="${rline%% *}"
# Report lines are column-padded by the printf that wrote them
# (`'CRITICAL %s\n'` vs `'WARNING %s\n'` - one vs two spaces),
# so stripping only the first space would leave WARNING bodies
# with a leading space that a since-anchored case match (see
# below) would silently fail to match. Strip the exact,
# known-fixed prefix per tier instead of a single space.
case "$tier" in
CRITICAL) body="${rline#CRITICAL }" ;;
WARNING) body="${rline#WARNING }" ;;
*) continue ;;
esac
[ "$tier" = CRITICAL ] && crit_seen=$((crit_seen + 1))
# Derive the namespaced id baseline_suppresses() will later
# compare against (see id_severity/current_sha_for_id above) -
# one case arm per finding-producing loop in `compare`. Every
# pattern is anchored to the fixed label text `compare` itself
# emits at the START of body, never matched as a substring
# anywhere in it: a workspace-controlled value (a git config
# value, a path) can contain text like "workspace file: " or
# "TCB (sandbox self) modified: " and reach $body unmodified, so
# an unanchored match could be hijacked into deriving the wrong
# (attacker-chosen) id for the wrong namespace - approving it
# would then baseline an id that can never match, un-baselining
# the real finding while still counting it as acknowledged. A
# finding whose body matches none of these (currently only
# CROSS-PROJECT) has no id to record, so it's shown but not
# offered a/s.
#
# The id itself must then be recovered with a shortest-prefix
# strip of that same fixed label (`${body#LABEL}`), never
# `${body##*: }`: the latter strips up to the LAST ": " in the
# whole string, and a workspace-controlled path can itself
# contain a literal ": " (e.g. ".git/hooks/pre-commit: decoy"),
# which would truncate the derived id to everything after that
# planted separator - the case match being anchored doesn't
# protect the extraction that follows it.
#
# Two forms come out of this, because the two consumers need
# different ones (see enc_id): $id is the ENCODED id exactly as
# the report carries it, and is what gets written to the
# baseline file, which stores that form. $rid is the same id
# decoded back to the raw bytes on disk, for current_sha_for_id
# to stat and hash and for id_severity to classify. Only the
# id-bearing portion is decoded - never the namespace prefix
# this code adds itself.
ackable=0; id=""; rid=""
case "$body" in
"HOST-CODE-EXECUTION workspace file: "*)
ackable=1; id="${body#HOST-CODE-EXECUTION workspace file: }"
rid="$(dec_id "$id")" ;;
"changed workspace file: "*)
ackable=1; id="${body#changed workspace file: }"
rid="$(dec_id "$id")" ;;
"COMMAND-EXECUTION git config: "*|"COMMAND-EXECUTION git alias: "*)
ackable=1; cfgline="${body#*: }"; ekey="${cfgline%%=*}"
id="gitconfig:$ekey"; rid="gitconfig:$(dec_id "$ekey")" ;;
"HOST-CODE-EXECUTION statusline.sh changed (runs UNSANDBOXED on the host via settings.json statusLine.command): "*)
ackable=1; esl="${body#HOST-CODE-EXECUTION statusline.sh changed (runs UNSANDBOXED on the host via settings.json statusLine.command): }"
id="statusline:$esl"; rid="statusline:$(dec_id "$esl")" ;;
"TCB (sandbox self) modified: "*)
ackable=1; etcb="${body#TCB (sandbox self) modified: }"
id="tcb:$etcb"; rid="tcb:$(dec_id "$etcb")" ;;
*) ackable=0 ;;
esac
# Defence in depth: even an anchored arm above should never
# leave id empty while claiming ackable, but don't trust that -
# crit_acked (and thus whether a report can shed -CRITICAL.txt)
# must only ever advance when a real, non-empty id was derived.
[ -n "$id" ] || ackable=0
[ -n "$rid" ] || ackable=0
echo ""
echo " [$tier] $body"
if [ "$ackable" -ne 1 ]; then
printf ' [c] skip [q] quit (this finding cannot be acknowledged): '
elif [ "$tier" = CRITICAL ]; then
printf ' [a] approve this exact content [c] skip [q] quit: '
else
printf ' [a] approve [s] always ignore [c] skip [q] quit: '
fi
{ read -r key <&3; } 2>/dev/null || { aborted=1; break; }
case "$key" in
q|$'\e'|'') aborted=1; break ;;
s)
if [ "$ackable" -ne 1 ]; then
echo " cannot acknowledge: this finding cannot be acknowledged."
elif [ "$tier" = CRITICAL ]; then
echo " cannot 'always ignore' a CRITICAL surface - approve the exact content or skip."
else
pending+=("spam $id")
fi
;;
a)
if [ "$ackable" -ne 1 ]; then
echo " cannot acknowledge: this finding cannot be acknowledged."
else
sha="$(current_sha_for_id "$rid")"
if [ -z "$sha" ]; then
# Nothing left to pin the approval to: current_sha_for_id
# returning empty means $id no longer exists (deleted
# between compare and ack, or - for gitconfig - the key
# was unset). Writing `safe <id> ` anyway would record a
# baseline entry baseline_suppresses() can never match
# (its own empty-sha guard rejects it as malformed), so
# the surface would stay permanently unsuppressible -
# while still advancing crit_acked and letting a CRITICAL
# report shed its -CRITICAL.txt protection. Refuse
# instead: record nothing, and leave the report's
# protection exactly as it was.
echo " cannot approve: $id no longer exists"
else
pending+=("safe $id $sha")
[ "$tier" = CRITICAL ] && crit_acked=$((crit_acked + 1))
fi
fi
;;
*) ;;
esac
done < "$report"
if [ "$aborted" -eq 1 ] || [ "${#pending[@]}" -eq 0 ]; then
echo ""; echo "dejima audit: nothing recorded."
exit 0
fi
mkdir -p "$BASELINE_DIR"
printf '%s\n' "${pending[@]}" >> "$bf"
# Only rename off -CRITICAL.txt (making the report evictable by
# rotation) when every CRITICAL finding this report carried was
# actually approved - not just when something was approved.
if [ "$crit_seen" -gt 0 ] && [ "$crit_acked" -eq "$crit_seen" ]; then
case "$report" in
*-CRITICAL.txt) mv "$report" "${report%-CRITICAL.txt}.txt" ;;
esac
fi
echo ""; echo "dejima audit: recorded ${#pending[@]} acknowledgement(s) in $bf"
exit 0
;;
*)
echo "usage: dejima-audit {snapshot|compare} <workspace> <state-file>" >&2
echo "usage: dejima-audit {snapshot|compare|show|ack} <workspace> [state-file]" >&2
exit 2
;;
esac
+282
View File
@@ -0,0 +1,282 @@
#!/bin/bash
# Host-side watcher: turns marker files dropped by hooks/dejima-notify.sh
# into ntfy pushes. Runs as a systemd USER unit on the host - never as root,
# never inside a container. See docs/remote-access.md.
#
# Usage: dejima-notify-watch [--once]
# (no argument) watch until killed - what the systemd unit runs
# --once one scan, then exit
#
# This script is the trust boundary. Everything under the marker root is
# written by a sandboxed session that may be compromised, and the device it
# ultimately reaches - the operator's phone - is outside the sandbox. The
# in-container hook's own enum constrains nothing here: a session owns the
# contents of its bind-mounted marker directory and can create any name it
# likes in it, hook or no hook. The guarantee rests entirely on this file:
#
# - the marker NAME is the only thing consulted, and only by iterating a
# literal enum, so a name outside it is never stat'd, read, or removed
# - marker CONTENT is never read, so there is nothing to inject
# - names are never interpolated into a command
# - `rm -f` unlinks a symlink instead of following it, so a marker aimed at
# a host file removes the link and leaves the file alone
# - the click URL and every ntfy header are composed from host-side data
#
# That last point is what the enum is really for. ntfy's own headers include
# X-Actions (which can make the phone issue an arbitrary HTTP request or fire
# an Android intent), X-Attach and X-Icon (which fetch URLs), X-Email and
# X-Call. A single attacker-controlled header here would turn the phone into
# a confused deputy inside the operator's network.
#
# One scan covers every run directory, so nothing a single session can do to
# its own markers may abort it: that would let one compromised sandbox silence
# every other session's notifications.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=dejima-project-lib.sh
source "$SCRIPT_DIR/dejima-project-lib.sh"
if ! remote_enabled; then
echo "Remote access is not configured ($(remote_conf_path) does not exist)." >&2
echo "See README \"Remote access\"." >&2
exit 1
fi
NTFY_URL="$(remote_conf_get NTFY_URL)"
BASE_URL="$(remote_conf_get BASE_URL)"
if [ -z "$NTFY_URL" ]; then
echo "NTFY_URL is not set in $(remote_conf_path) - nothing to push to." >&2
exit 1
fi
# valid_remote_value permits a leading '-', which is harmless in a config file
# and not harmless in curl's argument list: curl reads "-K/path" as an OPTION,
# and -K takes the request's URL - and its output file - from that path
# instead, so a value that isn't a URL sends the push somewhere the operator
# never named. Requiring an explicit scheme is what makes these URLs rather
# than arguments; the `--` at the call site is the other half. Widening
# valid_remote_value instead would be the wrong place: it also guards nginx
# and the TTYD_PORT, neither of which wants a URL.
#
# Refusing rather than sanitizing, because remote.conf is operator-written:
# a value that can't be used as intended means they got something wrong, and
# this is the one place they say where their phone notifications go.
require_url() {
case "$2" in
http://*|https://*) ;;
*)
echo "$1 in $(remote_conf_path) must be an http:// or https:// URL: $2" >&2
exit 1
;;
esac
}
require_url NTFY_URL "$NTFY_URL"
[ -z "$BASE_URL" ] || require_url BASE_URL "$BASE_URL"
STATE_DIR="$(remote_state_dir)"
NOTIFY_DIR="$STATE_DIR/notify"
DEBOUNCE_DIR="$STATE_DIR/debounce"
DEBOUNCE_SECONDS="${DEJIMA_DEBOUNCE_SECONDS:-60}"
case "$DEBOUNCE_SECONDS" in
''|*[!0-9]*)
echo "DEJIMA_DEBOUNCE_SECONDS must be a whole number of seconds: $DEBOUNCE_SECONDS" >&2
exit 1
;;
esac
# Same check bin/dejima makes before creating this, and for the same reason:
# /tmp is the fallback parent when XDG_RUNTIME_DIR is unset, and
# `mkdir -p -m 700` only sets the mode of the leaf it creates - a symlink
# planted here would send every mkdir and every rm below it into someone
# else's tree. Created rather than required, so the unit can start before the
# first sandbox has ever run.
mkdir -p -m 700 "$STATE_DIR" 2>/dev/null || true
if [ -L "$STATE_DIR" ] || [ ! -d "$STATE_DIR" ] \
|| [ "$(stat -c %u "$STATE_DIR")" != "$(id -u)" ]; then
echo "Refusing to watch: $STATE_DIR is a symlink, or is not a directory owned" >&2
echo "by the current user. Remove it, or set XDG_RUNTIME_DIR somewhere else." >&2
exit 1
fi
# -m 700 above applies only when this process is the one that creates it, and
# either side may get there first, so the mode is set unconditionally here -
# same as bin/dejima:491.
chmod 700 "$STATE_DIR"
mkdir -p -m 700 "$NOTIFY_DIR" "$DEBOUNCE_DIR"
# From the registry (host-written), not from anything the session wrote. Still
# reduced to a safe charset: the workspace path is operator-chosen, and a
# newline in it would split a curl -H header.
project_of() {
local run_id="$1" file line raw="" name
file="$STATE_DIR/sessions/$run_id"
# An entry that can't be opened must degrade to an unnamed push, never to
# a failure: one scan covers every run directory, so aborting here would
# take every other session's notification down with it. `|| raw=""` is
# what guarantees that - a failed redirection makes the compound command
# return nonzero, and the || consumes it. Errexit is not the mechanism:
# the sole caller is push(), which reaches this through a command
# substitution, and bash does not inherit errexit into one absent shopt
# inherit_errexit. The [ -r ] check is therefore about noise, not control
# flow - and not much noise either, since push() runs only for a marker
# that was consumed and not debounced: without it, a chmod'd entry prints
# "Permission denied" once per push for that session.
if [ -r "$file" ]; then
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
workspace=*) raw="${line#*=}"; break ;;
esac
done < "$file" || raw=""
fi
name="$(basename -- "$raw" | tr -cd 'A-Za-z0-9._-' | cut -c1-64)"
printf '%s' "${name:-unknown}"
}
push() {
local run_id="$1" event="$2" project title body
local -a click=()
project="$(project_of "$run_id")"
case "$event" in
waiting) title="dejima: $project needs input"; body="Session is waiting for you." ;;
done) title="dejima: $project finished"; body="Session has stopped." ;;
*) return 0 ;;
esac
# $run_id here came from the DIRECTORY NAME bin/dejima created and was
# shape-validated by valid_run_id - never from marker content.
if [ -n "$BASE_URL" ]; then
click=(-H "Click: ${BASE_URL%/}/?arg=$run_id")
fi
# stdout is discarded (ntfy answers with a JSON receipt); stderr is not.
# -sS exists precisely to keep transport errors on stderr, and under a
# systemd user unit that is the journal, which is where they belong.
curl -fsS --max-time 10 --retry 2 \
-H "Title: $title" \
-H "Priority: default" \
"${click[@]}" \
-d "$body" \
-- "$NTFY_URL" >/dev/null \
|| echo "dejima-notify-watch: push failed for $run_id/$event" >&2
}
# Persisted rather than in-memory so --once (and a restarted unit) debounce
# the same way a long-running process does.
debounced() {
local key="$1" now="$2" last
last="$(cat "$DEBOUNCE_DIR/$key" 2>/dev/null || true)"
# A non-numeric value in $(( )) is evaluated as a VARIABLE NAME, and any
# command substitution in its subscript runs - "now[$(cmd)]" executes cmd.
# set -u does not catch it, because `now` is bound right here. Digits
# alone still aren't enough either: a leading zero is read as octal, so
# 10# forces base 10 (and independently refuses to lex a name).
#
# The shape check leads because 10# only turns execution into an
# arithmetic *error*, and a bash arithmetic error unwinds to the top
# level: it discards the rest of the enclosing top-level command, so the
# `exit 0` after a --once scan never runs and control falls through into
# the watch loop below. Refusing the value keeps that unwind unreachable.
#
# Junk reads as "not sent recently" rather than as "sent": failing the
# other way would let one unparseable file silence a session for good.
case "$last" in
''|*[!0-9]*) return 1 ;;
esac
[ "$(( now - 10#$last ))" -lt "$DEBOUNCE_SECONDS" ]
}
declare -A WARNED=()
scan() {
local dir run_id event marker now
now="$(date +%s)"
for dir in "$NOTIFY_DIR"/*; do
[ -d "$dir" ] || continue
run_id="$(basename -- "$dir")"
# Only directories bin/dejima could have created. Anything else is
# skipped without being looked into at all.
valid_run_id "$run_id" || continue
# The enum is iterated, not discovered: a name that isn't one of these
# is never stat'd, never read, never removed.
for event in waiting done; do
marker="$dir/$event"
# -e follows the link and is FALSE for a dangling symlink, which a
# session can plant as easily as a file; -L catches those so they
# are cleaned up too, without their target being created.
[ -e "$marker" ] || [ -L "$marker" ] || continue
# Consumed before it is acted on, and non-fatally: the marker
# directory is bind-mounted read-write, so a session can make its
# own marker undeletable (a non-empty directory named `waiting` is
# enough), and dying here would silence every OTHER session too.
# Skipped rather than pushed anyway, because a marker that
# survives would otherwise re-push on every scan from now on.
if ! rm -f "$marker"; then
# Once per marker per process, not once per scan: the marker
# survives, so every later scan finds it again, and a session
# that leaves one in place could otherwise fill the journal
# for as long as the watcher runs. Permanently, deliberately -
# a marker that becomes deletable and then undeletable again
# is the same stuck session, not news. The key cannot be
# inflated to grow this map: scan() iterates only the top
# level of $NOTIFY_DIR against a two-name enum, and a session
# can create directories only inside its own run directory,
# so it is bounded at twice the number of run directories.
if [ -z "${WARNED[$run_id/$event]:-}" ]; then
echo "dejima-notify-watch: warning: could not consume marker $run_id/$event" >&2
WARNED[$run_id/$event]=1
fi
continue
fi
if debounced "$run_id-$event" "$now"; then
continue
fi
printf '%s' "$now" > "$DEBOUNCE_DIR/$run_id-$event"
push "$run_id" "$event"
done
done
}
ONCE=""
case "${1:-}" in
--once) ONCE=1 ;;
"") ;;
*)
echo "usage: ${0##*/} [--once]" >&2
exit 2
;;
esac
if [ -n "$ONCE" ]; then
scan
fi
# A separate top-level command from the scan above, deliberately: a bash
# arithmetic error unwinds to the top level and discards the REST of the
# enclosing top-level command, so a `scan; exit 0` pair sharing one `if` can
# lose its exit and run the watch loop instead of returning. Nothing should
# reach such an error (debounced() refuses a non-numeric stamp before the
# arithmetic), but a --once run that never exits is a bad way to find out.
[ -z "$ONCE" ] || exit 0
# inotify-tools is optional: without it this degrades to a ~6s poll rather
# than failing - the 1s floor below plus the 5s fallback sleep, which is the
# interval a scan actually repeats at. The -t 30 timeout also closes the race
# where a marker lands in a run directory created after the current watch was
# established.
while :; do
scan
# A floor under the loop, unconditional and ahead of the wait: inotify
# wakes on the sandbox's OWN file creations, so without it a session that
# creates files in a loop drives one scan per creation for as long as it
# likes. One scan a second is far more than the notifications need.
sleep 1
if command -v inotifywait >/dev/null 2>&1; then
# A failure here is reachable from inside a sandbox rather than rare:
# -r takes one inotify watch per directory below $NOTIFY_DIR, and a
# session owns its own marker directory, so it can mkdir enough of
# them to exhaust this uid's max_user_watches - after which every
# inotifywait returns immediately. Falling back to the poll interval
# costs a little latency and bounds it; `|| true` would spin.
inotifywait -qq -r -t 30 -e create -e moved_to "$NOTIFY_DIR" >/dev/null 2>&1 \
|| sleep 5
else
sleep 5
fi
done
+101
View File
@@ -242,3 +242,104 @@ generate_derived_dockerfile() {
printf 'ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]\n'
return 0
}
# ---------------------------------------------------------------------------
# Remote access (opt-in). See docs/remote-access.md.
#
# The mere existence of remote.conf is the switch: with it absent, every code
# path this feature adds is skipped and bin/dejima builds exactly the
# invocation it built before the feature existed. Same "unset means skipped
# entirely" shape LOKI_URL already has.
# ---------------------------------------------------------------------------
remote_conf_path() {
printf '%s/remote.conf' "$(config_root)"
}
remote_enabled() {
[ -f "$(remote_conf_path)" ]
}
valid_remote_key() {
[[ "$1" =~ ^(NTFY_URL|BASE_URL|TTYD_PORT|TTYD_FONT_SIZE)$ ]]
}
# Deliberately narrow. These values end up in a curl -H header, an nginx
# comparison, and a URL handed to a phone - anything shell-active, any
# whitespace, and anything that could split a header must not survive.
valid_remote_value() {
[[ "$1" =~ ^[A-Za-z0-9:/._~?=%@+-]*$ ]]
}
# A run ID as bin/dejima generates it. Shared with dejima-attach and
# dejima-notify-watch, both of which take one from an untrusted direction
# (ttyd's --url-arg; a directory name under the marker root).
valid_run_id() {
[[ "$1" =~ ^[0-9a-f]{16}$ ]]
}
remote_state_dir() {
printf '%s/dejima' "${XDG_RUNTIME_DIR:-/tmp}"
}
# remote_conf_get <key>
# Prints the value, or nothing when the file doesn't set it. Validates the
# WHOLE file on every read, not just the requested key: a remote.conf that
# doesn't parse means the operator got something wrong, and this feature's
# security properties depend on its values - so refuse rather than proceed,
# the same way approved_entries does for the approval store.
remote_conf_get() {
local want="$1" conf line key value found=""
valid_remote_key "$want" || { echo "unknown remote.conf key: $want" >&2; return 1; }
conf="$(remote_conf_path)"
[ -f "$conf" ] || return 0
while IFS= read -r line || [ -n "$line" ]; do
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
case "$line" in
''|'#'*) continue ;;
*=*) ;;
*) echo "malformed line in $conf: $line" >&2; return 1 ;;
esac
key="${line%%=*}"
value="${line#*=}"
if ! valid_remote_key "$key"; then
echo "unknown key in $conf: $key" >&2
return 1
fi
if ! valid_remote_value "$value"; then
echo "invalid value for $key in $conf: $value" >&2
return 1
fi
[ "$key" = "$want" ] && found="$value"
done < "$conf"
printf '%s' "$found"
}
remote_ttyd_port() {
local port
port="$(remote_conf_get TTYD_PORT)" || return 1
port="${port:-7681}"
if [[ ! "$port" =~ ^[0-9]+$ ]] || [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then
echo "TTYD_PORT out of range in $(remote_conf_path): $port" >&2
return 1
fi
printf '%s' "$port"
}
# Empty when unset, which is the signal to pass no -t at all and leave ttyd on
# its own default. Bounded rather than merely numeric: this reaches ttyd as
# argv, and a value large enough to make one glyph wider than the viewport
# leaves a terminal that can render nothing at all - reported as "the front
# door is broken", with the cause three files away. 8 is already small on a
# phone; 72 is already absurd on a desktop.
remote_ttyd_font_size() {
local size
size="$(remote_conf_get TTYD_FONT_SIZE)" || return 1
[ -n "$size" ] || return 0
if [[ ! "$size" =~ ^[0-9]+$ ]] || [ "$size" -lt 8 ] || [ "$size" -gt 72 ]; then
echo "TTYD_FONT_SIZE must be 8-72 in $(remote_conf_path): $size" >&2
return 1
fi
printf '%s' "$size"
}
+121
View File
@@ -0,0 +1,121 @@
#!/bin/bash
# Launches ttyd with dejima-attach as its command, reading TTYD_PORT from
# remote.conf so the port has exactly one source of truth (the systemd unit
# deliberately does not repeat it). Started by systemd/dejima-ttyd.service.
# See docs/remote-access.md.
#
# --interface 127.0.0.1 is a security requirement, not a preference.
# 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 (the prefix key opens a new window outside the
# container). A listener on any address the container can route to would hand
# a compromised session its own escape hatch. Loopback is not one of those
# addresses: the container has no route to the host's 127.0.0.1. Put nginx in
# front, bound to one specific LAN address - see
# docs/nginx-dejima.conf.example.
#
# ttyd is given no credential of its own, so the reverse proxy is the only
# thing authenticating a request, and the loopback bind is what makes that
# safe rather than careless: nothing but a process on this host can open the
# socket at all. --writable is what makes the terminal a terminal rather than
# a viewer.
#
# --check-origin is the other half of that, and it is not optional. Without
# it ttyd does no origin validation at all (it only calls check_host_origin
# when the flag is set), and browsers do not apply the same-origin policy to
# WebSockets: any page the operator's browser loads could open
# wss://<front-door>/ws, have the browser attach its cached credential for
# that origin, and be handed this host shell. Proxy authentication cannot
# stop that - it is satisfied by the victim's own browser. ttyd compares the
# Origin authority against the Host header, which is why the nginx example
# passes Host through unchanged; the two go together.
#
# --url-arg lets the client pass ?arg=<run-id>, which is what the notification
# deep link uses. It reaches dejima-attach as argv, never as a shell word, and
# anything there that is not --list or the empty string (a bare `?arg=`, which
# lands on the picker) has to match the run-id shape before it is used as a
# path component or anything else.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=dejima-project-lib.sh
source "$SCRIPT_DIR/dejima-project-lib.sh"
if ! remote_enabled; then
echo "Remote access is not configured ($(remote_conf_path) does not exist)." >&2
echo "See README \"Remote access\"." >&2
exit 1
fi
# Read BEFORE the ttyd check below, deliberately: an operator with both a
# broken remote.conf and no ttyd is otherwise told only about the package,
# installs it, comes back to the identical failure, and never learns that
# their config is the other half of it. remote_ttyd_port validates the whole
# file and names the line that broke it, and a failure here is fatal under
# set -e on the assignment.
port="$(remote_ttyd_port)"
# Optional, and applied to EVERY client - ttyd 1.7.7 takes client options only
# at launch (the URL-query override that would allow a per-client size landed
# after that release), so there is no way to give a phone one size and a
# desktop another from here. Left unset, ttyd keeps its own default.
#
# It is the smaller half of the mobile fix, not the fix: ttyd's page carries no
# viewport meta, so a phone lays it out at a virtual desktop width and scales
# the result down - a bigger font scales down with everything else. The nginx
# example injects the meta; see docs/nginx-dejima.conf.example.
font_size="$(remote_ttyd_font_size)"
# Checked rather than left to exec, whose failure is bash's "ttyd: not found"
# on a line number - true, and useless to an operator who needs to know that
# the missing piece is a package.
if ! command -v ttyd >/dev/null 2>&1; then
echo "ttyd is not installed - the web front door needs it, 1.7.4 or newer" >&2
echo "(-W/--writable arrived in 1.7.4; older builds are writable by default" >&2
echo "and reject the flag, so every start would be a usage error)." >&2
exit 1
fi
# The distro's own ttyd package ships an ENABLED system service that runs
# `login` and, having no --interface of its own, binds every interface - the
# bridge address a sandbox can route to included. Installing ttyd to set this
# feature up is therefore enough to open a host login prompt to the sandbox,
# on this very port, before any of this runs.
#
# It also takes the port. Binding the loopback address below while another
# process already holds that port on every interface fails with EADDRINUSE, so
# this unit dies at every start while the reverse proxy in front of it happily
# proxies to whatever DID get the port. The operator sees the password prompt
# they configured and then a host login shell, and nothing in that path looks
# broken.
#
# So refuse, and say which. Left to exec, the whole failure is ttyd's one-line
# bind error in a unit nobody is watching.
if command -v ss >/dev/null 2>&1; then
listeners="$(ss -ltnH "sport = :$port" 2>/dev/null || true)"
if [ -n "$listeners" ]; then
echo "Something is already listening on port $port:" >&2
echo "$listeners" >&2
echo >&2
echo "The web front door cannot start, and until that listener is gone the" >&2
echo "reverse proxy in front of this port serves IT, not dejima." >&2
echo >&2
echo "The usual cause is the distro's own ttyd service, which is enabled on" >&2
echo "install and runs 'login' on every interface - reachable from inside a" >&2
echo "sandbox. Check and disable it:" >&2
echo >&2
echo " systemctl status ttyd.service" >&2
echo " sudo systemctl disable --now ttyd.service" >&2
echo " sudo systemctl mask ttyd.service" >&2
echo >&2
echo "See README \"Remote access\"." >&2
exit 1
fi
fi
font_opt=()
[ -z "$font_size" ] || font_opt=(-t "fontSize=$font_size")
exec ttyd --interface 127.0.0.1 --port "$port" --check-origin --url-arg --writable \
${font_opt[@]+"${font_opt[@]}"} \
"$SCRIPT_DIR/dejima-attach"
+255
View File
@@ -0,0 +1,255 @@
#!/bin/bash
# Read and write Gitea issues from a shell - the issue-side counterpart to
# gitea-pr, and deliberately its twin rather than its sibling: both are
# copied into the sandbox 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 duplicated config/token/curl
# preamble is pinned in both files by test/static.bats instead.
set -euo pipefail
GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.config/gitea/token}"
function usage() {
echo "Usage: gitea-issue list [--state open|closed|all] [--limit N]"
echo " gitea-issue view NUMBER"
echo " gitea-issue create --title TITLE [--body BODY | --body-file FILE]"
echo " gitea-issue comment NUMBER (--body BODY | --body-file FILE)"
echo " gitea-issue close NUMBER"
echo " gitea-issue reopen NUMBER"
echo ""
echo "--body-file reads the body from FILE, or from stdin when FILE is '-'."
echo "Prefer it over --body for anything with quotes, backticks or newlines."
echo ""
echo "Requires GITEA_SSH_HOST and GITEA_API set in the environment (e.g. in" \
"your shell profile), and a token at $GITEA_TOKEN_FILE (or GITEA_TOKEN" \
"set for this invocation only)."
}
function require_config() {
if [ -z "${GITEA_SSH_HOST:-}" ]; then
echo "GITEA_SSH_HOST is not set. Add 'export GITEA_SSH_HOST=<gitea ssh hostname>'" \
"(e.g. gitea.example.com) to ~/.profile.local" >&2
exit 1
fi
if [ -z "${GITEA_API:-}" ]; then
echo "GITEA_API is not set. Add 'export GITEA_API=https://<gitea host>/api/v1'" \
"(e.g. https://gitea.example.com/api/v1) to ~/.profile.local" >&2
exit 1
fi
}
function require_token() {
if [ -z "${GITEA_TOKEN:-}" ] && [ -f "$GITEA_TOKEN_FILE" ]; then
GITEA_TOKEN=$(<"$GITEA_TOKEN_FILE")
fi
if [ -z "${GITEA_TOKEN:-}" ]; then
echo "No Gitea token found. Generate one in the Gitea web UI" \
"(Settings > Applications, scopes write:repository + write:issue)" \
"and save it to $GITEA_TOKEN_FILE (chmod 600), or set GITEA_TOKEN" \
"in the environment for this invocation only." >&2
exit 1
fi
}
# Feeds the auth header to curl as a config on stdin rather than as an
# argument. Command lines are world-readable through /proc, so `-H
# "Authorization: token $GITEA_TOKEN"` exposes the token to every other
# process on the host for as long as the request runs - and to anything that
# scrapes `ps` output, including the agents this repo exists to sandbox.
# printf is a bash builtin, so the token never lands in another argv.
function gitea_api() {
printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" |
curl -sS -w '\n%{http_code}' -K - "$@"
}
# Splits gitea_api's combined output into the globals `json` and `http_code`
# and fails with the body on any non-2xx. Every call site did this by hand in
# gitea-pr; there are six of them here, and six hand-rolled copies is where
# one of them quietly stops checking.
function api_or_die() {
local what="$1"; shift
local response
response=$(gitea_api "$@")
http_code=$(echo "$response" | tail -1)
json=$(echo "$response" | sed '$d')
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
echo "Failed to $what (HTTP $http_code):" >&2
echo "$json" >&2
exit 1
fi
}
# Reads a body from a file, or from stdin for '-'. The point is to keep prose
# out of argv: a body composed on a command line has to survive shell quoting,
# and the things that break it - an apostrophe, a backtick, a $ - are exactly
# what English and code fences are made of. A file (or a heredoc into '-') has
# no such layer.
function read_body_file() {
local file="$1"
if [ "$file" = "-" ]; then
cat
return
fi
if [ ! -f "$file" ]; then
echo "No such body file: $file" >&2
exit 1
fi
cat "$file"
}
function gitea_owner_repo() {
local remote_url host owner_repo
remote_url=$(git remote get-url origin 2>/dev/null) || {
echo "No 'origin' remote found in this repository" >&2
exit 1
}
host=$(echo "$remote_url" | sed -E 's#^[^@]+@([^:]+):.*#\1#')
if [ "$host" != "$GITEA_SSH_HOST" ]; then
echo "origin remote ($remote_url) is not on $GITEA_SSH_HOST; gitea-issue only supports this host" >&2
exit 1
fi
owner_repo=$(echo "$remote_url" | sed -E 's#^[^@]+@[^:]+:##; s#\.git$##')
echo "$owner_repo"
}
function require_number() {
if ! [[ "${1:-}" =~ ^[0-9]+$ ]]; then
echo "Expected an issue number, got '${1:-}'" >&2
exit 1
fi
}
# Sets `body` from --body/--body-file, rejecting both. Shared by create and
# comment so the two cannot drift into disagreeing about which one wins.
function parse_body_args() {
body=""
local body_file=""
parsed_title=""
while [ "$#" -gt 0 ]; do
case "$1" in
--title) parsed_title="$2"; shift 2 ;;
--body) body="$2"; shift 2 ;;
--body-file) body_file="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
esac
done
# Rejected rather than ranked: silently preferring one would make a caller
# that passed both ship a body it didn't mean to.
if [ -n "$body" ] && [ -n "$body_file" ]; then
echo "Pass --body or --body-file, not both" >&2
exit 1
fi
if [ -n "$body_file" ]; then
body="$(read_body_file "$body_file")"
fi
}
if [ "$#" -lt 1 ]; then
usage
exit 1
fi
subcommand="$1"
shift
case "$subcommand" in
list|view|create|comment|close|reopen)
require_config
require_token
owner_repo=$(gitea_owner_repo)
owner="${owner_repo%%/*}"
repo="${owner_repo##*/}"
;;
*)
usage
exit 1
;;
esac
issues_api="${GITEA_API}/repos/${owner}/${repo}/issues"
case "$subcommand" in
list)
state="open"
limit="30"
while [ "$#" -gt 0 ]; do
case "$1" in
--state) state="$2"; shift 2 ;;
--limit) limit="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
esac
done
case "$state" in
open|closed|all) ;;
*) echo "--state must be open, closed or all" >&2; exit 1 ;;
esac
if ! [[ "$limit" =~ ^[0-9]+$ ]]; then
echo "--limit must be a number" >&2
exit 1
fi
# type=issues is load-bearing: Gitea's issues endpoint returns pull
# requests too, since a PR *is* an issue with a branch attached.
# Without it every open PR shows up here as an issue, which reads as
# a work list that is quietly twice as long as the real one.
api_or_die "list issues" \
"${issues_api}?state=${state}&type=issues&limit=${limit}"
echo "$json" | jq -r '.[] | "#\(.number)\t[\(.state)]\t\(.title)\t(\(.comments) comments)"'
;;
view)
require_number "${1:-}"
number="$1"
api_or_die "fetch issue #${number}" "${issues_api}/${number}"
echo "$json" | jq -r '"#\(.number) [\(.state)] \(.title)\n[\(.user.login) @ \(.created_at)]\n\(.body // "(no description)")\n"'
echo "=== Comments ==="
api_or_die "fetch comments for issue #${number}" "${issues_api}/${number}/comments"
echo "$json" | jq -r '.[] | "[\(.user.login) @ \(.created_at)]\n\(.body)\n"'
;;
create)
parse_body_args "$@"
if [ -z "$parsed_title" ]; then
echo "gitea-issue create requires --title" >&2
exit 1
fi
body="**Filed by LLM agent**
${body}"
api_or_die "create issue" -X POST "$issues_api" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg title "$parsed_title" --arg body "$body" \
'{title: $title, body: $body}')"
echo "$json" | jq -r '.html_url'
;;
comment)
require_number "${1:-}"
number="$1"
shift
parse_body_args "$@"
if [ -z "$body" ]; then
echo "gitea-issue comment requires --body or --body-file" >&2
exit 1
fi
body="**Comment by LLM agent**
${body}"
api_or_die "comment on issue #${number}" -X POST "${issues_api}/${number}/comments" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg body "$body" '{body: $body}')"
echo "$json" | jq -r '.html_url'
;;
close|reopen)
require_number "${1:-}"
number="$1"
new_state="closed"
[ "$subcommand" = "reopen" ] && new_state="open"
api_or_die "${subcommand} issue #${number}" -X PATCH "${issues_api}/${number}" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg state "$new_state" '{state: $state}')"
echo "$json" | jq -r '"#\(.number) is now \(.state): \(.html_url)"'
;;
esac
+35 -3
View File
@@ -4,10 +4,13 @@ set -euo pipefail
GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.config/gitea/token}"
function usage() {
echo "Usage: gitea-pr create [--title TITLE] [--body BODY] [--base BRANCH]"
echo "Usage: gitea-pr create [--title TITLE] [--body BODY | --body-file FILE] [--base BRANCH]"
echo " gitea-pr comments PR_NUMBER"
echo " gitea-pr merge PR_NUMBER"
echo ""
echo "--body-file reads the body from FILE, or from stdin when FILE is '-'."
echo "Prefer it over --body for anything with quotes, backticks or newlines."
echo ""
echo "Requires GITEA_SSH_HOST and GITEA_API set in the environment (e.g. in" \
"your shell profile), and a token at $GITEA_TOKEN_FILE (or GITEA_TOKEN" \
"set for this invocation only)."
@@ -16,12 +19,12 @@ function usage() {
function require_config() {
if [ -z "${GITEA_SSH_HOST:-}" ]; then
echo "GITEA_SSH_HOST is not set. Add 'export GITEA_SSH_HOST=<gitea ssh hostname>'" \
"(e.g. gitbox.bradina.localdomain) to ~/.profile.local" >&2
"(e.g. gitea.example.com) to ~/.profile.local" >&2
exit 1
fi
if [ -z "${GITEA_API:-}" ]; then
echo "GITEA_API is not set. Add 'export GITEA_API=https://<gitea host>/api/v1'" \
"(e.g. https://gitbox.branderson.io/api/v1) to ~/.profile.local" >&2
"(e.g. https://gitea.example.com/api/v1) to ~/.profile.local" >&2
exit 1
fi
}
@@ -50,6 +53,24 @@ function gitea_api() {
curl -sS -w '\n%{http_code}' -K - "$@"
}
# Reads a body from a file, or from stdin for '-'. The point is to keep prose
# out of argv: a body composed on a command line has to survive shell quoting,
# and the things that break it - an apostrophe, a backtick, a $ - are exactly
# what English and code fences are made of. A file (or a heredoc into '-') has
# no such layer.
function read_body_file() {
local file="$1"
if [ "$file" = "-" ]; then
cat
return
fi
if [ ! -f "$file" ]; then
echo "No such body file: $file" >&2
exit 1
fi
cat "$file"
}
function gitea_owner_repo() {
local remote_url host owner_repo
remote_url=$(git remote get-url origin 2>/dev/null) || {
@@ -91,15 +112,26 @@ case "$subcommand" in
create)
title=""
body=""
body_file=""
base=""
while [ "$#" -gt 0 ]; do
case "$1" in
--title) title="$2"; shift 2 ;;
--body) body="$2"; shift 2 ;;
--body-file) body_file="$2"; shift 2 ;;
--base) base="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
esac
done
# Rejected rather than ranked: silently preferring one would make a
# caller that passed both ship a body it didn't mean to.
if [ -n "$body" ] && [ -n "$body_file" ]; then
echo "Pass --body or --body-file, not both" >&2
exit 1
fi
if [ -n "$body_file" ]; then
body="$(read_body_file "$body_file")"
fi
body="**Summary generated by LLM agent**
${body}"
+114 -64
View File
@@ -11,8 +11,9 @@ ENV TZ="$TZ"
ARG CLAUDE_CODE_VERSION=latest
# Install basic development tools, iptables/ipset (for the egress firewall),
# openssh-client (for git-over-ssh and agent forwarding), and the toolchain
# nvim's plugin config (config/nvim, telescope/treesitter/mason) expects.
# openssh-client (for git-over-ssh and agent forwarding), and the toolchain a
# typical nvim plugin set (telescope/treesitter/mason) expects, since
# SANDBOX_NVIM_CONFIG can point a session's editor at one.
#
# Deliberately NOT installing bubblewrap/socat: those exist to let Claude
# Code's own internal per-command sandboxing (~/.claude/settings.json's
@@ -39,6 +40,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
dnsutils \
aggregate \
jq \
openssl \
dnsmasq \
ulogd2 \
ulogd2-json \
@@ -59,45 +61,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& ln -s "$(command -v fdfind)" /usr/local/bin/fd
# cowsay/cowthink/fortune install to /usr/games on Debian, which isn't on
# PATH by default - config/nvim's start-screen header (plugins/ui.lua) shells
# out to them by bare name. Set via ENV (not a profile.d script) so it's
# PATH by default - nvim start-screen plugins commonly shell out to them by
# bare name for a dashboard header. Set via ENV (not a profile.d script) so it's
# picked up by every process unconditionally, including nvim's non-login
# subprocess spawns, which wouldn't source /etc/profile.d.
ENV PATH=/usr/games:$PATH
# Replace the base image's built-in `node` user (uid/gid 1000,
# $HOME=/home/node) with one matching the host user who runs
# bin/dejima - so absolute paths under $HOME (dotfiles, and
# ~/.claude/plugins/installed_plugins.json's own installPath bookkeeping,
# which Claude Code resolves as a literal host-absolute string rather than
# relative to whatever $HOME happens to be) resolve identically inside and
# outside the container, instead of needing a one-off dual-mount for every
# individual thing that assumes the real host path. Defaults below
# reproduce today's node/1000/1000//home/node exactly, so a build that
# never overrides them (bin/dejima always does; the test suite
# never does) exercises this same rename path rather than a separate,
# untested one.
ARG SANDBOX_USER=node
ARG SANDBOX_UID=1000
ARG SANDBOX_GID=1000
ARG SANDBOX_HOME=/home/node
RUN userdel -r node 2>/dev/null; \
groupdel node 2>/dev/null; \
groupadd -g "$SANDBOX_GID" "$SANDBOX_USER" && \
useradd -u "$SANDBOX_UID" -g "$SANDBOX_GID" -m -d "$SANDBOX_HOME" -s /bin/bash "$SANDBOX_USER"
ENV SANDBOX_USER=$SANDBOX_USER
ENV SANDBOX_HOME=$SANDBOX_HOME
# Ensure the sandbox user has access to /usr/local/share
RUN mkdir -p /usr/local/share/npm-global && \
chown -R $SANDBOX_USER:$SANDBOX_USER /usr/local/share
# Persist bash history. The env vars that actually wire history up to this
# file are set below via zsh-in-docker's -a flag.
RUN mkdir /commandhistory && \
touch /commandhistory/.bash_history && \
chown -R $SANDBOX_USER /commandhistory
# Set `DEVCONTAINER` environment variable to help with orientation
ENV DEVCONTAINER=true
@@ -115,25 +84,23 @@ ENV DEJIMA=1
# this user, so it doesn't need to own anything itself.
RUN useradd -r -s /usr/sbin/nologin ulogd
# Create the ssh directory and set permissions. The project directory itself
# is bind-mounted at runtime at its real host path (see docker-compose.yml),
# not a fixed /workspace, so it can share ~/.claude project/session history
# with the host. ~/.claude is bind-mounted too, not baked in here.
RUN mkdir -p $SANDBOX_HOME/.ssh && \
chown -R $SANDBOX_USER:$SANDBOX_USER $SANDBOX_HOME/.ssh && \
chmod 700 $SANDBOX_HOME/.ssh
# Pre-create the nvim data/state/cache dirs owned by the sandbox user,
# since they're mounted as named volumes in docker-compose.yml: Docker
# seeds a fresh named volume from whatever already exists at the mount
# path in the image, ownership included, so this is what keeps those
# volumes writable by that user instead of defaulting to root.
RUN mkdir -p $SANDBOX_HOME/.local/share/nvim $SANDBOX_HOME/.local/state/nvim $SANDBOX_HOME/.cache/nvim && \
chown -R $SANDBOX_USER:$SANDBOX_USER $SANDBOX_HOME/.local $SANDBOX_HOME/.cache
# Every fetch from here down retries, and none is single-shot. What matters
# isn't the odds of a particular download failing, it's the odds of any of
# them failing - and without retries a single blip anywhere throws away the
# whole build.
#
# These steps deliberately sit *above* the SANDBOX_* ARGs, not below. Nothing
# between here and `USER` needs the sandbox user to exist yet - they all
# install system-wide as root - so keeping them ahead of the ARGs means a
# build that overrides one (test/runtime.bats does, and so does any host
# whose uid isn't 1000) still reuses every layer down to this point instead
# of re-running all of it. That single ordering was most of the cost of the
# override rebuild: it was 1169s of a 1379s test run, for a difference that
# amounts to one useradd.
ARG GIT_DELTA_VERSION=0.18.2
RUN ARCH=$(dpkg --print-architecture) && \
wget "https://github.com/dandavison/delta/releases/download/${GIT_DELTA_VERSION}/git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \
"https://github.com/dandavison/delta/releases/download/${GIT_DELTA_VERSION}/git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
dpkg -i "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
rm "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb"
@@ -143,8 +110,10 @@ RUN ARCH=$(dpkg --print-architecture) && \
# published SHA256SUMS since it runs as root.
ARG GOSU_VERSION=1.19
RUN ARCH=$(dpkg --print-architecture) && \
wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/${GOSU_VERSION}/gosu-${ARCH}" && \
wget -O /tmp/gosu.sha256sums "https://github.com/tianon/gosu/releases/download/${GOSU_VERSION}/SHA256SUMS" && \
wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \
-O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/${GOSU_VERSION}/gosu-${ARCH}" && \
wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \
-O /tmp/gosu.sha256sums "https://github.com/tianon/gosu/releases/download/${GOSU_VERSION}/SHA256SUMS" && \
grep -E "gosu-${ARCH}\$" /tmp/gosu.sha256sums | sed "s#gosu-${ARCH}#/usr/local/bin/gosu#" | sha256sum -c - && \
rm /tmp/gosu.sha256sums && \
chmod +x /usr/local/bin/gosu
@@ -153,9 +122,11 @@ RUN ARCH=$(dpkg --print-architecture) && \
# old for this config's plugins (blink.cmp etc. need a recent Neovim), so
# fetch the current release from GitHub directly rather than pinning a
# version that may go stale.
RUN asset_url=$(curl -fsSL https://api.github.com/repos/neovim/neovim/releases/latest \
RUN asset_url=$(curl -fsSL --retry 3 --retry-delay 2 --retry-all-errors --max-time 60 \
https://api.github.com/repos/neovim/neovim/releases/latest \
| jq -r '[.assets[] | select(.name | test("linux.*x86_64.*\\.tar\\.gz$"))][0].browser_download_url') && \
wget -O nvim.tar.gz "$asset_url" && \
wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \
-O nvim.tar.gz "$asset_url" && \
tar -C /usr/local --strip-components=1 -xzf nvim.tar.gz && \
rm nvim.tar.gz
@@ -165,12 +136,14 @@ RUN asset_url=$(curl -fsSL https://api.github.com/repos/neovim/neovim/releases/l
# need proxy.golang.org and sum.golang.org reachable at runtime - see
# fixed-domains.sh.
RUN ARCH=$(dpkg --print-architecture) && \
release_json=$(curl -fsSL 'https://go.dev/dl/?mode=json') && \
release_json=$(curl -fsSL --retry 3 --retry-delay 2 --retry-all-errors --max-time 60 \
'https://go.dev/dl/?mode=json') && \
filename=$(echo "$release_json" | jq -r --arg arch "$ARCH" \
'[.[] | select(.stable == true)][0].files[] | select(.os == "linux" and .arch == $arch and .kind == "archive") | .filename') && \
sha256=$(echo "$release_json" | jq -r --arg arch "$ARCH" \
'[.[] | select(.stable == true)][0].files[] | select(.os == "linux" and .arch == $arch and .kind == "archive") | .sha256') && \
wget -O go.tar.gz "https://go.dev/dl/${filename}" && \
wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \
-O go.tar.gz "https://go.dev/dl/${filename}" && \
echo "${sha256} go.tar.gz" | sha256sum -c - && \
tar -C /usr/local -xzf go.tar.gz && \
rm go.tar.gz
@@ -183,6 +156,70 @@ ENV PATH=$PATH:/usr/local/go/bin
ARG PLAYWRIGHT_VERSION=1.61.1
RUN npx -y playwright@${PLAYWRIGHT_VERSION} install-deps chromium
# Replace the base image's built-in `node` user (uid/gid 1000,
# $HOME=/home/node) with one matching the host user who runs
# bin/dejima - so absolute paths under $HOME (dotfiles, and
# ~/.claude/plugins/installed_plugins.json's own installPath bookkeeping,
# which Claude Code resolves as a literal host-absolute string rather than
# relative to whatever $HOME happens to be) resolve identically inside and
# outside the container, instead of needing a one-off dual-mount for every
# individual thing that assumes the real host path. Defaults below
# reproduce today's node/1000/1000//home/node exactly, so a build that
# never overrides them (bin/dejima always does; the test suite
# never does) exercises this same rename path rather than a separate,
# untested one.
#
# As late as it can go: everything an override invalidates is below this
# line, so the cheaper this block is to reach, the cheaper an override is.
ARG SANDBOX_USER=node
ARG SANDBOX_UID=1000
ARG SANDBOX_GID=1000
ARG SANDBOX_HOME=/home/node
RUN userdel -r node 2>/dev/null; \
groupdel node 2>/dev/null; \
groupadd -g "$SANDBOX_GID" "$SANDBOX_USER" && \
useradd -u "$SANDBOX_UID" -g "$SANDBOX_GID" -m -d "$SANDBOX_HOME" -s /bin/bash "$SANDBOX_USER"
ENV SANDBOX_USER=$SANDBOX_USER
ENV SANDBOX_HOME=$SANDBOX_HOME
# Ensure the sandbox user has access to /usr/local/share. The recursion is
# scoped to npm-global rather than applied to the whole directory, which is
# what this used to do: Neovim now unpacks into /usr/local/share/nvim above
# this line rather than below it, and recursing from the parent would hand
# the sandbox user Neovim's runtime files. /usr/local/share itself is still
# chowned, so anything that expected to create entries in it still can.
RUN mkdir -p /usr/local/share/npm-global && \
chown $SANDBOX_USER:$SANDBOX_USER /usr/local/share && \
chown -R $SANDBOX_USER:$SANDBOX_USER /usr/local/share/npm-global
# Persist bash history. The env vars that actually wire history up to this
# file are set below via zsh-in-docker's -a flag.
RUN mkdir /commandhistory && \
touch /commandhistory/.bash_history && \
chown -R $SANDBOX_USER /commandhistory
# Create the ssh directory and set permissions. The project directory itself
# is bind-mounted at runtime at its real host path (see docker-compose.yml),
# not a fixed /workspace, so it can share ~/.claude project/session history
# with the host.
#
# ~/.claude itself is baked in here rather than bind-mounted (see the mount
# inventory in docker-compose.yml): only enumerated paths *inside* it come
# from the host, and Docker creates the parents of a mount point root-owned
# if they don't already exist - which would leave the sandbox user unable to
# write the caches and history it keeps directly in ~/.claude.
RUN mkdir -p $SANDBOX_HOME/.ssh $SANDBOX_HOME/.claude && \
chown -R $SANDBOX_USER:$SANDBOX_USER $SANDBOX_HOME/.ssh $SANDBOX_HOME/.claude && \
chmod 700 $SANDBOX_HOME/.ssh $SANDBOX_HOME/.claude
# Pre-create the nvim data/state/cache dirs owned by the sandbox user,
# since they're mounted as named volumes in docker-compose.yml: Docker
# seeds a fresh named volume from whatever already exists at the mount
# path in the image, ownership included, so this is what keeps those
# volumes writable by that user instead of defaulting to root.
RUN mkdir -p $SANDBOX_HOME/.local/share/nvim $SANDBOX_HOME/.local/state/nvim $SANDBOX_HOME/.cache/nvim && \
chown -R $SANDBOX_USER:$SANDBOX_USER $SANDBOX_HOME/.local $SANDBOX_HOME/.cache
# Set up non-root user
USER $SANDBOX_USER
@@ -205,14 +242,25 @@ ENV VISUAL=nano
# Default powerline10k theme, git/fzf oh-my-zsh plugins, fzf's own key
# bindings, and wiring HISTFILE to the persisted /commandhistory volume -
# see https://github.com/deluan/zsh-in-docker for the installer's own flags.
#
# Downloaded to a file and then run, rather than upstream's documented
# `sh -c "$(wget -O- ...)"`. In that form a failed download substitutes to an
# empty string, `sh -c ""` exits 0, and the build goes green having installed
# no zsh config at all - the failure only surfaces much later, as a shell that
# is mysteriously unconfigured. Two steps make the download's exit status the
# build's exit status.
ARG ZSH_IN_DOCKER_VERSION=1.2.0
RUN sh -c "$(wget -O- https://github.com/deluan/zsh-in-docker/releases/download/v${ZSH_IN_DOCKER_VERSION}/zsh-in-docker.sh)" -- \
RUN wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \
-O /tmp/zsh-in-docker.sh \
"https://github.com/deluan/zsh-in-docker/releases/download/v${ZSH_IN_DOCKER_VERSION}/zsh-in-docker.sh" && \
sh /tmp/zsh-in-docker.sh \
-p fzf \
-p git \
-a "export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \
-a "source /usr/share/doc/fzf/examples/key-bindings.zsh" \
-a "source /usr/share/doc/fzf/examples/completion.zsh" \
-x
-x && \
rm /tmp/zsh-in-docker.sh
# Install Claude Code
RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}
@@ -244,7 +292,8 @@ RUN npm install -g playwright@${PLAYWRIGHT_VERSION} && \
npx playwright install chromium
# Copy and set up firewall + DNS-proxy + entrypoint scripts, the gitea-pr
# helper from this repo's bin/ directory, and the claude wrapper that pins
# and gitea-issue helpers from this repo's bin/ directory, and the claude
# wrapper that pins
# --dangerously-skip-permissions. The launched application itself
# (SANDBOX_CMD, default `claude`) and any editor config for it are supplied
# at runtime, not baked into the image - see docker-compose.yml/entrypoint.sh.
@@ -259,9 +308,10 @@ COPY docker/entrypoint.sh /usr/local/bin/
COPY docker/anthropic-proxy-forwarder.js /usr/local/bin/anthropic-proxy-forwarder.js
COPY docker/claude-wrapper.sh /usr/local/bin/claude
COPY bin/gitea-pr /usr/local/bin/gitea-pr
COPY bin/gitea-issue /usr/local/bin/gitea-issue
USER root
RUN chmod +x /usr/local/bin/dns-proxy.sh /usr/local/bin/init-firewall.sh /usr/local/bin/log-shipper.sh /usr/local/bin/entrypoint.sh /usr/local/bin/gitea-pr /usr/local/bin/claude && \
RUN chmod +x /usr/local/bin/dns-proxy.sh /usr/local/bin/init-firewall.sh /usr/local/bin/log-shipper.sh /usr/local/bin/entrypoint.sh /usr/local/bin/gitea-pr /usr/local/bin/gitea-issue /usr/local/bin/claude && \
echo 'export PATH="$PATH:/usr/local/share/npm-global/bin"' > /etc/profile.d/npm-global-path.sh
# Entrypoint runs as root (no USER node here) so it can set up the firewall
+52 -9
View File
@@ -1,22 +1,65 @@
// TCP -> unix-socket byte pipe: Claude Code needs an http://127.0.0.1 base
// URL, but the host-side credential proxy listens on a bind-mounted unix
// socket (no host TCP exposure - see bin/dejima-anthropic-proxy).
// Deliberately no HTTP awareness: bytes in, bytes out. Port 18300 is a repo
// constant (the container's network namespace is private, so nothing can
// collide with it); the env override exists only for host-side tests.
// TCP front door for the host-side credential proxy, which listens on a
// bind-mounted unix socket (no host TCP exposure - see
// bin/dejima-anthropic-proxy). Deliberately no HTTP awareness in either
// mode: bytes in, bytes out.
//
// tls (default): terminates TLS for api.anthropic.com on :443, which
// entrypoint.sh points at loopback via /etc/hosts. Claude Code then needs no
// ANTHROPIC_BASE_URL and no placeholder token - it is configured exactly like
// a stock install, which is the point (see docs/security.md). ALPN is pinned
// to http/1.1 because the far end of the pipe is a Python http.server: let a
// client negotiate h2 and it would receive HTTP/2 frames nothing here parses.
//
// loopback: the original plaintext forwarder on :18300, reached via an
// explicit ANTHROPIC_BASE_URL. Kept as the documented fallback for the day
// certificate pinning makes the tls path unusable - the alternative would be
// disabling the credential proxy entirely, which is exactly what must never
// be the answer.
//
// Ports are repo constants (the container's network namespace is private, so
// nothing can collide); the env override exists only for host-side tests.
const net = require("net");
const tls = require("tls");
const fs = require("fs");
const socketPath = process.env.SANDBOX_ANTHROPIC_PROXY_SOCKET;
const port = parseInt(process.env.SANDBOX_ANTHROPIC_PROXY_PORT || "18300", 10);
const transport = process.env.SANDBOX_ANTHROPIC_TRANSPORT || "tls";
const port = parseInt(
process.env.SANDBOX_ANTHROPIC_PROXY_PORT || (transport === "tls" ? "443" : "18300"),
10,
);
if (!socketPath) {
console.error("anthropic-proxy-forwarder: SANDBOX_ANTHROPIC_PROXY_SOCKET not set");
process.exit(1);
}
net.createServer((client) => {
const pipe = (client) => {
const upstream = net.connect(socketPath);
client.pipe(upstream);
upstream.pipe(client);
client.on("error", () => upstream.destroy());
upstream.on("error", () => client.destroy());
}).listen(port, "127.0.0.1");
};
let server;
if (transport === "tls") {
const certPath = process.env.SANDBOX_ANTHROPIC_TLS_CERT;
const keyPath = process.env.SANDBOX_ANTHROPIC_TLS_KEY;
if (!certPath || !keyPath) {
console.error(
"anthropic-proxy-forwarder: SANDBOX_ANTHROPIC_TLS_CERT/SANDBOX_ANTHROPIC_TLS_KEY not set",
);
process.exit(1);
}
server = tls.createServer(
{
cert: fs.readFileSync(certPath),
key: fs.readFileSync(keyPath),
ALPNProtocols: ["http/1.1"],
},
pipe,
);
} else {
server = net.createServer(pipe);
}
server.listen(port, "127.0.0.1");
+11 -1
View File
@@ -3,4 +3,14 @@
# PATH) so every invocation - interactive shell, or claudecode.nvim's
# terminal - runs with --dangerously-skip-permissions, without callers
# needing to know that.
exec /usr/local/share/npm-global/bin/claude --dangerously-skip-permissions "$@"
#
# The sandbox's settings overrides arrive the same way: as an extra
# --settings layer rather than by mounting over ~/.claude/settings.json (see
# docker-compose.yml and #47). That only holds if every entry point passes
# the flag, and shadowing the binary is what makes it true for the
# invocations the entrypoint never sees. The file is absent when a container
# is started without bin/dejima, as test/runtime.bats does.
settings=()
[ -f /etc/dejima-settings.json ] && settings=(--settings /etc/dejima-settings.json)
exec /usr/local/share/npm-global/bin/claude --dangerously-skip-permissions "${settings[@]}" "$@"
+76 -56
View File
@@ -42,6 +42,12 @@ services:
cap_add:
- NET_ADMIN
- NET_RAW
# The credential-proxy TLS terminator binds :443 as the unprivileged
# sandbox user (see entrypoint.sh). Docker already defaults this to 0, but
# pinning it makes the dependency explicit: a future default change then
# fails the launch instead of silently breaking the credential path.
sysctls:
net.ipv4.ip_unprivileged_port_start: 0
environment:
GITEA_SSH_HOST: ${GITEA_SSH_HOST:-}
GITEA_API: ${GITEA_API:-}
@@ -62,7 +68,7 @@ services:
# Opt-in escape hatch from the default hard GitHub-push block - see
# init-firewall.sh and git-push-guard.sh. Off unless explicitly set.
ALLOW_GITHUB_PUSH: ${ALLOW_GITHUB_PUSH:-0}
# Base URL of a Loki push endpoint (e.g. http://10.0.30.11:3100) to
# Base URL of a Loki push endpoint (e.g. http://192.0.2.10:3100) to
# ship DNS-query and firewall allow/reject logs to. Its host is
# automatically firewall-allowlisted (see entrypoint.sh). Optional -
# logging is skipped entirely if unset.
@@ -78,87 +84,101 @@ services:
# "xterm", dropping 256-color and any extended-keys terminfo bits),
# not whatever the host's actual terminal/tmux session is using.
# Forward the real value so nvim negotiates key sequences (e.g. the
# Ctrl-H/Backspace disambiguation config/tmux.conf sets up) against
# Ctrl-H/Backspace disambiguation a tmux config may set up) against
# the terminfo entry the rest of the chain actually expects.
TERM: ${TERM:-xterm-256color}
# Host-side credential proxy (see bin/dejima and README
# "Security notes"): when enabled, Claude Code is pointed at a
# loopback forwarder and given a placeholder token - the real OAuth
# credential stays on the host, injected per-request by the proxy.
# All three are empty (and the ANTHROPIC_* pair unset by
# entrypoint.sh) when the proxy is disabled.
# Host-side credential proxy (see bin/dejima and docs/security.md):
# the real OAuth credential stays on the host, injected per-request by
# the proxy. In the default tls transport Claude Code gets no
# ANTHROPIC_* values at all - api.anthropic.com is pointed at an
# in-container TLS terminator instead, so the session carries no
# environment fingerprint. The pair below is only populated in the
# loopback transport (the documented cert-pinning fallback), and is
# empty - and unset by entrypoint.sh - otherwise.
ANTHROPIC_BASE_URL: ${SANDBOX_ANTHROPIC_BASE_URL:-}
ANTHROPIC_AUTH_TOKEN: ${SANDBOX_ANTHROPIC_AUTH_TOKEN:-}
SANDBOX_ANTHROPIC_PROXY_SOCKET: ${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}
SANDBOX_ANTHROPIC_TRANSPORT: ${SANDBOX_ANTHROPIC_TRANSPORT:-}
volumes:
# Mounted at the same absolute path as on the host (rather than a
# fixed /workspace) so Claude Code's project/session-history keying
# (based on cwd) lines up with the host, letting a session started
# here be resumed on the host with `claude --resume`.
- ${WORKSPACE_DIR:?set WORKSPACE_DIR to the project directory to mount}:${WORKSPACE_DIR}
- ${HOME}/.claude:${HOME}/.claude
# settings.json/CLAUDE.md/plugins are config and hook/plugin *code*,
# not runtime state - hooks run automatically with no approval gate,
# so a compromised session writing to them would plant something that
# then executes unsandboxed on the host next time Claude Code starts
# there. Layer them back read-only on top of the read-write parent
# mount above (Docker applies more specific mounts on top of broader
# ones); everything else under ~/.claude (session/project history,
# caches, etc.) still needs to stay read-write.
# ~/.claude is deliberately not mounted as a whole. It used to be,
# read-write, with the config inside it layered back read-only on top.
# Every one of those overlays was a mount point nested in a
# host-writable mount, and a single-file bind mount is pinned to the
# dentry at that path: a host-side rename of the underlying file - a
# sync daemon, or Claude Code's own atomic writes - unlinks it, the
# mount silently detaches, and the path falls through to the real,
# writable host file (#47, #48). The same thing happened to the mask
# over .credentials.json, handing the sandbox the operator's real
# OAuth token on the first refresh (#50).
#
# settings.json specifically comes from $SANDBOX_SETTINGS_FILE (a
# bin/dejima-generated copy with sandbox.enabled forced to
# false), not the real file directly - Claude Code's own internal
# per-command sandboxing is redundant on top of this container (and
# can't actually function inside it anyway), and mounting it read-only
# means it can't be patched from inside, so the substitution has to
# happen on the host before the container ever starts.
- ${SANDBOX_SETTINGS_FILE:?set by bin/dejima}:${HOME}/.claude/settings.json:ro
- ${HOME}/.claude/CLAUDE.md:${HOME}/.claude/CLAUDE.md:ro
# So the contents are enumerated instead. Anything not listed below is
# container-local: writable during the session, gone with the
# container. That is the safer default - a config file a future Claude
# Code version adds simply doesn't persist, instead of sitting
# unprotected on a writable mount - but it does mean new state that
# *should* persist has to be added here deliberately.
#
# Runtime state that has to survive the session, read-write. All
# directories, which is what makes them safe: a directory bind mount
# can't detach the way a file one does, because a rename replaces an
# entry *within* the mounted directory rather than the mount point.
# projects/ is what host-side `claude --resume` reads (and what
# log-shipper.sh tails); the rest is Claude Code's edit-undo record
# and the task/plan state skills write.
- ${HOME}/.claude/projects:${HOME}/.claude/projects
- ${HOME}/.claude/file-history:${HOME}/.claude/file-history
- ${HOME}/.claude/backups:${HOME}/.claude/backups
- ${HOME}/.claude/tasks:${HOME}/.claude/tasks
- ${HOME}/.claude/plans:${HOME}/.claude/plans
- ${HOME}/.claude/sessions:${HOME}/.claude/sessions
# Hook/plugin/skill *code*, and prompt text that a later host session
# loads. Hooks run automatically with no approval gate, a skill can
# ship an executable script, and a command/agent/output-style is text
# loaded into an unsandboxed host session's context - so a compromised
# session writing to any of these plants something that then runs on
# the host. Read-only, and no longer nested inside a writable parent:
# if one of these ever did detach, the path falls through to an empty
# container-local directory rather than to the host's copy. They may
# not exist on a given host at all - bin/dejima pre-creates them,
# since existence is what lets Docker mount them.
- ${HOME}/.claude/plugins:${HOME}/.claude/plugins:ro
# ~/.claude/hooks holds hook *code* too (install.sh symlinks this
# repo's hooks/ files into it) - same rationale as settings.json/
# CLAUDE.md/plugins above, but for a gap those three don't cover:
# without this, the directory itself stays part of the read-write
# ~/.claude mount, so a compromised session could unlink one of
# install.sh's symlinks and drop a plain file in its place. The
# dynamic per-symlink-target mounts bin/dejima adds at
# launch (see below) only protect targets that resolve *outside*
# ~/.claude - they never cover this directory itself, and a plain
# file dropped here wouldn't be a symlink for that logic to even
# notice. That file would then run unsandboxed on the host as a
# hook the next time Claude Code starts there - the same
# persistence path the other three overlays exist to close.
- ${HOME}/.claude/hooks:${HOME}/.claude/hooks:ro
# skills/commands/agents carry the same execute-or-load-on-host risk as
# hooks/plugins above: a skill can ship an executable script, and a
# command/agent is prompt text loaded into a later, unsandboxed host
# session. Unlike those, they may not exist on a given host at all -
# bin/dejima pre-creates them (empty is fine; existence is what
# lets Docker overlay them) so a compromised session can't create-then-
# populate one under the read-write parent mount.
- ${HOME}/.claude/skills:${HOME}/.claude/skills:ro
- ${HOME}/.claude/commands:${HOME}/.claude/commands:ro
- ${HOME}/.claude/agents:${HOME}/.claude/agents:ro
# output-styles is prompt text loaded into a later host session's
# system prompt, same class as commands/agents above.
- ${HOME}/.claude/output-styles:${HOME}/.claude/output-styles:ro
# Config *files* are staged here for entrypoint.sh to copy into
# ~/.claude, rather than mounted at their real paths. A copy can't
# detach at all, and it closes a gap no mount could: when
# statusline.sh is itself a symlink (the common case, into whatever
# config checkout the host keeps it in), bind-mount destination
# resolution follows it, so a :ro mount landed on the target and left
# the symlink itself replaceable.
# statusline.sh is staged by bin/dejima, since not every host has one.
- ${HOME}/.claude/settings.json:/etc/dejima-claude/settings.json:ro
- ${HOME}/.claude/CLAUDE.md:/etc/dejima-claude/CLAUDE.md:ro
# The sandbox's own settings overrides (sandbox.enabled off, plus the
# remote-access notify hooks when that's on), generated per run by
# bin/dejima and reaching Claude Code as claude-wrapper.sh's
# --settings argument. A container-local path, like the staged config
# above and for the same reason (#47) - and separate from that staging
# directory because this file is generated per run rather than copied
# from the host, and it stays mounted rather than being copied in.
- ${SANDBOX_SETTINGS_FILE:?set by bin/dejima}:/etc/dejima-settings.json:ro
# A throwaway per-session copy, not the real file - see
# bin/dejima and README "Throwaway ~/.claude.json". A plain
# read-only mount hangs Claude Code on startup (it writes to this
# file before login completes), so this can't just be layered
# read-only the way settings.json/CLAUDE.md/plugins/hooks are above;
# read-only the way plugins/hooks/skills are above;
# a generated, discarded copy is what actually stops a compromised
# session's changes (e.g. a malicious mcpServers entry) from ever
# reaching the real file.
- ${SANDBOX_CLAUDE_JSON_FILE:?set by bin/dejima}:${HOME}/.claude.json
# ~/.claude/CLAUDE.md (mounted above) tells agents to write specs to
# ~/specs/ rather than a project-local path - that instruction is
# already in effect inside the container (CLAUDE.md carries over), so
# the directory it points at needs to actually exist and be writable
# here too, or an agent following it just writes into an ephemeral
# path inside the container that vanishes with the session.
- ${HOME}/specs:${HOME}/specs
# Global git identity (user.name/user.email) - a throwaway copy of it,
# in its own directory rather than a single file bind-mounted at
# ~/.gitconfig: entrypoint.sh's `gh auth setup-git` writes a
+127 -5
View File
@@ -99,11 +99,119 @@ if [ -s "$SANDBOX_SSH_KEY_SRC" ]; then
export SSH_AUTH_SOCK
fi
# Loopback bridge for the host-side credential proxy (see bin/dejima
# and bin/dejima-anthropic-proxy): Claude Code talks HTTP to
# 127.0.0.1:18300, the forwarder pipes bytes into the bind-mounted unix
# socket. Only started when this session actually has a proxy socket.
# ~/.claude config files arrive staged read-only under /etc/dejima-claude
# (see the mount inventory in docker-compose.yml) and get copied into place
# here rather than bind-mounted at their real paths. A copy can't be
# detached by a host-side rename the way a single-file mount can (#47, #48),
# and it closes the one case no mount could: a statusline.sh that is a
# symlink into a dotfiles checkout. Docker resolves the symlink when it sets
# up the staging mount, so what lands here is already the real content.
#
# Root-owned and read-only, so the `#` memory shortcut and /memory keep
# failing visibly against ~/.claude/CLAUDE.md instead of appearing to
# succeed. A compromised session can still unlink and replace them - they
# sit in a directory it owns - but that change is now container-local and
# can't reach the host, which is the whole reason these were read-only.
if [ -d /etc/dejima-claude ]; then
for staged in settings.json CLAUDE.md; do
if [ -f "/etc/dejima-claude/$staged" ]; then
install -o root -g root -m 444 "/etc/dejima-claude/$staged" "$SANDBOX_HOME/.claude/$staged"
fi
done
if [ -f /etc/dejima-claude/statusline.sh ]; then
install -o root -g root -m 555 /etc/dejima-claude/statusline.sh "$SANDBOX_HOME/.claude/statusline.sh"
fi
fi
# The real OAuth credential no longer enters the container at all - ~/.claude
# isn't mounted wholesale any more, so there is nothing to mask (#50). What
# lands at this path instead depends on the transport: the tls one needs a
# well-formed fake credential, because Claude Code authenticates from this
# file rather than from an ANTHROPIC_AUTH_TOKEN, and bin/dejima mounts one.
# Nothing is mounted for the loopback transport, so give Claude Code the
# empty file the old mask used to provide rather than betting that an absent
# credentials file behaves the same as an empty one. Either way this is
# skipped when something is already there - which also covers
# SANDBOX_CREDENTIAL_PROXY=0 mounting the real credential in deliberately.
if [ ! -e "$SANDBOX_HOME/.claude/.credentials.json" ]; then
install -o "$SANDBOX_USER" -g "$SANDBOX_USER" -m 600 /dev/null "$SANDBOX_HOME/.claude/.credentials.json"
fi
# TCP front door for the host-side credential proxy (see bin/dejima and
# bin/dejima-anthropic-proxy): the proxy listens on a bind-mounted unix
# socket, and anthropic-proxy-forwarder.js pipes bytes into it. Only started
# when this session actually has a proxy socket.
#
# In the default tls transport, api.anthropic.com is pointed at loopback here
# and the forwarder terminates TLS for it, so Claude Code runs with no
# ANTHROPIC_BASE_URL and no placeholder token - it is indistinguishable from a
# stock install, which is the whole point (see docs/security.md). The hosts
# entry deliberately lives inside this guard rather than in compose's
# extra_hosts: under SANDBOX_CREDENTIAL_PROXY=0 nothing listens on :443, and
# an unconditional entry would black-hole the real API on exactly the path
# that has to keep working.
SANDBOX_ANTHROPIC_TRANSPORT="${SANDBOX_ANTHROPIC_TRANSPORT:-tls}"
export SANDBOX_ANTHROPIC_TRANSPORT
# Checked here rather than in bin/dejima because every launch reaches this
# script, including a bare `docker run` (see test/runtime.bats). The two halves
# branch on opposite polarity - bin/dejima treats anything but `loopback` as
# tls, this script and the forwarder treat anything but `tls` as loopback - so
# a typo would configure them for different transports and fail every API call
# with nothing pointing back at the cause.
case "$SANDBOX_ANTHROPIC_TRANSPORT" in
tls|loopback) ;;
*)
echo "FATAL: SANDBOX_ANTHROPIC_TRANSPORT is set to '$SANDBOX_ANTHROPIC_TRANSPORT'." >&2
echo "The only valid values are 'tls' (the default) and 'loopback'; they are" >&2
echo "case-sensitive. Not starting the session - an unrecognised value would" >&2
echo "otherwise be read as one transport here and the other by bin/dejima." >&2
exit 1
;;
esac
if [ -n "${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}" ]; then
forwarder_port=18300
if [ "$SANDBOX_ANTHROPIC_TRANSPORT" = "tls" ]; then
forwarder_port=443
# Generated here, in the container, rather than on the host: the key
# never touches host disk and there is no host state to clean up. It
# is readable by the sandbox user because the forwarder stays
# unprivileged - and worthless to anyone else, since the only thing
# that trusts this anchor is this container, which is already reading
# that traffic in the clear.
tls_dir=/etc/dejima-anthropic-tls
mkdir -p "$tls_dir"
# set -e aborts the script on openssl's nonzero exit either way, so a
# failure here never proceeds to chown/export a missing cert - but
# discarding its stderr would make that abort silent-but-loud (dies,
# says nothing why). Captured instead of routed straight to /dev/null
# so the one line that explains the failure survives to the FATAL
# block below; openssl's success-path chatter is just progress dots.
if ! openssl_err=$(openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
-keyout "$tls_dir/key.pem" -out "$tls_dir/cert.pem" \
-subj "/CN=api.anthropic.com" \
-addext "subjectAltName=DNS:api.anthropic.com" 2>&1); then
echo "FATAL: openssl failed to generate the TLS terminator cert for api.anthropic.com." >&2
echo "$openssl_err" >&2
echo "Not starting the session - API traffic would have no valid cert to trust." >&2
echo "Re-run with SANDBOX_ANTHROPIC_TRANSPORT=loopback, or with" >&2
echo "SANDBOX_CREDENTIAL_PROXY=0 to launch without the proxy (the real OAuth" >&2
echo "credential is then mounted into the container)." >&2
exit 1
fi
chown "$SANDBOX_USER" "$tls_dir/key.pem" "$tls_dir/cert.pem"
chmod 600 "$tls_dir/key.pem"
chmod 644 "$tls_dir/cert.pem"
export SANDBOX_ANTHROPIC_TLS_CERT="$tls_dir/cert.pem"
export SANDBOX_ANTHROPIC_TLS_KEY="$tls_dir/key.pem"
# /etc/hosts wins over DNS in getaddrinfo, so this bypasses the DNS
# proxy entirely - which is why fixed-domains.sh leaves the name off
# the allowlist whenever there is a proxy socket.
echo "127.0.0.1 api.anthropic.com" >> /etc/hosts
# The trust anchor for the cert above. NODE_EXTRA_CA_CERTS rather than
# the system store: it needs no update-ca-certificates run, and it is
# the single most ordinary variable a corporate environment sets.
export NODE_EXTRA_CA_CERTS="$tls_dir/cert.pem"
fi
gosu "$SANDBOX_USER" node /usr/local/bin/anthropic-proxy-forwarder.js &
# Node's own startup (V8 init, module load, then finally binding the
# listen socket) isn't instant - without this wait, a request that
@@ -111,10 +219,24 @@ if [ -n "${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}" ]; then
# immediate check; in principle also a very fast-starting real client)
# can race ahead of the bind and see a connection refused. Bash's own
# /dev/tcp is enough to probe this without a curl/nc dependency.
forwarder_bound=0
for _ in $(seq 1 50); do
(exec 3<>/dev/tcp/127.0.0.1/18300) 2>/dev/null && exec 3>&- && break
(exec 3<>/dev/tcp/127.0.0.1/$forwarder_port) 2>/dev/null && exec 3>&- && forwarder_bound=1 && break
sleep 0.1
done
# Loud, not silent. bin/dejima already refuses to launch when the
# host-side proxy fails to start, for the same reason: every quiet
# fallback from here either exposes the credential or, in tls mode, sends
# API traffic to a loopback port nothing is listening on and produces a
# connection error nobody can trace back to this.
if [ "$forwarder_bound" != "1" ]; then
echo "FATAL: the credential-proxy forwarder never bound 127.0.0.1:$forwarder_port." >&2
echo "Not starting the session - API traffic would have no working path to the" >&2
echo "host-side proxy. Re-run with SANDBOX_ANTHROPIC_TRANSPORT=loopback, or with" >&2
echo "SANDBOX_CREDENTIAL_PROXY=0 to launch without the proxy (the real OAuth" >&2
echo "credential is then mounted into the container)." >&2
exit 1
fi
fi
# Compose can't conditionally omit an env entry, so these arrive as empty
+14 -1
View File
@@ -3,7 +3,6 @@
FIXED_DOMAINS=(
"registry.npmjs.org"
"platform.claude.com"
"api.anthropic.com"
"gitlab.com"
"pypi.org"
"files.pythonhosted.org"
@@ -12,6 +11,20 @@ FIXED_DOMAINS=(
"sum.golang.org"
)
# api.anthropic.com is allowlisted only when this session has no credential
# proxy (kill switch or API-key auth), where DNS is the only path to the API.
# With the proxy on, the session reaches the API through the forwarder rather
# than by resolving this name (under the tls transport, via entrypoint.sh's
# /etc/hosts pin), so leaving it unresolvable defeats a bypass that looks the
# name up by DNS. It is not a route block: the API's address stays reachable as an IP
# literal for as long as platform.claude.com above resolves to the same one.
# Appended here rather than to EXTRA_ALLOWED_DOMAINS because it is a public
# SaaS name: dns-proxy.sh exempts the extra list from stop-dns-rebind, and
# init-firewall.sh downgrades a failed lookup there to a warning.
if [ -z "${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}" ]; then
FIXED_DOMAINS+=("api.anthropic.com")
fi
# GitHub itself is allowlisted by IP range (see github-domains ipset,
# populated from api.github.com/meta), never by resolving these names to
# specific IPs - but the container's own tools (git, gh, curl) still need to
+154
View File
@@ -0,0 +1,154 @@
# Example nginx vhost for the dejima web front door. EVERY address, hostname
# and path below is a placeholder - substitute your own. As written this file
# will not load: that is deliberate, so it can never become a live config by
# being copied into sites-enabled and forgotten.
#
# ---------------------------------------------------------------------------
# Read this part before the config part.
#
# Whoever reaches this vhost gets a HOST SHELL. ttyd runs bin/dejima-attach,
# which attaches a tmux client on the operator's own tmux server - and a tmux
# client is a shell outside the container, one prefix key away from a new
# window that has nothing to do with any sandbox.
#
# So two things carry this feature's entire security, and both live here:
#
# 1. The listen address. docker/init-firewall.sh:187 accepts container
# OUTPUT to the default-route address - the Docker bridge gateway, which
# is the host. A vhost bound to every interface, or to that gateway
# address, is reachable FROM a sandbox, which would let a compromised
# session walk out through its own escape hatch. Bind one specific LAN
# address, never the wildcard form (a bare `listen 443 ssl;`), and never
# the bridge address. `ip -br addr` lists the candidates; `ss -ltnp`
# afterwards shows what you actually bound.
#
# 2. Authentication. ttyd is started with no credential of its own (see
# bin/dejima-ttyd), because it is bound to 127.0.0.1 where only this
# host can reach it - this proxy is the only gate in front of it. A
# vhost without auth_basic (or something stronger in its place) is an
# unauthenticated host shell on your LAN. Put every listen behind it:
# a second server block without auth is the same open door.
#
# Authentication alone does not stop a page in your browser from
# opening a WebSocket to this vhost and having the browser attach your
# credential to it, which is why bin/dejima-ttyd passes --check-origin.
# That check compares the Origin against the Host header, so the
# `Host $http_host` line below is load-bearing, not tidiness - and it
# has to be $http_host, which keeps the port, on any listen other
# than 443. See the comment on that line.
#
# 3. The catch-all below. nginx chooses a server block by SNI/Host, and
# when nothing matches it serves the DEFAULT server for that listen
# address - which, if the vhost below is the only block on it, is the
# vhost below. `server_name` therefore isolates nothing by itself: with
# one block, this front door answers to EVERY name that resolves to
# this address, the box's own hostname included. What is behind it is a
# host shell, so the reject block comes first and takes that role.
#
# TLS is not optional either: HTTP Basic sends the password on every request,
# and the terminal itself carries whatever the session is working on.
# ---------------------------------------------------------------------------
# Refuses every name that is not the vhost below. Without it, `server_name`
# is decorative and any hostname pointed at this address reaches the shell.
#
# If you ALREADY have a default_server on this address:port, don't add a
# second - nginx refuses to start ("duplicate default server") - just make
# sure the existing one doesn't fall through to the block below.
server {
listen <LAN-IP>:443 ssl default_server;
# Refuses at the TLS layer, so it needs no certificate of its own
# (nginx 1.19.4+). On older nginx, give this block the same certificate
# as the vhost below and `return 444;` instead.
ssl_reject_handshake on;
}
server {
# One specific address. Not the wildcard form, not the Docker bridge.
listen <LAN-IP>:443 ssl;
server_name <hostname>;
ssl_certificate /path/to/fullchain.pem;
ssl_certificate_key /path/to/privkey.pem;
# The only authentication there is. See point 2 above.
# htpasswd -c /path/to/dejima.htpasswd <username>
auth_basic "dejima";
auth_basic_user_file /path/to/dejima.htpasswd;
location / {
# Loopback: ttyd binds 127.0.0.1 only, so nginx has to be on the same
# host. The port must match TTYD_PORT in ~/.config/dejima/remote.conf
# (7681 when it is not set).
proxy_pass http://127.0.0.1:7681;
# ttyd serves its page over HTTP and then upgrades to a WebSocket;
# both need HTTP/1.1 and the upgrade headers passed through.
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# ttyd's --check-origin compares the client's Origin against Host.
# Drop this line and it compares against 127.0.0.1:<port> instead.
#
# $http_host, not $host: $host has the port stripped, and ttyd keeps
# the port in what it compares unless it is 80 or 443. On a vhost
# listening anywhere else - 8443, say - $host would make ttyd compare
# "name:8443" against "name" and refuse every WebSocket: the page
# loads, the terminal never connects, and the only sign is
# "refuse to serve WS client from different origin" in ttyd's log.
proxy_set_header Host $http_host;
# An attached terminal can sit idle for hours; nginx's default read
# timeout is 60 seconds. ttyd's own WebSocket ping (every 5s by
# default) normally keeps the connection alive on its own, but that
# is ttyd's default to change, not something to depend on here.
proxy_read_timeout 1d;
# ---- Phone rendering. Drop this if you only ever connect from a
# desktop; it is inert there.
#
# ttyd's page ships no viewport meta - not in 1.7.7, not on main - so
# a mobile browser assumes a desktop-width layout (~980 CSS px) and
# scales the whole thing down to fit. Text lands at a size no font
# setting can rescue, because the font is scaled down with everything
# else, and pinch-zoom does not reflow a terminal: you get a giant
# canvas to pan around. Injecting the tag makes the browser lay out at
# the device's real width, which lets xterm.js fit an honest column
# count and lets rotating the phone actually reflow.
#
# Needs nginx built with the sub module. Check before relying on it,
# and validate with `nginx -t` rather than trusting the reload: a
# config that does not parse leaves the OLD one running while
# `systemctl reload nginx` still reports success, so an unknown
# directive here looks just like one that ran and did nothing.
# Spelled out because Debian/Ubuntu install nginx to /usr/sbin, which
# is on root's PATH and not yours: a bare `nginx -V` says "command
# not found" on a box that is running nginx perfectly well.
# /usr/sbin/nginx -V 2>&1 | tr ' ' '\n' | grep http_sub_module
# sudo /usr/sbin/nginx -t
#
# Desktop browsers ignore width=device-width, so this costs them
# nothing.
sub_filter '<head>' '<head><meta name="viewport" content="width=device-width, initial-scale=1">';
sub_filter_once on;
# sub_filter cannot rewrite a compressed body, and silently passes it
# through unchanged - which looks exactly like the tag not working.
proxy_set_header Accept-Encoding "";
# ttyd sends this page with no Cache-Control, no ETag and no
# Last-Modified - src/http.c sets status, content-type,
# content-encoding and content-length, and nothing else - and a
# response carrying neither freshness information nor a validator is
# cacheable at the browser's discretion, which on a phone means
# indefinitely. That is harmless while the page is served verbatim and
# a trap once it is REWRITTEN here: a phone that cached the
# un-injected page goes on rendering it, so the tag reads as broken
# long after it works, and no future change to the injection reaches
# a browser that has already been here. Nothing is lost by refusing
# the cache - with no validator upstream, a revalidation could never
# come back 304 anyway - and the page is still gzipped to the client,
# since clearing Accept-Encoding above only applies to the request
# made to ttyd.
add_header Cache-Control "no-store" always;
}
}
+675
View File
@@ -0,0 +1,675 @@
# Remote access
[← dejima README](../README.md)
**Opt-in, and entirely off until you create one file.** Nothing here runs and
no front door exists unless `~/.config/dejima/remote.conf` is present. With
it absent, `dejima` builds exactly the `docker compose` invocation it
built before this feature existed, no session state is written, no
notification hook is registered, and `dejima-attach`, `dejima-ttyd` and
`dejima-notify-watch` each refuse to run at all. `install.sh` symlinks those
three onto `PATH` and stops there - it runs no `systemctl`, copies no unit,
and starts nothing.
What this is: a way to get back to a session that is *already running* on
your machine, from a phone on the same network, when it stops to ask you
something. What it is not: a way to start a session, a hosted service, or
anything that should face the internet.
**Both front doors hand out a host shell.** `dejima-attach` attaches a tmux
client on your own tmux server, and a tmux client is one prefix key away
from a new window that has nothing to do with any sandbox. Everything below
follows from that single fact - it is why the listener is on loopback, why
the proxy needs authentication, and why the bind address is the one step to
get right.
## `remote.conf`
Flat `KEY=value` lines, `#` comments and blank lines ignored, at
`~/.config/dejima/remote.conf` (`$DEJIMA_CONFIG_DIR` relocates the whole
config tree, same as it does for approvals):
| Key | Default | Purpose |
| --- | --- | --- |
| `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 |
`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 file 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 without an `=` 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 files away.
Those are the two scripts that read a *value* out of the file.
`dejima-attach` and `dejima` do not - to them the file is a switch, and
all they ask is whether it exists - so neither one parses it and neither
refuses on a bad line. That is deliberate rather than an oversight: a
malformed line cannot change what they do, and making the front door refuse
to open over a typo would take away the terminal you would fix it from.
```
# ~/.config/dejima/remote.conf - substitute your own topic and hostname
NTFY_URL=https://ntfy.example.invalid/your-topic-here
BASE_URL=https://dejima.example.invalid
TTYD_PORT=7681
# TTYD_FONT_SIZE=20 # optional, and applies to the desktop too
```
**Editing this file changes nothing until the services restart.** Every value
is read once, at process start - `dejima-ttyd` reads `TTYD_PORT` and
`TTYD_FONT_SIZE` and then `exec`s `ttyd` with them baked into its argv. So:
```sh
systemctl --user restart dejima-ttyd.service
systemctl --user restart dejima-notify-watch.service # if NTFY_URL changed
pgrep -a ttyd # confirm the argv is what you just configured
```
Skipping this is quiet rather than loud: nothing warns, and the front door
keeps serving with the old settings, which reads as "the setting had no
effect" rather than as "the process is older than the file".
## The bind address is the one thing to get right
This is the step that carries the whole feature's security, and the only one
where a plausible-looking choice is a full sandbox escape.
`docker/init-firewall.sh` deliberately ACCEPTs container OUTPUT to the
container's default-route address - the Docker bridge gateway, which is your
host. That is the one host address a sandbox can reach at all. Combine it
with the fact above (a tmux client is a host shell) and the consequence is
direct: **a front door the sandbox can reach is a compromised session's own
escape hatch.**
So:
- `ttyd` is bound to `127.0.0.1` by `dejima-ttyd`, hardcoded, not
configurable. The container has no route to your host's loopback, so
nothing but a process on this host can open that socket.
- **The reverse proxy has to run on that same host** - the dejima box, the
one your sandboxes start on. That is not a preference, it is what the
loopback bind implies: `proxy_pass http://127.0.0.1:<TTYD_PORT>` only
resolves to `ttyd` from the same machine. If you already run a proxy on a
*different* box, do not point it here - reaching `ttyd` from there would
mean binding `ttyd` to a routable address, which is precisely the escape
this whole section exists to prevent. Put a second, small proxy on the
dejima box instead.
- That proxy must bind **one specific address, on the dejima box's LAN
interface**. Never the wildcard - a bare `listen 443 ssl;` is every
interface *including* the Docker bridges - never `0.0.0.0`, and never a
bridge address.
## Picking the address
`ip -br addr` on the dejima box lists more candidates than you want, because
Docker adds its own. It looks something like this:
```
lo UNKNOWN 127.0.0.1/8 ::1/128
enp3s0 UP 192.0.2.24/24 <a link-local v6 address>
docker0 UP <docker's own subnet>
br-9f3c1a2b4d5e UP <docker's own subnet>
```
Only the physical LAN interface qualifies - `enp3s0` here; yours may be
`eth0`, `wlan0`, `enp0s31f6`, or a bond. So `listen 192.0.2.24:443 ssl;`,
using your own address in place of that one.
Everything else on that list is disqualified, and two of them dangerously
so. `lo` is where `ttyd` already is. `docker0` and every `br-<hash>` are
Docker bridges - **these are the addresses a sandbox can reach**, so a proxy
bound to one is a front door opening inward. If you are unsure which lines
those are, ask for exactly them:
```sh
ip -br addr show type bridge # every address here is disqualified
```
Cross-check from the other side, too: the address a running sandbox has a
route to is the one to avoid, and the container will tell you which it is -
`docker exec <container> ip route | grep default` names it.
Afterwards, `ss -ltnp | grep <TTYD_PORT>` should show `ttyd` on `127.0.0.1`
only, and `ss -ltnp` on your proxy's port should show the single LAN address
you chose - not `*` and not `0.0.0.0`.
## Verifying it
Verify rather than assume. With a sandbox running, from inside it (adjust
`7681` if you changed `TTYD_PORT`, and `443` to whatever port your proxy
listens on):
```sh
# the bridge gateway - the only host address this container can route to
gw=$(ip route | grep default | cut -d' ' -f3)
# both should fail: the loopback ttyd, and the front door's own port
timeout 5 bash -c "cat < /dev/null > /dev/tcp/$gw/7681" && echo BAD || echo ok
timeout 5 bash -c "cat < /dev/null > /dev/tcp/$gw/443" && echo BAD || echo ok
```
`BAD` on either line means stop and fix the bind before going further -
whatever answered is reachable from inside the sandbox. The first line is
also what catches a distro `ttyd.service` still running on that port, which
is why this is worth doing even if you are confident about the proxy. `test/runtime.bats`
asserts this same containment property against a live container (with a
positive control on the same path, so a passing result can't come from the
container simply reaching nothing), and it passes on a real Docker host.
## SSH front door
Generate a key that exists only for this, and give it a forced command:
```sh
ssh-keygen -t ed25519 -f ~/.ssh/dejima-phone -C phone
```
Then one line in `~/.ssh/authorized_keys`, with the full path to the symlink
`install.sh` made:
```
restrict,pty,command="/home/<you>/.local/bin/dejima-attach" ssh-ed25519 AAAA... phone
```
`restrict` turns off port forwarding, agent forwarding, X11 and everything
else; `pty` adds back the one thing a terminal needs; `command=` runs
`dejima-attach` no matter what the client asks to run. Nothing the phone
sends is interpreted as a command, so this needs no client-side
"run this on connect" support - any plain SSH app lands on the picker.
It has to be the **same Unix user** that runs `dejima`: tmux sockets are
per-user, and `dejima-attach` talks to the tmux server of whoever runs it.
Options are per-key, so your normal key still gets a normal shell - worth
confirming both after you add the line.
## Web front door
Needs `ttyd` **1.7.4 or newer**. `-W`/`--writable` arrived in 1.7.4, which
inverted the old default (before it, the terminal was writable unless you
passed `--readonly`), so an older build rejects the flag and
`dejima-ttyd` fails at every start. Your distro's package may well be
too old: Debian bookworm's is 1.7.3 and Ubuntu 22.04's is 1.6.3, neither of
which has the flag - Ubuntu 24.04's 1.7.4 does. Check with `ttyd --version`,
and take an upstream release binary if the packaged one is behind.
On Debian there is a middle option: sid packages **1.7.7**, and you can take
that one package without moving the rest of the system to unstable. sid is
not a configured source on a stable install, so add it - and pin it *below*
your normal release, or the next `apt upgrade` follows it:
```sh
echo 'deb http://deb.debian.org/debian unstable main' \
| sudo tee /etc/apt/sources.list.d/sid.list
# without this pin the whole system drifts to unstable
printf 'Package: *\nPin: release a=unstable\nPin-Priority: 10\n' \
| sudo tee /etc/apt/preferences.d/99-sid
sudo apt update
sudo apt install -t unstable ttyd # -t overrides the pin, for this package
```
Read what `apt` proposes before agreeing to it. One package from sid drags
its libraries along, and if the plan includes `libc6` you are no longer
taking one package - back out and use an upstream release binary instead.
`apt_preferences(5)` has the details.
**Installing the package opens a door of its own. Close it.** However you get
`ttyd` from a distro repository - stable, sid, Ubuntu - the package ships a
*system* service that systemd enables and starts on install. It serves a
login prompt, and because it passes no `--interface` of its own it binds
**every** interface, the bridge address a sandbox routes to included. It
listens on `ttyd`'s default port, which is the port this feature defaults to
as well.
Two consequences, and the second is the one that hides:
- A host login prompt is now reachable *from inside every container you run*,
put there by the act of installing the package.
- It holds the port, so `dejima-ttyd` fails to bind at every start while your
reverse proxy proxies to the distro's `ttyd` instead. You get your own
password prompt and then a host login shell, on the hostname you set up for
dejima, with nothing along the way appearing broken.
```sh
systemctl status ttyd.service # almost certainly active
sudo systemctl disable --now ttyd.service
sudo systemctl mask ttyd.service # so reinstalling cannot revive it
```
`dejima-ttyd` refuses to start when anything already holds its port and
names this as the likely cause, rather than leaving you with `ttyd`'s
one-line bind error in a unit nobody is watching. That is a diagnostic, not a
fix: the listener is there from the moment the package lands, whether or not
you ever run `dejima-ttyd`.
`dejima-ttyd` reads `TTYD_PORT` and launches
`ttyd --interface 127.0.0.1 --port <port> --check-origin --url-arg --writable dejima-attach`.
Two of those are load-bearing:
- **`--check-origin`** is not tidiness. Without it `ttyd` does no origin
validation at all, and browsers do not apply the same-origin policy to
WebSockets - they do attach the cached credential for the target origin.
Any page loaded in an authenticated operator's browser could open
`wss://<front-door>/ws` and be handed this host shell, with no interaction
beyond visiting the page. Proxy authentication cannot stop that; it is
satisfied by the victim's own browser.
- **`--url-arg`** is what lets the notification's deep link name a session
(`?arg=<run-id>`). It reaches `dejima-attach` as argv, never as a shell
word, and has to match the run-id shape before it is used for anything.
Install the unit by hand - it ships disabled, and `install.sh` will not do
this for you:
```sh
mkdir -p ~/.config/systemd/user
cp systemd/dejima-ttyd.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now dejima-ttyd.service
```
It is a **user** unit, not a system one, and it repeats no configuration:
`ExecStart` is `%h/.local/bin/dejima-ttyd`, and the bind address and port
live in the script and `remote.conf` so the two cannot drift.
Then put a reverse proxy in front of it. Adapt
`docs/nginx-dejima.conf.example` - it will not load as written, deliberately,
so it can never become a live config by being dropped into `sites-enabled`
and forgotten. What matters in it:
- **Authentication on every server block.** `ttyd` is started with no
credential of its own, because the loopback bind means only this host can
reach it - the proxy is the entire gate. A block without `auth_basic` is
an unauthenticated host shell on your network.
- **A `default_server` that refuses.** nginx picks a server block by
SNI/`Host`, and when nothing matches it serves the **default** for that
listen address - which, if the front door is the only block on it, is the
front door. `server_name` on its own therefore isolates nothing: the vhost
answers to *every* name that resolves to that address, including the box's
own hostname. The example ships a first block that is `default_server` and
does nothing but `ssl_reject_handshake on;`, so only the name you chose
gets through. If you already have a default server on that address, keep
the one you have - just make sure it doesn't fall through to this vhost.
- **TLS.** HTTP Basic sends the password on every request, and the terminal
itself carries whatever the session is working on.
- **`proxy_set_header Host $http_host;`** — `$http_host`, not `$host`.
`--check-origin` compares the client's `Origin` against the `Host` header,
and nginx's `$host` has the port stripped while `ttyd` keeps the port in
what it compares unless it is 80 or 443. On a vhost listening anywhere
else - 8443, say - `$host` makes `ttyd` compare `name:8443` against `name`
and refuse every WebSocket: the page loads, the terminal never connects,
and the only sign is a "refuse to serve WS client from different origin"
line in `ttyd`'s log.
- **A long `proxy_read_timeout`.** An attached terminal sits idle for hours;
nginx's default is 60 seconds.
## Reading it on a phone
Out of the box the phone view is close to unusable: text renders far too
small to read, and pinch-zooming leaves you panning around a giant canvas
instead of reflowing anything. That is not a font setting you missed.
**`ttyd` serves no viewport meta tag** - not in 1.7.7, not on `main` at the
time of writing. A mobile browser that finds no `<meta name="viewport">`
assumes the page was written for a desktop, lays it out at a virtual width
of around 980 CSS pixels, and scales the result down to fit the screen.
Everything shrinks together, so raising the font size just gives you a
larger font scaled down by the same factor. And a terminal is a fixed
character grid on a canvas: zooming magnifies pixels, it does not re-wrap
lines the way zooming a text page does.
The fix is to supply the tag the page is missing, which the proxy can do on
the way past. `docs/nginx-dejima.conf.example` ships it:
```nginx
sub_filter '<head>' '<head><meta name="viewport" content="width=device-width, initial-scale=1">';
sub_filter_once on;
proxy_set_header Accept-Encoding "";
add_header Cache-Control "no-store" always;
```
Now the browser lays out at the device's real width, xterm.js fits an honest
column count to it, and rotating the phone reflows properly. Desktop
browsers ignore `width=device-width`, so nothing changes there.
Three things that will waste your time if you skip them:
- Your nginx needs the sub module, and a missing one fails *quietly*. Check
for it up front, and validate with **`nginx -t`** rather than trusting the
reload - a reload whose config does not parse leaves the *old* config
running while `systemctl reload nginx` still reports success, so an unknown
`sub_filter` directive looks from the outside exactly like a directive that
ran and did nothing.
```sh
/usr/sbin/nginx -V 2>&1 | tr ' ' '\n' | grep http_sub_module
sudo /usr/sbin/nginx -t
```
The path is spelled out because Debian and Ubuntu install nginx to
`/usr/sbin`, which is on root's `PATH` and not on yours - a bare `nginx -V`
answers `command not found` on a box that is running nginx perfectly well.
Debian's `nginx-light` is built without the module; `nginx-core`,
`nginx-full` and `nginx-extras` have it.
- `sub_filter` cannot rewrite a compressed response body and passes it
through untouched, which looks exactly like the tag not working. Clearing
`Accept-Encoding` toward the upstream is what stops that.
- **Your phone may already be holding the un-injected page.** `ttyd` sends it
with no `Cache-Control`, no `ETag` and no `Last-Modified`, and a response
with neither freshness information nor a validator is cacheable for as long
as the browser feels like it. Fine while the body is served verbatim; a trap
once the proxy rewrites it, because the stale copy renders forever and the
injection reads as broken long after it works. `add_header Cache-Control
"no-store" always;` closes it going forward - to check *right now*, load the
front door in a private tab, which bypasses the cache without clearing
anything else.
Confirm it server-side before blaming the browser, or the other way round -
the first 220 bytes of the response settle it, and the tag should sit ahead
of `<meta charset>`:
```sh
curl -sk -u USER:PASS https://<front-door>/ | head -c 220; echo
```
If the text is still smaller than you want once the layout is honest, set
`TTYD_FONT_SIZE` in `remote.conf` (8-72). Be aware of what it costs:
**it applies to every client.** `ttyd` 1.7.7 accepts client options only at
launch - the URL-query override that would let a deep link carry its own
size landed upstream after 1.7.7 was cut - so there is no way from here to
give a phone one size and the desktop another. A larger size also means
fewer columns, and because attaching shares the window with your desktop
session, tmux resizes that window to whichever client used it last. Start by
fixing the viewport and only reach for this if you still need it.
## Typing on a phone
Text you already typed reappears a batch at a time, sometimes cascading into
another batch. Backspacing clears it; a few characters later it happens
again.
**It is the keyboard's autocorrect**, and two things locate it there. The
duplicated characters are real input you can backspace over rather than a
redraw artifact, and the WebSocket runs over TCP, which cannot deliver the
same bytes twice - so the browser is emitting them. And it reproduces on
demand: a long run of digits never duplicates, while ordinary words duplicate
on every correction the keyboard makes - `fix` to `fox`, `lets` to `let's`.
When a soft keyboard *replaces* a span it has already committed, xterm.js
re-sends the whole contents of its hidden input element instead of the
difference, so the entire line replays.
xterm.js already asks the keyboard not to do this - it sets `autocorrect`,
`autocapitalize` and `spellcheck` to off on that element as it creates it -
and Android ignores all three. This is a general `ttyd`/xterm.js interaction
with predictive keyboards, and there is no lever here, in the proxy or in
`dejima-ttyd`, that reaches it.
So use a keyboard with nothing to correct against. **Unexpected Keyboard**
(F-Droid or Play Store) was written for Termux users and fits well: its word
suggestions need a dictionary you download yourself, so a fresh install has
nothing to autocorrect with, and Ctrl, Alt, Esc, Tab, the arrows and the
function keys are all real keys, reached by swiping a key toward the corner
the symbol is printed in. That second half matters more than the bug does -
without Ctrl there is no tmux prefix, and Esc and the arrows are hard to do
without in anything interactive. Android's keyboard switcher makes it a
per-session choice rather than a replacement for the keyboard you use for
everything else.
Turning autocorrect off in the keyboard you already have fixes it too, at the
cost of losing the correction everywhere else.
## Behind another reverse proxy
Terminating TLS somewhere else and forwarding here is a supported shape, but
read this first: it moves the front door out of the LAN threat model
everything above is written against. If the outer proxy is internet-facing,
`auth_basic` over TLS is now the only thing between the public internet and
a host shell. Put something stronger in front of it, and take the
[security notes](security.md) as the floor rather than the ceiling.
The mechanical part has four requirements at the outer hop, and each one
fails differently:
```nginx
proxy_ssl_server_name on; # else: TLS handshake refused
proxy_ssl_name dejima.example.invalid;
proxy_set_header Host dejima.example.invalid; # exactly once - see below
proxy_http_version 1.1; # else: page loads, terminal never connects
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 1d; # else: dropped after 60s idle
```
- **`proxy_ssl_server_name` is off by default**, so nginx proxying to an
HTTPS upstream sends no SNI at all. The inner nginx sees a handshake with
no name, hands it to the `default_server` - which exists precisely to
refuse unrecognised names - and you get a 502 at the outer hop.
- **Set `Host` exactly once.** `proxy_set_header` is additive within a
configuration level rather than overriding, so declaring `Host` twice
sends *two* `Host` headers and the inner nginx answers 400 to all of it.
The easy way to do this by accident is `include proxy_params;` (Debian
ships one that already sets `Host $http_host`) plus your own `Host` line
in the same block.
- **A literal, not `$host` or `$http_host`.** The value has to be the
hostname the *browser* used, because that is what `ttyd`'s
`--check-origin` compares the `Origin` against at the far end. A literal
also guarantees it agrees with `proxy_ssl_name`.
- **`Upgrade`/`Connection` and the timeout are needed at every hop**, not
just the inner one. Miss them outside and the page loads while the
terminal never connects - the same symptom the inner config produces, from
a different place.
nginx logs the *reason* for a 400 at `info` level, which is below the
default. When something here is wrong and the access log only says `400`,
set `error_log /var/log/nginx/error.log info;` on the inner proxy, reproduce
once, and it names the cause outright. Turn it back down afterwards. To
establish which hop is at fault, bypass the outer one from the dejima box
itself:
```sh
curl -vk --resolve dejima.example.invalid:443:<dejima-box-lan-ip> \
https://dejima.example.invalid/
```
A `401` there means the inner config is correct and the fault is entirely in
what the outer proxy sends.
## Notifications
`dejima` registers `hooks/dejima-notify.sh` as a `Notification` and
`Stop` hook for a remote-enabled session - into the throwaway settings
override layer it already generates for that session, never into your real
`~/.claude/settings.json`, which every sandbox and your host's own Claude
Code read directly. Your own `Notification` and `Stop` hooks keep running
alongside it: Claude Code merges hooks across settings layers rather than
letting the higher one replace the lower. That layer is mounted read-only, so a
session cannot unregister its own hook. The hook writes one empty file, named `waiting` or
`done`, into this run's own bind-mounted marker directory. No content, no
stdin, no network.
On the host, `dejima-notify-watch` turns those markers into ntfy pushes.
Same installation shape as above:
```sh
cp systemd/dejima-notify-watch.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now dejima-notify-watch.service
```
`inotify-tools` is optional - without `inotifywait` the watcher degrades to
a roughly six-second poll rather than failing (a one-second floor under the
loop plus a five-second sleep). Repeat markers for the same session and
event inside 60 seconds are debounced (`DEJIMA_DEBOUNCE_SECONDS` overrides
that).
One environment gotcha, because it fails silently: the watcher and
`dejima` both resolve the marker root as `${XDG_RUNTIME_DIR:-/tmp}/dejima`,
so they only meet when they agree on `XDG_RUNTIME_DIR`. systemd sets it for
user units and a login shell gets it from pam_systemd, but a `dejima`
run from a context that has neither - a bare `su`, a cron job, a tmux server
started before login - writes its markers under `/tmp/dejima` while the unit
watches `/run/user/<uid>/dejima`, and no push is ever sent, with nothing
logged to say so. `systemctl --user show-environment` is where to check.
The same directory is why you probably want lingering on:
```sh
loginctl enable-linger "$USER"
```
Both units here are *user* units. Without lingering they are stopped at
logout and `/run/user/<uid>` is destroyed with the session, taking the
session registry and every marker with it - so a sandbox left running in a
detached tmux session has no front door and sends no notification until you
log in again. It fails closed rather than open (nothing is left listening
unguarded), but it fails invisibly, and it is the same `XDG_RUNTIME_DIR`
that the gotcha above is about.
**The marker directory is a write channel into your runtime tmpfs**, and
nothing bounds it. `/run/user/<uid>` is RAM-backed and sized at a fraction
of physical memory, and each session gets its own directory under it
bind-mounted read-write, so a session that writes into `/run/dejima-notify`
until it is full degrades your whole login session - D-Bus, the systemd user
manager - and not just dejima. A session can already fill the workspace
mount, so this is an increment rather than a new kind of exposure, but a
RAM-backed filesystem fails harder and sooner than a disk. There is
deliberately no quota here; `rm -rf` on that run's marker directory (or
logging out, which discards the tmpfs) is the recovery.
**What a notification contains.** A title of `dejima: <project> needs input`
(or `dejima: <project> finished`), a fixed one-line body, and - when
`BASE_URL` is set - a click link of `<BASE_URL>/?arg=<run-id>` that opens the
web front door already attached to that session. `<project>` is the
workspace directory's name, taken from the host-side registry and reduced to
`A-Za-z0-9._-`.
**And nothing about what Claude is actually asking.** That absence is
deliberate, not a missing feature: your phone is outside the sandbox, so no
sandbox-controlled bytes are allowed to reach it. The question itself is on
the terminal you are about to attach to; the push exists only to tell you to
go look.
The guarantee for that is host-side, and it is worth being precise about
where it lives. The in-container hook's `waiting|done` whitelist is **not**
it - a compromised session owns its own bind-mounted marker directory and
can create any file name in it, hook or no hook. What holds is
`dejima-notify-watch`: it iterates a literal two-name enum rather than
reading the directory, so a name outside it is never stat'd, read or
removed; it never reads marker *content*; and it consumes markers with
`rm -f`, which unlinks a symlink instead of following it. Every byte it
sends - title, body, headers, click URL - is composed from host data. That
last point is what the enum is really for: ntfy's own headers include
`X-Actions`, `X-Attach` and `X-Icon`, which can make the receiving phone
issue an arbitrary HTTP request, so a single attacker-controlled header
would turn it into a confused deputy inside your network.
**When no notification arrives.** Five things have to happen in order, and a
break in any of them looks identical from the phone - which is to say, like
nothing at all. Walk them from the inside out; each step tells you whether
the next one is worth checking.
1. **Is the session registered?** Inside the sandbox, `echo $DEJIMA_RUN_ID`
and `echo $DEJIMA_NOTIFY_DIR`. Both empty means `dejima` started this
container with remote access off - `remote.conf` did not exist at launch,
and no later change affects a session already running.
2. **Is the hook registered?** The `Notification` and `Stop` entries live in
the throwaway override layer, not in your real `settings.json`:
`grep -c dejima-notify /etc/dejima-settings.json` from inside should be 2.
That file is passed to Claude Code as `--settings` by `claude-wrapper.sh`;
if it is missing entirely, this container was not started by `dejima`.
3. **Does the marker reach the host?** This is the one worth knowing, because
it splits the chain cleanly in half. From inside the sandbox:
```sh
: > "$DEJIMA_NOTIFY_DIR/waiting" && sleep 2 && ls "$DEJIMA_NOTIFY_DIR"
```
The file vanishing means the host watcher is running and consuming, and
everything below this line is host-side. The file remaining means
`dejima-notify-watch` is not running - `systemctl --user status
dejima-notify-watch`, and check `enable-linger` if it died with your
session.
4. **Did the push succeed?** `curl`'s stderr goes to the watcher's journal:
`journalctl --user -u dejima-notify-watch -n 50`. A `push failed` line
with a 404 usually means `NTFY_URL` names the server but no topic; a 401
or 403 means the instance wants credentials, which the URL can carry as
`https://<user>:<token>@<host>/<topic>` - nothing here sends an auth
header of its own.
5. **Is the phone subscribed to the topic the config names?** Last in the
list and first to check in practice, since it is the only link with no
log anywhere. The topic in `NTFY_URL` and the one in the app have to be
the same string.
Note also that step 3 sends a real notification when the chain is healthy,
which makes it a test of the whole path rather than only of the watcher.
Repeat runs within `DEJIMA_DEBOUNCE_SECONDS` (60 by default) are consumed
without pushing, so leave a minute between attempts or you will diagnose the
debounce instead.
## Attaching
`dejima-attach` with no argument gives a numbered picker - digits rather
than arrow keys, because a phone soft keyboard handles those far better.
`--list` prints one tab-separated row per live session for scripting, and a
run-id argument attaches directly, which is what the deep link and `ttyd`'s
`--url-arg` produce. Rows carry the run id, the tmux label, the workspace
name, the session's age, its state, and the container id.
The picker is coloured (gruvbox) when stdout is a terminal that says it can
show it; `NO_COLOR=1` or a `dumb` `$TERM` turns that off, along with the
box-drawing glyphs. `--list` is never coloured, terminal or not - the picker
parses those rows itself and the README calls them scriptable.
Three states, from a registry `dejima` writes on launch and removes on
exit:
- `attachable` - a live tmux pane to join.
- `stale` - registered, but its pane is gone. Listed rather than hidden,
since the registry is a hint and not truth: the exit trap doesn't run
under `SIGKILL` or a host crash.
- `no-tmux` - the sandbox was started outside tmux, so there is no pane to
attach to. Still listed, deliberately, rather than silently omitted.
Entries whose container has gone away are pruned as they're listed. When
Docker can't be asked at all, nothing is pruned and the container column
reads `?` - an unanswerable question is never treated as a "no".
Attaching creates a **grouped** tmux session (`dejima-<run-id>`) rather than
joining yours. Grouped sessions share windows but track their own current
window, so the phone can sit on one sandbox while the desktop sits on
another, and the throwaway carries `destroy-unattached`, so it disposes of
itself when the phone detaches or the connection drops.
**You get the whole window list, not just the one sandbox.** Sharing windows
is what "grouped" means: you land on the right window, and every other
window on your tmux server is one tab away - other sandboxes, and whatever
non-dejima windows you have open. Worth knowing before you first connect,
because it is a surprise otherwise.
It is not a containment boundary being crossed, which is why it is left as
it is. Whoever reaches this terminal is one prefix key away from a brand new
host shell no matter what the tab bar shows - that is the fact this entire
feature is designed around, and tmux offers no per-client way to take the
prefix away (key tables are server-global, so disabling it for the phone
would disable it for your desktop too). Restricting the view would be
tidier; it would not make a reachable front door any less of a host shell.
Authentication and the bind address are the controls that matter here.
Sizing is left to tmux on purpose: `dejima-attach` sets no `window-size`.
tmux's own default (`latest`, since 3.1) already keeps a small phone client
from shrinking the desktop's view, and the option's only scopes are the
window - which a grouped session *shares* with yours, so setting it there
would permanently overwrite your choice with nothing to restore afterwards -
and global. An operator who deliberately set `smallest` keeps it.
+408
View File
@@ -0,0 +1,408 @@
# Security notes
[← dejima README](../README.md)
**Threat model, honestly stated:** this reduces the blast radius of a
misbehaving or prompt-injected coding agent - it does not make it safe to
point at arbitrary untrusted or adversarial input, and it does not turn
`--dangerously-skip-permissions` into something without real risk. Point it
at repositories you already trust. Everything below is about how bad a
compromised session *inside* that trust boundary can get, not a claim that
one can't happen.
- `--dangerously-skip-permissions` removes your chance to review tool calls
before they run. Only point this at trusted repositories.
- The mounted project itself has **no write protection**. `deny-dangerous-
bash.sh`, `git-push-guard.sh` and `gitea-merge-guard.sh` are regex
pattern-matching on the literal Bash command string - a fast-fail guard
against an overeager agent doing something obviously destructive, not a
boundary against one that's actively trying to get around them (a
different scripting language, an encoded command, editing `.git/config`
directly all sail right through).
Nothing here stops a compromised or badly-instructed session from
modifying or deleting the real working copy it's mounted against.
- That same read-write access to the mounted project is also a
host-code-execution vector, not just a data-destruction one: a
compromised session could plant `.git/hooks/post-commit` (or
`pre-push`), set `core.hooksPath`, or write a
`.claude/settings.local.json` with a malicious hook command, into the
project. Any of those run unsandboxed on the host the next time you run
`git` or open Claude Code in that repo - no network egress required, so
the firewall doesn't see it either. Same underlying persistence pattern
as the `~/.claude/hooks` and `~/.claude.json` risks above, just through
the project mount instead of the config mount. "Only point this at
trusted repositories" (above) covers input trust; this is about the
sandbox writing a persistence payload into an otherwise-trusted repo.
The workspace's own `.git/hooks` directory is now mounted read-only
(same pattern as the `~/.claude` code directories below), so planting a hook
file directly is closed - accepted breakage: installing hooks
*in-session* (`husky`, `pre-commit install`) now fails, which is the
point. `core.hooksPath` and `.claude/settings.local.json` aren't
overlaid (they're config, not fixed paths a static mount can cover) and
remain fully writable; the exit-time audit below reports changes to
either, but only after the session ends - it doesn't stop them.
- `bin/dejima-audit` snapshots host-persistence surfaces before each
run and diffs them after the container exits: `.git/config` (and worktree
`config.worktree`) keys that run a command on the next `git` -
`core.hooksPath`, `core.fsmonitor`, `core.sshCommand`,
`credential.helper`, `core.pager`, `pager.*`, `core.editor`,
`sequence.editor`, `gpg.program`, `diff.external`, `merge.*.driver`,
`diff.*.command`, `filter.*.smudge`/`clean`/`process`, `diff.*.textconv`,
`include.path`/`includeif.*.path` (config indirection - these can point at
a planted file that itself sets an exec key, so the exec key never
appears in `.git/config` directly), `!`-shell aliases - plus content
changes under the workspace's `.claude/`, `.mcp.json`, `.envrc`,
`.vscode/`, and `.git/hooks/` (symlinks included - a repointed symlink is
hashed by its target, not just its content), plus cross-project writes
under `~/.claude/projects` (see below). When the mounted workspace *is*
this repo's own sandbox itself (see the trusted-computing-base note
further below), it also diffs content hashes of the sandbox's own trusted
computing base - `bin/`, `hooks/`, `docker/`, `install.sh`,
`test/` - under that workspace. It's **advisory only**: it fires *after*
the session has already ended, so a report of a planted `core.hooksPath`
or hook file is something to read and clean up *before* the next `git`
or `claude` in that repo, not something that was blocked. It's silent when nothing
changed, and never affects the session's own exit code even if it can't
complete. It doesn't cover `$SANDBOX_SHARE_DIR` (see "Shared scratch
space") - that hand-off path stays unaudited - and its coverage is a
fixed list of known Claude Code config surfaces, not a general integrity
check, so a future config surface this list hasn't been taught about yet
would go unreported. **Its diff is time-windowed, not session-attributed:**
a finding means one of these paths changed while the session was running,
not that the session made the change. A concurrent dejima or Claude Code
session, another host process, or you editing the same files by hand
during the run all land in the same diff and get reported identically -
the tool has no way to tell them apart.
- Report findings name a path or config key that a session may itself have
chosen, so those names reach your terminal as untrusted text. One that
contains an ASCII control character, a C1 control, or a Unicode bidi
override - which between them can scroll, blank, or visually reorder what
you read, and can split a name across the audit's own records - is
written hex-encoded behind a `{hex}` marker instead, and decodes with
`printf '%b' "$(sed 's/../\\x&/g' <<< HEX)"`. Ordinary names are printed
as-is, so the marker appearing at all is itself worth a second look.
The encoded form is what `dejima-audit ack` records in the baseline, so
what you acknowledge is the file that is actually on disk rather than a
readable approximation of its name.
- `~/.claude/projects` (session transcripts and per-project memory) is
**not** mounted read-only, unlike the code-carrying `~/.claude`
directories - deliberately. Scoping a read-only mount to "the
current project's slug"
would break the sandbox's own worktree workflow: a session running in
`WORKSPACE/.worktrees/<branch>` writes to a *different* projects slug
than the parent repo, so a slug-scoped overlay would either block that
legitimate write or have to be widened until it stopped protecting
anything. Instead, cross-project writes are **detected, not prevented**:
the exit-time audit above flags any write during the session to another
project's `~/.claude/projects/<slug>` directory, excluding the current
project's own slug and its worktree children. That's after-the-fact
reporting of transcript/memory poisoning in *other* projects, not a
block on it happening. Residual even within that: the current project's
own transcripts/memory stay fully writable throughout, since Claude Code
needs that for host-side `claude --resume` to work; and detection keys
off each slug's max file mtime, so an edit to a file that isn't already
that project's newest doesn't advance the max and goes unflagged. A
read-only mount for `projects/` remains a possible future option if
Claude Code is confirmed to fail cleanly (not hang, as `~/.claude.json`
did) when a write to it is blocked. This is also the sharpest case of
the time-window caveat above: `~/.claude/projects` is a single
directory shared by *every* Claude Code session on the host, not scoped
to the one being audited, so an entirely innocent concurrent session
working on some unrelated project can produce a `CROSS-PROJECT` finding
here that reads like the audited session poisoned another project's
history - it didn't, it just happened to be running at the same time.
- Allowing egress to a domain at all creates a channel for exfiltrating data
through *that service's own request surface* (query strings, lookups,
etc.), not just to a literal attacker-controlled destination. Domain-level
allowlisting closes off reaching somewhere new; it can't inspect or
restrict what gets sent to somewhere already trusted for normal operation
(installing packages, calling the model API). That's a structural limit of
allowlist-only firewalls in general, not something specific to this setup,
and nothing here addresses it.
- This is container-level isolation (Linux namespaces plus a small
capability set), not VM-level isolation - no gVisor/Firecracker or
similar. It shares the host kernel, so a container-escape vulnerability
would defeat every other control described here at once.
- Most of what this document asserts is re-checked by the `test/` suite on
every change - over 300 bats tests across six files, and `runtime.bats`
builds the image and asserts against a real running container rather than
a mock: IPv6 blocked, a non-allowlisted domain refused by the DNS proxy
and a non-allowlisted destination unreachable, `node` unable to re-run
`init-firewall.sh` to widen the allowlist, no working `sudo`. Coverage is
not the same as completeness, though. The reasoning in this document is
judgement rather than assertions, nothing tests it, and a property nobody
thought to write a test for can still regress quietly. A missing `ipset`
dedup flag broke firewall startup entirely - twice, on two different
ipsets - before anyone caught it; there is a test asserting every
`ipset add` is duplicate-safe now, which is the pattern to expect. Each
of these tests exists because something was got wrong first.
- The Anthropic OAuth credential (`~/.claude/.credentials.json`) never
enters the container. It simply isn't mounted: `~/.claude` is not shared
as a whole (see the mount inventory below), so there is no path from
inside the container to that file. `dejima` starts a host-side proxy
(`bin/dejima-anthropic-proxy`) on a per-session unix socket and mounts a
fake credential in place of the real one - the two secrets are
placeholders, while `scopes`/`subscriptionType`/`rateLimitTier`/`clientId`
are copied from the real file so the session's own UI stays accurate. Its
expiry is set a year out rather than mirrored, deliberately: a near-term
one would make Claude Code refresh on its own, with a refresh token that
isn't real, and log the session out. Inside the container,
`entrypoint.sh` points `api.anthropic.com` at a loopback TLS terminator
(`anthropic-proxy-forwarder.js`) whose certificate is generated at startup
and trusted only there, and which pipes raw bytes into the proxy's socket.
The proxy injects the real access token per request and owns OAuth refresh
- rotated tokens are written back to the real host file atomically, so
host-side sessions stay logged in. Claude Code itself is configured
exactly as it would be on the host: no `ANTHROPIC_BASE_URL`, no
`ANTHROPIC_AUTH_TOKEN`, nothing that marks the session as unusual to the
client. The proxy then forwards the client's header block verbatim -
same names, same casing, same order, repeats intact, and no
`Accept-Encoding` it didn't send - changing only the `Authorization`
value, and `anthropic-beta` if the OAuth value isn't already there. Left
to itself, Python's `http.client` would have rewritten all of that into
its own house style and moved the fingerprint from the client to the
wire. What it cannot disguise is that the connection is made by CPython's
`ssl` rather than by Node: the TLS handshake (JA3/JA4) and the absence of
connection reuse still distinguish it at that layer. What remains is
session-scoped: a
compromised session can still *use* the model API as you while it runs
(that's inherent to the sandbox working at all), but it can no longer read
a durable credential and exfiltrate it for offline use via the other
allowed egress destinations (npm, GitHub, your Gitea host). Caveats:
`/login` inside the container doesn't work while the proxy is on; the
refresh flow mirrors a Claude Code internal and may break if upstream
changes it, in which case the proxy fails loudly (launch abort, 502s on
API calls) rather than silently; and a 401 that does reach the container
can make Claude Code clear the fake credential file, showing a logged-out
session - harmless, since it's a host temp file, and rare, since the proxy
absorbs 401s with its own refresh-and-retry.
`SANDBOX_CREDENTIAL_PROXY=0` is the kill switch, restoring the previous
behavior - the credential mounted into the container explicitly, readable
by a compromised session. Setups without a `claudeAiOauth` block (API-key
auth) skip the proxy automatically; there's no stored subscription
credential to protect.
- **If TLS interception stops working.** The container reaches the API by
resolving `api.anthropic.com` to the loopback terminator above, whose
certificate is trusted only inside the container. If Claude Code ever pins
certificates for that host, the handshake starts failing - typically right
after an upgrade, as `UNABLE_TO_VERIFY_LEAF_SIGNATURE`,
`ERR_TLS_CERT_ALTNAME_INVALID`, or a pin-specific error. What
distinguishes pinning from a merely broken certificate is that
`curl --cacert /etc/dejima-anthropic-tls/cert.pem https://api.anthropic.com/v1/messages`
still succeeds from inside the container while Claude Code does not: the
certificate is fine, the client is refusing it. **This is not a reason to
set `SANDBOX_CREDENTIAL_PROXY=0`.** That kill switch mounts the real,
durable OAuth credential into a container a compromised session can read,
which is the single thing this whole mechanism exists to prevent.
Certificate pinning breaks how Claude Code is *addressed*; it has no
bearing on whether the credential can be kept on the host. Work down this
ladder instead - the first two rungs keep the credential out of the
container, and only the "looks like an ordinary session" property degrades.
First, `SANDBOX_ANTHROPIC_TRANSPORT=loopback`: a plaintext loopback
forwarder and an explicit `ANTHROPIC_BASE_URL=http://127.0.0.1:18300`, the
topology this sandbox used before the TLS terminator existed, so pinning
cannot apply at all. It costs the base-URL fingerprint, two attribution
fields in the `x-anthropic-billing-header`, and `/remote-control`. Second,
if even that fails, `ANTHROPIC_UNIX_SOCKET` pointed at the bind-mounted
socket together with `ANTHROPIC_BASE_URL=http://api.anthropic.com`:
plaintext HTTP straight over the socket, bypassing TLS entirely while
keeping the first-party base-URL check truthy. That rung is a direction,
not a switch: nothing in this repo implements or tests it, both variables
have to be passed through `docker-compose.yml` by hand, and it depends on
Claude Code's own support for `ANTHROPIC_UNIX_SOCKET`. It also costs more in
appearance than rung one: it classifies the session as host-orchestrated
and emits its own analytics event. Only if both fail, and knowing what it
gives up, `SANDBOX_CREDENTIAL_PROXY=0` - the one rung that surrenders the
real credential into the container. Rung one is a supported, tested mode,
not an improvised workaround; if you reach the third, treat it as a
temporary state rather than a new baseline.
- `~/.claude` is **not** shared with the container as a whole. Its contents
are enumerated in `docker-compose.yml`, in three classes, and anything not
listed is container-local: writable during the session and gone with the
container.
- **Read-write, because it has to survive the session**: `projects/`,
`file-history/`, `backups/`, `tasks/`, `plans/`, `sessions/`. All
directories, which is what makes them safe (see the detachment note
below).
- **Read-only**: `plugins/`, `hooks/`, `skills/`, `commands/`, `agents/`,
`output-styles/`, plus the symlink-target directories `dejima`
discovers at launch. Hooks execute automatically with no separate
approval step, skills can ship executable scripts, and
commands/agents/output-styles are prompt text loaded into a later host
session, so without this a compromised session could plant a backdoored
hook - including by unlinking one of `install.sh`'s hook symlinks and
dropping a plain file in its place, since the dynamic per-target mounts
only protect symlink targets that resolve *outside* `~/.claude`, never
this directory itself - tamper with plugin code, or drop a malicious
skill/command/agent/output-style file. Any of those would then run or
load **unsandboxed on the host** the next time Claude Code starts
there, turning a single compromised sandbox session into persistent
host compromise. They may not exist yet on a given host, so `dejima`
pre-creates them at launch (empty is fine) - Docker can only mount a
directory that already exists, and without pre-creation a compromised
session could create and populate one itself.
- **Copied in, not mounted**: `settings.json`, `CLAUDE.md`, and
`statusline.sh` are mounted read-only at `/etc/dejima-claude/` and
installed into `~/.claude` by `entrypoint.sh` as root-owned, unwritable
files. A session can still unlink and replace them - `~/.claude` belongs
to it - but only its own copy, which was the whole point of making them
read-only.
The reason for the split is that a **single-file bind mount silently
detaches**. It is pinned to the dentry at its mount point, so when the
host replaces the underlying file - the ordinary atomic
write-temp-then-`rename`, performed by Claude Code itself or by any sync
daemon watching `~/.claude` - that dentry is unlinked and the mount goes
away with no error anywhere. `docker inspect` still lists it;
`/proc/self/mountinfo` inside the container no longer does. This was
found the hard way three times: the generated settings overlay reverting
mid-session (#47), every other file overlay carrying the same latent bug
(#48), and the mask over `.credentials.json` detaching on the proxy's own
first token refresh, exposing the operator's real OAuth access *and*
refresh tokens to the sandbox (#50). Directory mounts are immune, because
a rename replaces an entry *within* the mounted directory rather than the
mount point itself. Enumerating also changes the failure mode: with no
read-write parent underneath, a mount that somehow did detach falls
through to an empty container-local path rather than to the host's real
file. The cost is that anything a future Claude Code version adds under
`~/.claude` is container-local until it is listed here - it works during
the session but doesn't persist, which is the safer direction to fail.
- `~/.claude/statusline.sh` (referenced by `settings.json`'s
`statusLine.command` and executed on the host) is one of the copied-in
files above, and the case that shows why copying beats mounting. It used
to be bind-mounted read-only onto its own path, which - **confirmed
against a live container** - blocked replacement only when
`statusline.sh` was a plain regular file. When it was a symlink (the
common case, into a config checkout elsewhere), Docker/runc resolved the
bind-mount destination through the symlink to its target, so the symlink
entry itself stayed on the writable `~/.claude` mount and remained
replaceable, and detection was the only compensating control. Staging
turns that same symlink resolution into the fix: it happens on the
*source* side, so what gets copied in is the real content and the copy
has no route back to the host (`test/runtime.bats`'s statusline.sh
tests). `bin/dejima-audit` still hashes `statusline.sh` on the host
(following a symlink to its target *path string*, so a repointed or
replaced symlink is caught even though its own inode doesn't change) and
reports a change as a host-code-execution finding when the session exits.
- `~/.claude.json` mixes that same kind of live state (project registry,
usage stats, caches) with config (`mcpServers`). Unlike the four above,
it can't just be layered read-only: Claude Code does an atomic
write-temp-then-rename against it during normal startup, before login
even completes, and a read-only file makes that **hang** instead of
failing cleanly (confirmed against a real read-only copy of this file -
upstream bug: [anthropics/claude-code#29162](https://github.com/anthropics/claude-code/issues/29162)).
Instead, `dejima` generates a throwaway copy of the file at
launch (seeded from the real file, same "generate a sandbox-specific
file on the host" pattern as the generated settings override layer)
and mounts *that* read-write in its place; the copy is discarded,
unmodified in the real file, when the session ends. A compromised
session can add whatever it wants to `mcpServers` (or anything else in
the file) - it never reaches the real `~/.claude.json`, so there's
nothing to persist. This also means multiple concurrent sandbox sessions
never contend over the same file - each gets its own independent copy.
### Throwaway ~/.claude.json: what doesn't carry over
Because nothing writes back, anything a *sandboxed* session would
normally have caused Claude Code to record into `~/.claude.json` is lost
when that session ends, instead of showing up in a later host-side
`claude` run. Concretely, as of the fields this file holds today:
- **Per-project trust/approval state** - `hasTrustDialogAccepted`,
`allowedTools`, `enabledMcpjsonServers`/`disabledMcpjsonServers` - for
any project directory opened only inside the sandbox. Opening that same
project on the host later re-prompts for whatever it would have
prompted for outside the sandbox too. Largely moot today since the
sandbox always runs `--dangerously-skip-permissions`, but the trust
dialog is a separate gate from tool-permission prompts.
- **`mcpServers` additions or edits** - the point of this fix, not an
incidental side effect. Nothing added or changed here during a
sandboxed session ever reaches the real file.
- **Session/usage telemetry** - `lastCost`, `lastDuration`,
`lastTotalInputTokens`/`lastTotalOutputTokens`, `lastModelUsage`,
`lastSessionId`, `mcpContextUris`, and similar per-project fields.
Sandbox activity won't show up in host-side usage totals or in
`claude --resume`'s "last session" metadata for that project.
- **Global counters and caches** - `numStartups`, `firstStartTime`,
`cachedGrowthBookFeatures`, `changelogLastFetched`,
`announcementImpressions`, `tipsHistory`, `feedbackSurveyState`, and
similar top-level bookkeeping. Purely cosmetic - these get refetched or
recomputed as needed; sandbox runs just don't contribute to the host's
copies of them.
- **`oauthAccount`/`userID`/`machineID`** are read fresh from the real
file at the start of every sandbox launch, so authentication carries in
normally each time; anything that changes about them *during* a
sandboxed session (e.g. a token refresh) doesn't carry back out. Worth
re-checking if a future Claude Code version starts relying on that for
normal operation.
- DNS resolution itself is allowlisted (see "DNS is allowlisted too" in the
README),
not just the IP connections made afterward, closing off exfiltration via
encoding data into query names to an attacker-controlled domain - verified
a non-allowlisted domain gets refused outright (`status: REFUSED`) rather
than resolving and then only failing to connect.
- Don't mount other host secrets (`~/.ssh` private keys, cloud credential
files) into the container. Git push auth goes through the forwarded SSH
agent instead, which never exposes the key material itself - though note
the agent will sign for *any* Gitea repo your key has access to, not just
the mounted project, and the firewall's Gitea allowlist is host-based
rather than repo-scoped, so a compromised session can still reach (and
push to) other repos on that host you have access to. This doesn't extend
to GitHub by default: port 22 there is blocked unless `ALLOW_GITHUB_PUSH=1`
is set, so the forwarded agent has no transport to use for it at all
otherwise (see "GitHub push: blocked by default, optional opt-in" above).
- `SANDBOX_SSH_KEY` (see "Headless git push without agent forwarding") is a
second, independent credential-loading path with the same "signs for any
Gitea repo it has access to" shape as the forwarded agent above - the
mitigation is that it's meant to be a dedicated deploy key scoped to only
the repos that actually need push access, not your personal identity.
That scoping is a convention, not something enforced here: nothing checks
what `SANDBOX_SSH_KEY` actually points at, so pointing it at your real
`~/.ssh/id_ed25519`/`id_rsa` instead of a dedicated key would load your
full personal identity into the container for the whole session (`bin/
dejima` warns if this looks like it might be happening - see
below - but doesn't block it). The key source itself is mounted read-only
at `/root/...`, not under the sandbox user's own home, specifically so
that user (which the session actually runs as) has no path to read the
raw key material - only the agent socket it's loaded into.
- The gitconfig copy `dejima` generates (see that mount's
comment in `docker-compose.yml`) carries over whatever's in your real
`~/.gitconfig` verbatim. If yours has inline credentials (a
`[credential]` token, a `url.<token@>.insteadOf` rewrite, a
`credential.helper store` path), those enter the container along with
it - an unadvertised secret-carrying path distinct from the
SSH-agent/token story documented above. Keep credentials out of your
global gitconfig, or expect them to be reachable from inside the
sandbox.
- The sandbox's own user (matching your host user — see "Config parity" in
the README) has no `sudo`/setuid path back to root once the container is up:
`entrypoint.sh` runs as root, sets up the firewall directly, then drops to
that user via `gosu` before execing `$SANDBOX_CMD`. A compromised session can't
re-run `init-firewall.sh` with a wider allowlist to open its own
exfiltration path. Verified: capabilities (`NET_ADMIN`/`NET_RAW`) are
cleared from the effective/permitted sets on the `gosu` drop, and that
user gets a permission error running `iptables` directly.
- The approval store (`${DEJIMA_CONFIG_DIR:-$HOME/.config/dejima}`,
see "Global and per-project domains and dependencies" above) lives outside
this repo, and outside every project mount, by default - unlike a
project's own `.dejima/` declaration files, it's never
bind-mounted into the container, so a compromised
session has no path to write to it at all, let alone plant something for
the *next* launch to pick up. Its files are read by a small
line-oriented parser (`approved_entries` in
`bin/dejima-project-lib.sh`), never sourced or executed, and are
written only by the CLIs (`dejima-allow`, `dejima-dep`,
`dejima-project`) a human runs on the host. `dejima`
warns at launch whenever the resolved store path — default or overridden
via `DEJIMA_CONFIG_DIR` — lands under the mounted workspace,
rather than silently trusting it.
- When the mounted workspace *is* this repo itself, `dejima`
warns at launch that the repo's own trusted computing base - `bin/`,
`hooks/`, `docker/`, `install.sh` (also `test/`, per the
exit-time audit's exact TCB path list above) - is writable for the
session (it's inside the project mount, which has no write protection,
per above), and that the exit-time audit will report any changes to it.
That's a warning and a post-hoc report, not a block: a compromised
session can still edit those files during the run, and whatever it
changes takes effect the *next* time `dejima` itself runs,
not the current one.
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Notification/Stop hook. bin/dejima registers it, per session, into the
# throwaway settings.json copy it generates for a session that has remote
# access enabled, and nowhere else. Every sandbox gets such a copy, derived
# from the operator's real settings.json; registering it in that real file
# instead would put this hook into all of them - and into the host's own
# Claude Code, which reads that file directly - where there is no marker
# directory to write into. The copy is mounted read-only (see
# docker-compose.yml), so a session cannot unregister its own hook.
#
# It writes one empty file whose NAME is its entire meaning. No content, no
# stdin, no network. The host-side watcher composes the whole notification
# from host data, so not one byte from inside the sandbox reaches the
# operator's phone. Anything more expressive here would be an injection
# channel into a device that sits outside the sandbox - see
# docs/remote-access.md on why the phone is the thing being protected.
set -euo pipefail
# A whitelist, not a filter: an unrecognized event creates nothing at all.
# Sanitizing an arbitrary name into a marker would make the file name a
# channel the sandbox controls, which is the one thing this hook must not be.
case "${1:-}" in
waiting|done) ;;
*) exit 0 ;;
esac
# Absent outside a remote-enabled session, and the directory is a bind mount
# that only exists there. Exit quietly rather than fail: a hook that errored
# in an ordinary sandbox would surface on every notification, and there is
# nothing for it to do. Never created here either - the host owns that
# directory's lifetime (bin/dejima makes it and rm -rf's it on exit).
[ -n "${DEJIMA_NOTIFY_DIR:-}" ] || exit 0
[ -d "$DEJIMA_NOTIFY_DIR" ] || exit 0
: > "$DEJIMA_NOTIFY_DIR/$1"
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# PreToolUse hook: inside dejima, block `gitea-pr merge`. The sandbox
# workflow is feature branch -> `gitea-pr create` -> a human merges; what
# lands on main is an operator decision, exactly as it already is for
# `git push` (see git-push-guard.sh, whose no-direct-to-main rule this is
# the API-side counterpart to - a merge reaches main without a push, so that
# guard never sees it).
#
# Deliberately not conditional on intent. A hook is handed a command, not a
# conversation, so it cannot tell an instructed merge from an unprompted one;
# the only honest place to draw the line is the context, and the context that
# needs the line is this one - headless unattended runs under
# --dangerously-skip-permissions, where nobody is watching the turn happen.
# On the host the hook is a no-op and an operator can still ask for a merge.
#
# Like its siblings this is a fast-fail guard against an overeager agent, not
# a hard security boundary: a sandbox session has full shell access and the
# Gitea token in its environment, so it could call the merge endpoint with
# curl directly. It stops the mistake, not the attack.
if [ -z "${DEJIMA:-}" ]; then
exit 0
fi
command=$(jq -r '.tool_input.command // empty')
# The leading class includes '/' so a path-qualified call is caught too -
# /usr/local/bin/gitea-pr is how the image installs it, and is what an agent
# reaching past a shadowed PATH would type. Word-splitting rather than a full
# shell parse, the same pragmatic tradeoff the sibling hooks make.
if [[ "$command" =~ (^|[[:space:]&\|\;/])gitea-pr[[:space:]]+merge([[:space:]]|$) ]]; then
echo "Blocked by gitea-merge-guard.sh: merging is a human decision in the sandbox - the workflow is 'gitea-pr create', then the operator merges. Report the PR URL and stop." >&2
exit 2
fi
exit 0
+39 -8
View File
@@ -169,13 +169,22 @@ What this means:
project. ~/shared is also a real, persistent host directory (not part of
the project) - use it to hand the user a file (a screenshot, a temp
script) without touching the project checkout, or to pick up something
they left there for you. Everything else in the container besides
~/.claude is disposable: installed system packages, anything outside the
mounted paths, and the container itself go away when this run ends.
they left there for you. ~/.claude is bind-mounted too, so this session's
transcript and its project memory outlive the container - the work is
recoverable on the host with the \`--resume\` command at the end of this
briefing. Everything else is disposable: installed system packages,
anything outside the mounted paths, and the container itself go away when
this run ends.
- Outbound network is restricted to an allowlist (Anthropic's API, the npm
registry, GitHub read-only, the configured Gitea host, plus anything
approved via dejima-allow). Requests to anything else will simply
fail to connect. If a task needs one more domain and nothing else, that's
fail to connect. That restriction is not only a limit on your reach: it
is also what bounds where a prompt injection or a compromised dependency
in this session could send the user's code or fetch its next instruction
from. You cannot audit yourself for that from the inside, so the
allowlist is load-bearing for you too, not just for the host - working
around it would give up that protection, not merely lift the one limit
it appears to. If a task needs one more domain and nothing else, that's
a quick fix, not a reason to stop: tell the user to run
\`bin/dejima-allow <domain> --container $container_id\` (add
\`--global\` to persist it past this session) from a terminal outside the
@@ -194,10 +203,9 @@ Git workflow - do this even though nothing stops you from doing otherwise:
\`git worktree add .worktrees/<branch> -b <branch>\` run from the project
root - never \`../<branch>\` or any other path outside it, including
anywhere under \$HOME. Only the exact directory bind-mounted into this
container is backed by real storage on the host; everything else,
including what looks like the project's own parent directory, is
container-local and silently disappears when the session ends - a
worktree created there is real work that just gets thrown away. Add
container is backed by real storage on the host - what looks like the
project's own parent directory is not, so a worktree created there is
real work that just gets thrown away. Add
\`.worktrees/\` to \`.git/info/exclude\` (not \`.gitignore\` - this is
sandbox-local, not something to commit) so it doesn't clutter \`git
status\` for the user's real checkout.
@@ -206,6 +214,29 @@ Git workflow - do this even though nothing stops you from doing otherwise:
push regardless), and never push directly to main/master, on any remote.
A \`git-push-guard.sh\` hook enforces both as a backstop, but treat them
as the actual workflow to follow, not just a filter to route around.
Merging is the operator's call, not yours - a \`gitea-merge-guard.sh\`
hook blocks it here. Report the PR URL and stop.
- Name the issues a PR resolves in its body: \`Fixes #N\` (\`Closes\` and
\`Resolves\` work too) tells Gitea to close them when it merges. Use
\`Refs #N\` for work that is partial or merely related, so those stay
open. Prefer the keyword to closing anything yourself once it lands,
because of the line above - you stop at the PR, and the merge happens
later without you, so whatever you left for "after the merge" is not
something you will be there to do. Write \`#N\` and not the issue's URL:
they render alike, but only the short form triggers the close. Read the
whole issue before claiming it, since the ask is often wider than the
title, and a closing keyword closes it whether or not you covered all
of it.
- Write PR and issue bodies to a file and pass \`--body-file FILE\`, rather
than \`--body "..."\` or a heredoc. Both of those put the entire body into
the Bash command string, where \`deny-dangerous-bash.sh\` reads it - and
that hook matches its patterns anywhere in the string, with no way to tell
an instruction from prose that merely mentions one. A body describing a
crash, a restart, or a destructive command it is warning about will get
the call blocked, which looks like a broken tool rather than what it is.
Writing the file first keeps the prose out of the command entirely.
\`gitea-issue\` (list/view/create/comment/close/reopen) takes the same
flag, for recording a problem you found but aren't fixing.
If a task genuinely needs something else the sandbox can't provide -
broader network access than a domain or two, host-level or system changes,
+44 -4
View File
@@ -1,10 +1,13 @@
#!/bin/bash
# Idempotent installer for dejima. Symlinks this repo's hooks into
# ~/.claude/hooks, registers them in ~/.claude/settings.json, and puts
# bin/dejima, bin/dejima-allow, bin/dejima-dep and
# bin/dejima-project on PATH. Safe to re-run -
# every step is a no-op if already done, and every change is printed as it
# happens.
# bin/dejima, bin/dejima-allow, bin/dejima-dep, bin/dejima-project and the
# opt-in remote-access scripts on PATH. Safe to re-run - every step is a
# no-op if already done, and every change is printed as it happens.
#
# It never enables anything: the remote-access scripts do nothing without
# ~/.config/dejima/remote.conf, and the user units in systemd/ are left for
# the operator to install by hand.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -50,6 +53,14 @@ link_hook() {
link_hook sandbox-context.sh
link_hook git-push-guard.sh
link_hook gitea-merge-guard.sh
# Linked so it exists at a stable path for the read-only ~/.claude/hooks
# mount, but deliberately NOT passed to register_hook below: bin/dejima
# registers it per session, into a throwaway settings copy, and only when
# remote access is configured. Registering it here would fire it for every
# unsandboxed host session too, where it has no marker directory to write to.
link_hook dejima-notify.sh
# deny-dangerous-bash.sh is a general (non-sandbox-gated) hook that may
# already be owned by another install (e.g. dotfiles) - only claim it if
@@ -90,6 +101,8 @@ register_hook SessionStart "startup|resume|clear|compact" sandbox-context.sh \
"injects sandbox context on session start"
register_hook PreToolUse Bash git-push-guard.sh \
"blocks pushes outside the allowed host(s)/branches"
register_hook PreToolUse Bash gitea-merge-guard.sh \
"blocks 'gitea-pr merge' inside the sandbox"
register_hook PreToolUse Bash deny-dangerous-bash.sh \
"blocks obviously destructive Bash commands"
echo
@@ -130,7 +143,34 @@ link_bin dejima
link_bin dejima-allow
link_bin dejima-dep
link_bin dejima-project
# Remote access (opt-in, see docs/remote-access.md). All three are inert
# without ~/.config/dejima/remote.conf - they refuse to run at all - so linking
# unconditionally adds no front door and costs nothing. The user units in
# systemd/ are deliberately NOT copied or enabled here: standing up a front
# door is an explicit operator decision, and this script never touches
# systemd.
link_bin dejima-attach
link_bin dejima-ttyd
link_bin dejima-notify-watch
echo
echo "Done. Try: $BIN_DIR/dejima ~/some/project"
echo "(or just 'dejima ~/some/project' once $BIN_DIR is on PATH)"
echo
# Conditional, because the unconditional version is a false statement to the
# operator most likely to see it: someone with a front door already up,
# re-running this after a git pull.
# From the library rather than spelled out again here: the config root has
# one definition (config_root, which honours DEJIMA_CONFIG_DIR) and a second
# copy of it would drift without anything noticing.
# shellcheck source=bin/dejima-project-lib.sh
source "$SCRIPT_DIR/bin/dejima-project-lib.sh"
remote_conf="$(remote_conf_path)"
if [ -f "$remote_conf" ]; then
echo "Remote access is configured ($remote_conf); this script did not start or stop anything."
echo "See docs/remote-access.md."
else
echo "Remote access is available but not configured - nothing is listening."
echo "See docs/remote-access.md to turn it on."
fi
+52
View File
@@ -0,0 +1,52 @@
# User unit for the host-side notification watcher. Not enabled by
# install.sh - pushing to a phone is an explicit operator decision:
#
# mkdir -p ~/.config/systemd/user
# cp systemd/dejima-notify-watch.service ~/.config/systemd/user/
# systemctl --user daemon-reload
# systemctl --user enable --now dejima-notify-watch.service
#
# It does nothing useful until ~/.config/dejima/remote.conf exists and sets
# NTFY_URL; without that dejima-notify-watch exits 1 and the restart limit
# below stops the unit instead of retrying forever.
#
# This unit must be a USER unit, and its environment is load-bearing: both
# this watcher and bin/dejima resolve the marker root as
# ${XDG_RUNTIME_DIR:-/tmp}/dejima, so the two agree only when they agree on
# XDG_RUNTIME_DIR. systemd sets it for every user unit; a login shell gets it
# from pam_systemd. If bin/dejima is ever run from a context that has no
# XDG_RUNTIME_DIR (a bare `su`, a cron job, a tmux server started before
# login), it writes its markers under /tmp/dejima while this unit watches
# /run/user/$UID/dejima, and no notification is ever pushed - with nothing
# logged anywhere to say so. `systemctl --user show-environment` is where to
# check that they match.
[Unit]
Description=dejima notification watcher (marker files to ntfy)
# A missing remote.conf or NTFY_URL fails every start identically, so give up
# visibly rather than restarting for the life of the login session. A failed
# push is not a failed start: the watcher warns and carries on, so a network
# that is not up yet costs nothing here.
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
# NTFY_URL, BASE_URL and the marker root all come from remote.conf, read by
# the script - do not repeat any of them here, or the two will drift.
# %h, not an absolute path: this file is the same for every operator.
ExecStart=%h/.local/bin/dejima-notify-watch
Restart=on-failure
RestartSec=5
# Unhardened on purpose, and worth stating rather than leaving as an
# omission: the unit next door (dejima-ttyd) deliberately hands out a shell
# with this same uid's full privileges, so sandboxing this one would raise
# the cost without lowering the exposure. What makes the watcher safe is in
# the script - it consults marker NAMES against a literal enum, never their
# contents, and composes every byte it sends from host-side data.
#
# No After= either: a user manager has no ordering to offer against the
# network, and none is needed - a push that cannot go out is warned about,
# not fatal.
[Install]
WantedBy=default.target
+42
View File
@@ -0,0 +1,42 @@
# User unit for the dejima web front door. Not enabled by install.sh -
# standing up a front door is an explicit operator decision:
#
# mkdir -p ~/.config/systemd/user
# cp systemd/dejima-ttyd.service ~/.config/systemd/user/
# systemctl --user daemon-reload
# systemctl --user enable --now dejima-ttyd.service
#
# It does nothing useful until ~/.config/dejima/remote.conf exists; without
# it dejima-ttyd exits 1 and the restart limit below stops the unit instead
# of retrying forever.
[Unit]
Description=dejima remote session terminal (ttyd)
# A missing or malformed remote.conf fails every start identically, so give
# up visibly (systemctl --user status says why) rather than restarting for
# the life of the login session.
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
# The bind address and the port both come from bin/dejima-ttyd, which reads
# remote.conf - do not repeat either of them here, or the two will drift.
# %h, not an absolute path: this file is the same for every operator.
ExecStart=%h/.local/bin/dejima-ttyd
Restart=on-failure
RestartSec=5
# Deliberately unhardened, which is worth stating rather than leaving as an
# omission: this unit exists to hand out a host shell (ttyd -> dejima-attach
# -> a tmux client on the operator's own server), so the usual directives
# would break it rather than contain it. PrivateTmp= would hide the tmux
# server socket under /tmp/tmux-$UID; ProtectHome= would hide the scripts
# above and the repo they link to; NoNewPrivileges= would break sudo inside
# the attached session. What contains this feature is the loopback bind in
# bin/dejima-ttyd, its --check-origin, and authentication in the reverse
# proxy - see docs/nginx-dejima.conf.example - not unit sandboxing.
#
# No After= either: a user manager has no ordering to offer against the
# network, and none is needed - the listener is on loopback.
[Install]
WantedBy=default.target
+730 -10
View File
@@ -14,8 +14,10 @@ setup() {
CURRENT_SLUG="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
STATUSLINE_DIR="$(mktemp -d)"
export DEJIMA_AUDIT_STATUSLINE="$STATUSLINE_DIR/statusline.sh"
AUDITDIR="$(mktemp -d)"; export DEJIMA_AUDIT_DIR="$AUDITDIR"
BASEDIR="$(mktemp -d)"; export DEJIMA_AUDIT_BASELINE_DIR="$BASEDIR"
}
teardown() { rm -rf "$WS"; rm -f "$STATE"; rm -rf "$PROJDIR"; rm -rf "$STATUSLINE_DIR"; }
teardown() { rm -rf "$WS"; rm -f "$STATE"; rm -rf "$PROJDIR"; rm -rf "$STATUSLINE_DIR"; rm -rf "$AUDITDIR"; rm -rf "$BASEDIR"; }
snap() { "$AUDIT" snapshot "$WS" "$STATE"; }
cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
@@ -27,6 +29,30 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
[ "$output" = "" ]
}
@test "compare against an existing-but-never-snapshotted state file skips silently instead of flagging everything" {
# bin/dejima's cleanup() runs on every exit, including one before
# `snapshot` ever ran (e.g. a startup abort) - its mktemp'd state file
# exists but is empty in that case, same as $STATE here before snap() is
# ever called. Compare must treat that the same as a missing file, not
# diff real content against nothing and manufacture a finding for
# everything already present. This is the routine case on every
# pre-snapshot fatal exit, so it must stay silent - the "skipping"
# message would otherwise bury the real error that was the operator's
# last line.
git -C "$WS" config core.hooksPath /tmp/evil
run cmp_
[ "$status" -eq 0 ]
[ "$output" = "" ]
}
@test "compare against a genuinely missing state file path prints a skip notice" {
git -C "$WS" config core.hooksPath /tmp/evil
run "$AUDIT" compare "$WS" "$STATE.does-not-exist" 2>&1
[ "$status" -eq 0 ]
[[ "$output" == *"no snapshot to compare against, skipping"* ]]
[[ "$output" != *"core.hooksPath"* ]]
}
@test "flags a newly-added core.hooksPath" {
snap
git -C "$WS" config core.hooksPath /tmp/evil
@@ -91,7 +117,7 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
echo '{}' > "$WS/.mcp.json"; snap
echo '{"mcpServers":{}}' > "$WS/.mcp.json"
run cmp_
count="$(printf '%s\n' "$output" | grep -c 'changed workspace file:.*\.mcp\.json')"
count="$(printf '%s\n' "$output" | grep -c 'workspace file:.*\.mcp\.json')"
[ "$count" -eq 1 ]
}
@@ -202,14 +228,16 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
[[ "$output" == *"statusline.sh"* ]]
}
@test "flags the confirmed exploit: a symlink statusline.sh swapped for a regular file" {
# This is the CONFIRMED Finding-1 gap: bin/dejima's read-only
# mount onto ~/.claude/statusline.sh does not reliably survive when
# statusline.sh is a symlink (the common case, e.g. into a dotfiles
# checkout) - runc resolves the bind-mount destination through the
# symlink, leaving the symlink dentry itself on the writable ~/.claude
# mount and still replaceable (confirmed against a live container - see
# test/runtime.bats). Detection here is the compensating control.
@test "flags the once-confirmed exploit: a symlink statusline.sh swapped for a regular file" {
# This was the CONFIRMED Finding-1 gap: statusline.sh was mounted
# read-only onto its own path inside a read-write ~/.claude mount, and
# runc resolved the bind-mount destination through the symlink (the
# common case, e.g. into a config checkout elsewhere), leaving the
# symlink dentry itself writable and replaceable. Staging the file for
# entrypoint.sh to copy in closed it (see test/runtime.bats), so
# detection is no longer the only control - but a session reaching the
# host statusline by some other route is exactly what an exit-time audit
# is for, so this stays.
local target_dir; target_dir="$(mktemp -d)"
printf '#!/bin/sh\necho original\n' > "$target_dir/statusline.sh"
ln -s "$target_dir/statusline.sh" "$DEJIMA_AUDIT_STATUSLINE"
@@ -241,3 +269,695 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
[ "$status" -eq 0 ]
[ "$output" = "" ]
}
@test "flags .envrc as host code execution, not a plain file change" {
snap
echo 'export X=1' > "$WS/.envrc"
run cmp_
[[ "$output" == *"HOST-CODE-EXECUTION"* ]]
[[ "$output" == *".envrc"* ]]
}
@test "flags .claude/settings.local.json as host code execution" {
snap
mkdir -p "$WS/.claude"; echo '{}' > "$WS/.claude/settings.local.json"
run cmp_
[[ "$output" == *"HOST-CODE-EXECUTION"* ]]
}
@test "flags .mcp.json as host code execution" {
echo '{}' > "$WS/.mcp.json"; snap
echo '{"a":1}' > "$WS/.mcp.json"
run cmp_
[[ "$output" == *"HOST-CODE-EXECUTION"* ]]
}
@test "flags .vscode/tasks.json as host code execution" {
snap
mkdir -p "$WS/.vscode"; echo '{}' > "$WS/.vscode/tasks.json"
run cmp_
[[ "$output" == *"HOST-CODE-EXECUTION"* ]]
}
@test "does not flag an ordinary .claude file as host code execution" {
snap
mkdir -p "$WS/.claude"; echo 'notes' > "$WS/.claude/CLAUDE.md"
run cmp_
[[ "$output" == *"CLAUDE.md"* ]]
[[ "$output" != *"HOST-CODE-EXECUTION"* ]]
}
@test "prints CRITICAL findings after WARNING findings" {
snap
echo 'export X=1' > "$WS/.envrc" # CRITICAL
mkdir -p "$WS/.vscode"; echo '{}' > "$WS/.vscode/settings.json" # WARNING
run cmp_
crit_line=$(printf '%s\n' "$output" | grep -n "HOST-CODE-EXECUTION" | head -1 | cut -d: -f1)
warn_line=$(printf '%s\n' "$output" | grep -n "settings.json" | head -1 | cut -d: -f1)
[ "$crit_line" -gt "$warn_line" ]
}
@test "lists a WARNING-tier file without marking it as host code execution" {
snap
mkdir -p "$WS/.vscode"; echo '{}' > "$WS/.vscode/settings.json"
run cmp_
[[ "$output" == *".vscode/settings.json"* ]]
[[ "$output" != *"HOST-CODE-EXECUTION"*".vscode/settings.json"* ]]
}
@test "caps the CRITICAL block at 10 entries" {
mkdir -p "$WS/.git/hooks"; snap
for i in $(seq 1 40); do echo "#!/bin/sh" > "$WS/.git/hooks/h$i"; done
run cmp_
[ "$(printf '%s\n' "$output" | grep -c '^ ! ')" -le 10 ]
}
@test "overflow past the CRITICAL cap prints below the list and reads as an alarm" {
mkdir -p "$WS/.git/hooks"; snap
for i in $(seq 1 40); do echo "#!/bin/sh" > "$WS/.git/hooks/h$i"; done
run cmp_
[[ "$output" == *"ABNORMAL"* ]]
last_crit=$(printf '%s\n' "$output" | grep -n '^ ! ' | tail -1 | cut -d: -f1)
overflow=$(printf '%s\n' "$output" | grep -n "ABNORMAL" | head -1 | cut -d: -f1)
[ "$overflow" -gt "$last_crit" ]
}
@test "caps the WARNING block at 5 entries" {
mkdir -p "$WS/.vscode"; snap
for i in $(seq 1 20); do echo x > "$WS/.vscode/f$i"; done
run cmp_
[ "$(printf '%s\n' "$output" | grep -c '^ - ')" -le 5 ]
}
@test "overflow past the WARNING cap prints a counted line below the list" {
mkdir -p "$WS/.vscode"; snap
for i in $(seq 1 20); do echo x > "$WS/.vscode/f$i"; done
run cmp_
[[ "$output" == *"further change(s) not shown."* ]]
last_warn=$(printf '%s\n' "$output" | grep -n '^ - ' | tail -1 | cut -d: -f1)
overflow=$(printf '%s\n' "$output" | grep -n '^ + ' | head -1 | cut -d: -f1)
[ "$overflow" -gt "$last_warn" ]
}
@test "the terminal header states what changed while the session ran, not what the session changed" {
# The diff covers the whole session window regardless of source
# (concurrent sessions, the operator's own edits, background tools),
# so the header must not claim the session itself made the change.
snap
echo 'export X=1' > "$WS/.envrc"
run cmp_
[[ "$output" == *"host-persistence surfaces changed in"* ]]
[[ "$output" == *"while this session ran"* ]]
[[ "$output" != *"this session changed"* ]]
}
@test "the report file explains that concurrent sessions and operator edits land in the same diff" {
snap
mkdir -p "$WS/.claude"; echo 'x' > "$WS/.claude/CLAUDE.md"
cmp_ >/dev/null 2>&1
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
run bash -c "cat '$AUDITDIR/$slug'/*.txt"
[[ "$output" == *"not necessarily because"* ]]
[[ "$output" == *"concurrent"* ]]
}
@test "the last line of the report is the verdict, not a border" {
snap
echo 'export X=1' > "$WS/.envrc"
run cmp_
last=$(printf '%s\n' "$output" | grep -v '^$' | tail -1)
[[ "$last" != *"====="* ]]
[[ "$last" == *"dejima:"* ]]
[[ "$last" == *"1 CRITICAL"* ]]
}
@test "the verdict line reports no critical findings when only WARNING-tier changes exist" {
snap
mkdir -p "$WS/.vscode"; echo '{}' > "$WS/.vscode/settings.json"
run cmp_
last=$(printf '%s\n' "$output" | grep -v '^$' | tail -1)
[[ "$last" != *"====="* ]]
[[ "$last" == *"dejima:"* ]]
[[ "$last" == *"no critical findings"* ]]
}
@test "strips terminal escapes from a planted config value" {
snap
git -C "$WS" config core.pager "$(printf 'less\033[2Jgotcha')"
run cmp_
[[ "$output" != *$'\033'* ]]
[[ "$output" == *"core.pager"* ]]
}
@test "keeps terminal escapes in a planted path out of the report" {
snap
mkdir -p "$WS/.claude"
touch "$WS/.claude/$(printf 'evil\033[2Jfile')"
run cmp_
[[ "$output" != *$'\033'* ]]
[[ "$output" == *"{hex}"* ]]
}
@test "keeps a bidi override in a planted path from visually reversing the report" {
snap
mkdir -p "$WS/.claude"
touch "$WS/.claude/$(printf 'evil%sfile' $'\xe2\x80\xae')"
run cmp_
[[ "$output" != *$'\xe2\x80\xae'* ]]
# Encoded, not silently stripped: rendering this as ".claude/evilfile"
# would send the operator grepping for a name no file on disk has, and
# is the id `ack` used to record - one that could never match again.
[[ "$output" != *".claude/evilfile"* ]]
[[ "$output" == *"{hex}2e636c617564652f6576696ce280ae66696c65"* ]]
}
@test "a path whose real bytes contain a bidi override can be acknowledged and stays acknowledged" {
# The round trip this encoding exists for. compare wrote sanitized text,
# ack derived the baseline id from it, and the next compare matched that
# id against the RAW bytes on disk - so this path could never be
# acknowledged, its report never cleared, and the startup warning fired
# on it forever.
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
mkdir -p "$WS/.claude"
snap
printf 'x' > "$WS/.claude/$(printf 'evil%sfile' $'\xe2\x80\xae')"
cmp_ >/dev/null 2>&1
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[ "$status" -eq 0 ]
[[ "$output" == *"recorded 1 acknowledgement"* ]]
[[ "$output" != *"no longer exists"* ]]
grep -q '^safe {hex}2e636c617564652f6576696ce280ae66696c65 ' "$BASEDIR/$slug"
# Same unchanged state file, so the same finding is rediscovered - and
# is now suppressed by what ack recorded.
run cmp_
[ "$output" = "" ]
}
@test "a TAB in a monitored path does not truncate it in the snapshot record" {
# The snapshot record is path<TAB>sha, read back with `cut -f1`, so a TAB
# in a filename used to truncate the path there - upstream of any
# encoding. ".git/hooks/pre<TAB>commit" was reported as ".git/hooks/pre":
# still CRITICAL (it is still under .git/hooks/), still naming a file
# that does not exist, so still unackable and nagging forever. Encoding
# the path into that record closes it, because an encoded path can
# contain neither TAB nor newline.
snap
printf 'x' > "$WS/.git/hooks/$(printf 'pre\tcommit')"
run cmp_
[[ "$output" != *".git/hooks/pre"$'\t'* ]]
[[ "$output" != *"! HOST-CODE-EXECUTION workspace file: .git/hooks/pre"* ]]
[[ "$output" == *"{hex}2e6769742f686f6f6b732f70726509636f6d6d6974"* ]]
# ...and it can actually be acknowledged, which was the whole point
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[ "$status" -eq 0 ]
[[ "$output" == *"recorded 1 acknowledgement"* ]]
[[ "$output" != *"no longer exists"* ]]
run cmp_
[ "$output" = "" ]
}
@test "a file whose name merely contains the hex marker is left alone and still round-trips" {
# The marker is only meaningful at the START of an id, so this one is
# display-safe and is passed through verbatim - the encoding must not
# reach for it here, and dec_id must not mistake it for encoded text.
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
mkdir -p "$WS/.claude"
snap
printf 'x' > "$WS/.claude/{hex}6576696c"
run cmp_
[[ "$output" == *".claude/{hex}6576696c"* ]]
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[ "$status" -eq 0 ]
[[ "$output" == *"recorded 1 acknowledgement"* ]]
grep -q '^safe \.claude/{hex}6576696c ' "$BASEDIR/$slug"
run cmp_
[ "$output" = "" ]
}
@test "an id that starts with the hex marker is encoded rather than passed through" {
# Otherwise a workspace could hand `ack` an id that decodes to one of its
# choosing - baselining something other than the finding while still
# counting the finding acknowledged. No monitored path can reach this
# (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.
snap
printf '[{hex}6576696c]\n\tkey = v\n' >> "$WS/.git/config"
run cmp_
[[ "$output" != *"{hex}6576696c.key"* ]] # not passed through verbatim...
[[ "$output" == *"{hex}7b6865787d36353736363936632e6b6579"* ]] # ...encoded
}
@test "writes a timestamped report file containing the full finding list" {
snap
mkdir -p "$WS/.claude"; echo 'x' > "$WS/.claude/CLAUDE.md"
cmp_ >/dev/null 2>&1
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
run bash -c "cat '$AUDITDIR/$slug'/*.txt"
[[ "$output" == *"CLAUDE.md"* ]]
}
@test "names a report carrying critical findings so rotation can spare it" {
snap
echo 'export X=1' > "$WS/.envrc"
cmp_ >/dev/null 2>&1
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
run bash -c "ls '$AUDITDIR/$slug'"
[[ "$output" == *"-CRITICAL.txt"* ]]
}
@test "rotation keeps DEJIMA_AUDIT_KEEP clean reports" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
mkdir -p "$AUDITDIR/$slug"
for i in $(seq 1 12); do
touch "$AUDITDIR/$slug/2026-01-01-0000$i.txt"
done
snap; git -C "$WS" config user.name changed
DEJIMA_AUDIT_KEEP=10 cmp_ >/dev/null 2>&1
run bash -c "ls '$AUDITDIR/$slug' | wc -l"
[ "$output" -le 10 ]
}
@test "rotation never evicts an unacknowledged CRITICAL report on age" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
mkdir -p "$AUDITDIR/$slug"
touch "$AUDITDIR/$slug/2020-01-01-000000-CRITICAL.txt"
for i in $(seq 1 12); do touch "$AUDITDIR/$slug/2026-01-01-0000$i.txt"; done
snap; git -C "$WS" config user.name changed
DEJIMA_AUDIT_KEEP=10 cmp_ >/dev/null 2>&1
# rotation actually ran: 12 planted clean reports + this run's own, trimmed to KEEP
clean_n="$(find "$AUDITDIR/$slug" -maxdepth 1 -name '*.txt' ! -name '*-CRITICAL.txt' | wc -l)"
[ "$clean_n" -le 10 ]
# ...and the unacknowledged CRITICAL report survived it
[ -f "$AUDITDIR/$slug/2020-01-01-000000-CRITICAL.txt" ]
}
@test "rotation gives up instead of spinning forever when the report dir is unwritable" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
mkdir -p "$AUDITDIR/$slug"
for i in $(seq 1 12); do touch "$AUDITDIR/$slug/2026-01-01-0000$i.txt"; done
snap; git -C "$WS" config user.name changed
chmod 500 "$AUDITDIR/$slug"
run timeout 5 env DEJIMA_AUDIT_KEEP=10 "$AUDIT" compare "$WS" "$STATE"
result="$status"
chmod 700 "$AUDITDIR/$slug"
[ "$result" -ne 124 ]
}
@test "show prints the only report there is, and names it" {
snap
echo 'export X=1' > "$WS/.envrc"
cmp_ >/dev/null 2>&1
run bash -c "'$AUDIT' show '$WS' 2>&1"
[ "$status" -eq 0 ]
[[ "$output" == *".envrc"* ]]
[[ "$output" == *"-CRITICAL.txt"* ]] # said which report it printed
}
@test "show and ack agree on which report they mean" {
# `show` selected the newest report overall while `ack` preferred the
# oldest unacknowledged CRITICAL one, so an operator who followed the
# startup warning to `show` first was handed a different report than the
# one being warned about - and went looking for a finding not in it.
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
report_dir="$AUDITDIR/$slug"; mkdir -p "$report_dir"
cat > "$report_dir/2020-01-01-000000-CRITICAL.txt" <<REPORT
dejima audit report for $WS
CRITICAL HOST-CODE-EXECUTION workspace file: .envrc
REPORT
cat > "$report_dir/2026-01-01-000001.txt" <<REPORT
dejima audit report for $WS
WARNING changed workspace file: .vscode/settings.json
REPORT
run "$AUDIT" show "$WS"
[ "$status" -eq 0 ]
[[ "$output" == *".envrc"* ]] # the stale CRITICAL report...
[[ "$output" != *"settings.json"* ]] # ...not the newer clean one
}
@test "show falls back to the newest report when no CRITICAL one is pending" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
report_dir="$AUDITDIR/$slug"; mkdir -p "$report_dir"
echo 'older' > "$report_dir/2020-01-01-000000.txt"
echo 'newer' > "$report_dir/2026-01-01-000001.txt"
run "$AUDIT" show "$WS"
[ "$status" -eq 0 ]
[[ "$output" == *"newer"* ]]
[[ "$output" != *"older"* ]]
}
@test "ack presents a stale unacknowledged CRITICAL report before a newer clean one" {
# IMPORTANT 3: `ack` used to pick the newest report overall (sort | tail
# -1), so a stale *-CRITICAL.txt sitting behind a newer clean report was
# unreachable through `ack` at all - the operator has no remedy but to
# keep pressing [c] past the startup warning, which is exactly the
# failure this feature exists to eliminate.
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
echo 'export X=1' > "$WS/.envrc" # real content, so the approval succeeds
report_dir="$AUDITDIR/$slug"; mkdir -p "$report_dir"
cat > "$report_dir/2020-01-01-000000-CRITICAL.txt" <<REPORT
dejima audit report for $WS
CRITICAL HOST-CODE-EXECUTION workspace file: .envrc
REPORT
cat > "$report_dir/2026-01-01-000001.txt" <<REPORT
dejima audit report for $WS
WARNING changed workspace file: .vscode/settings.json
REPORT
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[ "$status" -eq 0 ]
[[ "$output" == *".envrc"* ]] # presented the stale CRITICAL report...
[[ "$output" != *"settings.json"* ]] # ...not the newer clean one
grep -q '^safe \.envrc ' "$BASEDIR/$slug"
[ ! -f "$report_dir/2020-01-01-000000-CRITICAL.txt" ] # cleared: suffix dropped
[ -f "$report_dir/2020-01-01-000000.txt" ]
[ -f "$report_dir/2026-01-01-000001.txt" ] # the newer report is untouched
}
@test "a spam-listed path is not listed in the report" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
mkdir -p "$WS/.claude"; echo 'a' > "$WS/.claude/CLAUDE.md"; snap
echo "spam .claude/CLAUDE.md" > "$BASEDIR/$slug"
echo 'b' > "$WS/.claude/CLAUDE.md"
run cmp_
[[ "$output" != *"CLAUDE.md"* ]]
}
@test "a safe-listed path with a matching hash is suppressed" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap
echo 'export X=1' > "$WS/.envrc"
sha="$(sha256sum "$WS/.envrc" | cut -d' ' -f1)"
echo "safe .envrc $sha" > "$BASEDIR/$slug"
run cmp_
[[ "$output" != *".envrc"* ]]
}
@test "a safe-listed path re-fires at full severity when its hash changes" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap
echo 'export X=1' > "$WS/.envrc"
echo "safe .envrc 0000000000000000000000000000000000000000000000000000000000000000" > "$BASEDIR/$slug"
run cmp_
[[ "$output" == *"HOST-CODE-EXECUTION"* ]]
[[ "$output" == *".envrc"* ]]
}
@test "a hand-written spam entry cannot silence a CRITICAL surface" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap
echo "spam .envrc" > "$BASEDIR/$slug"
echo 'export X=1' > "$WS/.envrc"
run cmp_
[[ "$output" == *"HOST-CODE-EXECUTION"* ]]
[[ "$output" == *".envrc"* ]]
}
@test "a baseline id containing a space does not suppress a different real path" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
mkdir -p "$WS/.vscode"
echo 'a' > "$WS/.vscode/settings.json"; snap
printf 'spam .vscode/settings.json old\n' > "$BASEDIR/$slug"
echo 'b' > "$WS/.vscode/settings.json"
run cmp_
[[ "$output" == *".vscode/settings.json"* ]]
}
@test "ack writes nothing to the baseline when the operator quits" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
run bash -c "printf 'q\n' | '$AUDIT' ack '$WS'"
[ "$status" -eq 0 ]
[[ "$output" == *".envrc"* ]] # ack ran and presented the finding
[[ "$output" == *"nothing recorded"* ]] # ...and reported that it wrote nothing
[ ! -f "$BASEDIR/$slug" ] # ...and in fact wrote nothing
}
@test "ack refuses to spam a CRITICAL surface" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap
echo 'export X=1' > "$WS/.envrc"
mkdir -p "$WS/.vscode"; echo '{}' > "$WS/.vscode/settings.json"
cmp_ >/dev/null 2>&1
# 's' on the CRITICAL .envrc finding, then 'a' on the WARNING one that
# follows it - so $BASEDIR/$slug is guaranteed to exist afterward and the
# negative grep below can't pass vacuously against a missing file.
run bash -c "printf 's\na\n' | '$AUDIT' ack '$WS'"
[[ "$output" == *"cannot 'always ignore' a CRITICAL surface - approve the exact content or skip."* ]]
[ -f "$BASEDIR/$slug" ]
! grep -q '^spam \.envrc' "$BASEDIR/$slug"
}
@test "ack records a safe entry and clears the CRITICAL report marker" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
grep -q '^safe .envrc' "$BASEDIR/$slug"
run bash -c "ls '$AUDITDIR/$slug'"
[[ "$output" != *"-CRITICAL.txt"* ]]
}
@test "ack does not write an unmatchable entry for a non-path finding" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap; git -C "$WS" config user.name changed; cmp_ >/dev/null 2>&1
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[[ "$output" == *"cannot"* ]]
[ ! -f "$BASEDIR/$slug" ]
}
@test "an ack-approved finding is suppressed on the next compare (id round-trips through the baseline)" {
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[ "$status" -eq 0 ]
[[ "$output" == *"recorded 1 acknowledgement"* ]] # ack ran and wrote the entry
run cmp_
[[ "$output" != *".envrc"* ]] # ...and it round-trips
}
@test "the CRITICAL suffix survives when a CRITICAL finding is skipped but a WARNING is approved" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap
echo 'export X=1' > "$WS/.envrc" # CRITICAL
mkdir -p "$WS/.vscode"; echo '{}' > "$WS/.vscode/settings.json" # WARNING
cmp_ >/dev/null 2>&1
# Report order is CRITICAL block then WARNING block, so the first key
# answers the .envrc CRITICAL finding and the second answers the
# .vscode/settings.json WARNING finding.
run bash -c "printf 'c\na\n' | '$AUDIT' ack '$WS'"
[ "$status" -eq 0 ]
[[ "$output" == *".envrc"* ]] # ack ran and presented the CRITICAL finding
[[ "$output" == *"settings.json"* ]] # ...and the WARNING finding
grep -q '^safe .vscode/settings.json' "$BASEDIR/$slug" # the approval was recorded
run bash -c "ls '$AUDITDIR/$slug'"
[[ "$output" == *"-CRITICAL.txt"* ]] # but the CRITICAL finding was skipped, not approved,
# so the report must stay eviction-protected
}
@test "ack refuses to approve a finding whose path no longer exists, and the CRITICAL suffix survives" {
# A since-deleted path has no content left to pin a `safe` approval to -
# writing one anyway (the old behaviour) produced a baseline entry
# baseline_suppresses() can never match (its own empty-sha guard rejects
# it as malformed), so the surface stayed permanently unsuppressible -
# while `a` still advanced crit_acked and let the report shed its
# -CRITICAL.txt protection for good. Refusing outright keeps both the
# baseline and the report's protection honest.
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
rm -f "$WS/.envrc" # gone by the time ack runs
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[ "$status" -eq 0 ]
[[ "$output" == *"cannot approve: .envrc no longer exists"* ]]
[[ "$output" == *"nothing recorded"* ]]
[ ! -f "$BASEDIR/$slug" ] # nothing written to the baseline at all
run bash -c "ls '$AUDITDIR/$slug'"
[[ "$output" == *"-CRITICAL.txt"* ]] # unacknowledged CRITICAL keeps its protection
}
@test "a workspace path containing its own ': ' cannot hijack ack's id derivation" {
# Trigger (a) of CRITICAL 1: id="${body##*: }" took everything after the
# LAST ": " in body, so a CRITICAL path containing a literal ": " (a
# perfectly legal filename byte sequence) yielded the wrong id - here the
# old code would derive "decoy" instead of the real path, so approving it
# would baseline an id that can never match the real finding while still
# letting the report shed -CRITICAL.txt.
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap
mkdir -p "$WS/.git/hooks"
printf '#!/bin/sh\necho pwned\n' > "$WS/.git/hooks/pre-commit: decoy"
run cmp_
[[ "$output" == *"HOST-CODE-EXECUTION"* ]]
[[ "$output" == *".git/hooks/pre-commit: decoy"* ]]
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[ "$status" -eq 0 ]
[[ "$output" == *"recorded 1 acknowledgement"* ]]
grep -q '^safe \.git/hooks/pre-commit: decoy ' "$BASEDIR/$slug"
! grep -q '^safe decoy ' "$BASEDIR/$slug"
run bash -c "ls '$AUDITDIR/$slug'"
[[ "$output" != *"-CRITICAL.txt"* ]] # correctly acked: suffix dropped
run cmp_
[[ "$output" != *"pre-commit: decoy"* ]] # ...and the id round-trips through baseline_suppresses
}
# --- Task 8b: every CRITICAL finding type must be acknowledgeable, not just
# path findings, or a report carrying a non-path CRITICAL (git config,
# statusline, TCB) can never clear its -CRITICAL.txt suffix. ---
@test "a safe-listed gitconfig id with a matching hash suppresses a command-execution finding" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap
git -C "$WS" config core.hooksPath /tmp/evil
sha="$(printf '%s' "/tmp/evil" | sha256sum | cut -d' ' -f1)"
echo "safe gitconfig:core.hooksPath $sha" > "$BASEDIR/$slug"
run cmp_
[ "$status" -eq 0 ]
[ "$output" = "" ]
}
@test "a safe-listed gitconfig id re-fires when the value changes" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap
git -C "$WS" config core.hooksPath /tmp/evil
echo "safe gitconfig:core.hooksPath 0000000000000000000000000000000000000000000000000000000000000000" > "$BASEDIR/$slug"
run cmp_
[[ "$output" == *"COMMAND-EXECUTION"* ]]
[[ "$output" == *"core.hooksPath"* ]]
}
@test "a hand-written spam gitconfig entry cannot silence a CRITICAL command-execution surface" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap
echo "spam gitconfig:core.hooksPath" > "$BASEDIR/$slug"
git -C "$WS" config core.hooksPath /tmp/evil
run cmp_
[[ "$output" == *"COMMAND-EXECUTION"* ]]
[[ "$output" == *"core.hooksPath"* ]]
}
@test "ack records a safe gitconfig entry whose id round-trips and suppresses the next compare" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap
git -C "$WS" config core.hooksPath /tmp/evil
cmp_ >/dev/null 2>&1
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[ "$status" -eq 0 ]
[[ "$output" == *"recorded 1 acknowledgement"* ]]
grep -q '^safe gitconfig:core.hooksPath ' "$BASEDIR/$slug"
run cmp_
[[ "$output" != *"core.hooksPath"* ]]
}
@test "ack records a safe gitconfig alias entry whose id round-trips and suppresses the next compare" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap
git -C "$WS" config alias.pwn '!touch /tmp/x'
cmp_ >/dev/null 2>&1
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[ "$status" -eq 0 ]
[[ "$output" == *"recorded 1 acknowledgement"* ]]
grep -q '^safe gitconfig:alias.pwn ' "$BASEDIR/$slug"
run cmp_
[[ "$output" != *"alias.pwn"* ]]
}
@test "a report whose only CRITICAL is a git-config finding can be fully acknowledged and loses the CRITICAL suffix" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap
git -C "$WS" config core.hooksPath /tmp/evil
cmp_ >/dev/null 2>&1
run bash -c "ls '$AUDITDIR/$slug'"
[[ "$output" == *"-CRITICAL.txt"* ]]
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[ "$status" -eq 0 ]
run bash -c "ls '$AUDITDIR/$slug'"
[[ "$output" != *"-CRITICAL.txt"* ]]
}
@test "a safe-listed statusline id with a matching hash suppresses a statusline finding" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
printf '#!/bin/sh\necho original\n' > "$DEJIMA_AUDIT_STATUSLINE"
snap
printf '#!/bin/sh\necho pwned\n' > "$DEJIMA_AUDIT_STATUSLINE"
sha="$(sha256sum "$DEJIMA_AUDIT_STATUSLINE" | cut -d' ' -f1)"
echo "safe statusline:statusline.sh $sha" > "$BASEDIR/$slug"
run cmp_
[ "$status" -eq 0 ]
[ "$output" = "" ]
}
@test "ack records a safe statusline entry whose id round-trips and suppresses the next compare" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
printf '#!/bin/sh\necho original\n' > "$DEJIMA_AUDIT_STATUSLINE"
snap
printf '#!/bin/sh\necho pwned\n' > "$DEJIMA_AUDIT_STATUSLINE"
cmp_ >/dev/null 2>&1
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[ "$status" -eq 0 ]
[[ "$output" == *"recorded 1 acknowledgement"* ]]
grep -q '^safe statusline:statusline.sh ' "$BASEDIR/$slug"
run cmp_
[ "$status" -eq 0 ]
[ "$output" = "" ]
}
@test "a safe-listed tcb id with a matching hash suppresses a TCB finding" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
mkdir -p "$WS/bin"; echo x > "$WS/bin/x"; echo x > "$WS/install.sh"
export DEJIMA_AUDIT_REPO_ROOT="$(readlink -f "$WS")"
snap
echo changed > "$WS/bin/x"
sha="$(sha256sum "$WS/bin/x" | cut -d' ' -f1)"
echo "safe tcb:bin/x $sha" > "$BASEDIR/$slug"
run cmp_
[[ "$output" != *"TCB"* ]]
}
@test "ack records a safe tcb entry whose id round-trips and suppresses the next compare" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
mkdir -p "$WS/bin"; echo x > "$WS/bin/x"; echo x > "$WS/install.sh"
export DEJIMA_AUDIT_REPO_ROOT="$(readlink -f "$WS")"
snap
echo changed > "$WS/bin/x"
cmp_ >/dev/null 2>&1
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[ "$status" -eq 0 ]
[[ "$output" == *"recorded 1 acknowledgement"* ]]
grep -q '^safe tcb:bin/x ' "$BASEDIR/$slug"
run cmp_
[[ "$output" != *"TCB"* ]]
}
@test "a crafted git config value cannot hijack ack's id derivation" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap
git -C "$WS" config core.hooksPath 'workspace file: /tmp/evil'
cmp_ >/dev/null 2>&1
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
grep -q '^safe gitconfig:core.hooksPath' "$BASEDIR/$slug"
! grep -q '/tmp/evil ' "$BASEDIR/$slug"
run bash -c "ls '$AUDITDIR/$slug'"
[[ "$output" != *"-CRITICAL.txt"* ]]
}
@test "a crafted git config value cannot hijack id derivation when the finding is skipped" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap
git -C "$WS" config core.hooksPath 'workspace file: /tmp/evil'
cmp_ >/dev/null 2>&1
run bash -c "printf 'c\n' | '$AUDIT' ack '$WS'"
run bash -c "ls '$AUDITDIR/$slug'"
[[ "$output" == *"-CRITICAL.txt"* ]]
}
@test "ack still refuses to spam a CRITICAL git-config surface" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap
git -C "$WS" config core.hooksPath /tmp/evil
mkdir -p "$WS/.vscode"; echo '{}' > "$WS/.vscode/settings.json"
cmp_ >/dev/null 2>&1
# 's' on the CRITICAL gitconfig finding, then 'a' on the WARNING one that
# follows it - so $BASEDIR/$slug is guaranteed to exist afterward and the
# negative grep below can't pass vacuously against a missing file.
run bash -c "printf 's\na\n' | '$AUDIT' ack '$WS'"
[[ "$output" == *"cannot 'always ignore' a CRITICAL surface - approve the exact content or skip."* ]]
[ -f "$BASEDIR/$slug" ]
! grep -q '^spam gitconfig:core.hooksPath' "$BASEDIR/$slug"
}
+5
View File
@@ -39,6 +39,11 @@ class Handler(BaseHTTPRequestHandler):
"method": self.command,
"path": self.path,
"headers": {k.lower(): v for k, v in self.headers.items()},
# The same headers unflattened: name as it arrived on the wire, in
# arrival order, repeats included. `headers` above answers "what
# value got through"; this answers "what did the block look like",
# which is what the proxy's wire-fidelity tests assert on.
"header_lines": [[k, v] for k, v in self.headers.items()],
"body": body.decode("utf-8", "replace"),
}).encode())
+48 -4
View File
@@ -162,8 +162,8 @@ EOF
}
@test "project_slug matches the log-shipper project-key rule" {
run bash -c 'source "$1"; project_slug "/home/brad/my-proj"' _ "$LIB"
[ "$output" = "-home-brad-my-proj" ]
run bash -c 'source "$1"; project_slug "/home/someone/my-proj"' _ "$LIB"
[ "$output" = "-home-someone-my-proj" ]
}
@test "ensure_project_store creates the store and records the real path" {
@@ -718,6 +718,25 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
[ ! -f "$LAUNCH_ENV" ]
}
@test "the launcher refuses to start on a settings.json that isn't valid JSON" {
# Claude Code ignores an unparseable settings.json wholesale, with only a
# startup warning, so the session would come up with no permissions, no
# hooks and no skipDangerousModePermissionPrompt - a bypass-mode warning
# and nothing pointing at the settings file. A stray character appended to
# the real file is exactly how this turned up.
launch_setup
printf '{"theme": "dark"}\nx\n' > "$LAUNCH_DIR/home/.claude/settings.json"
run launch "$PROJ"
[ "$status" -ne 0 ]
[[ "$output" == *"settings.json is not valid JSON"* ]]
[ ! -f "$LAUNCH_ENV" ]
printf '{"theme": "dark"}\n' > "$LAUNCH_DIR/home/.claude/settings.json"
run launch "$PROJ"
[ "$status" -eq 0 ]
[ -f "$LAUNCH_ENV" ]
}
@test "the launcher runs the derived image only when the project has approved dependencies" {
launch_setup
run launch "$PROJ"
@@ -784,12 +803,37 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
launch_setup
run launch "$PROJ"
[ "$status" -eq 0 ]
[[ "$output" != *"no longer out of reach"* ]]
[[ "$output" != *"writable from inside the sandbox"* ]]
# Same project, but with the store somewhere the session can rewrite it.
export DEJIMA_CONFIG_DIR="$PROJ/.config/dejima"
"$REPO_ROOT/bin/dejima-dep" add apt:libpq-dev --project "$PROJ" >/dev/null
run launch "$PROJ"
[ "$status" -eq 0 ]
[[ "$output" == *"no longer out of reach"* ]]
[[ "$output" == *"the approval store"* ]]
[[ "$output" == *"approve its own domains and dependencies"* ]]
}
@test "the launcher warns when the audit report or baseline store sits inside the mounted workspace" {
launch_setup
run launch "$PROJ"
[ "$status" -eq 0 ]
[[ "$output" != *"writable from inside the sandbox"* ]]
export DEJIMA_AUDIT_DIR="$PROJ/.audit"
run launch "$PROJ"
[ "$status" -eq 0 ]
[[ "$output" == *"the audit report store"* ]]
[[ "$output" == *"delete the exit-time reports of earlier runs"* ]]
# Right alarm, right consequence: these three stores used to share the
# approval store's text verbatim, so the audit store described a hazard
# that was not the one it has.
[[ "$output" != *"the approval store"* ]]
unset DEJIMA_AUDIT_DIR
export DEJIMA_AUDIT_BASELINE_DIR="$PROJ/.baselines"
run launch "$PROJ"
[ "$status" -eq 0 ]
[[ "$output" == *"the audit baseline store"* ]]
[[ "$output" == *"write its own acknowledgements"* ]]
}
+171
View File
@@ -68,6 +68,99 @@ teardown() {
[[ "$beta" == *"oauth-2025-04-20"* ]]
}
@test "the client's header block reaches upstream with its own casing and order" {
# The container half of this sandbox exists to make the session look like
# a stock install. If the proxy rewrote the request into CPython's house
# style on its way out, that fingerprint would simply move from the client
# to the wire, where the API sees it instead. Sent in a deliberately
# un-CPython shape: mixed casing, and a header after the one the proxy has
# to rewrite, so a rewrite that reorders shows up here.
start_proxy
run pcurl -H "Content-Type: application/json" -H "Anthropic-Version: 2023-06-01" \
-H "X-Stainless-Lang: js" -d '{}' http://proxy/v1/messages
[ "$status" -eq 0 ]
names="$(jq -r '.header_lines[][0]' <<<"$output")"
grep -qx 'Content-Type' <<<"$names"
grep -qx 'Anthropic-Version' <<<"$names"
grep -qx 'X-Stainless-Lang' <<<"$names"
# Position, not just presence: Authorization keeps the slot curl gave it,
# ahead of the three above, rather than being appended after them.
auth_at="$(jq -r '[.header_lines[][0] | ascii_downcase] | index("authorization")' <<<"$output")"
type_at="$(jq -r '[.header_lines[][0] | ascii_downcase] | index("content-type")' <<<"$output")"
[ "$auth_at" -lt "$type_at" ]
[ "$(jq -r '.headers.authorization' <<<"$output")" = "Bearer real-access-token" ]
}
@test "no Accept-Encoding is invented for a client that sent none" {
# http.client's putrequest() adds `Accept-Encoding: identity` whenever the
# caller passed none - a CPython tell that no browser or Node client
# sends. curl sends no accept-encoding unless asked, so upstream must see
# none at all.
start_proxy
run pcurl -d '{}' http://proxy/v1/messages
[ "$(jq -r '[.header_lines[][0] | ascii_downcase | select(. == "accept-encoding")] | length' <<<"$output")" = "0" ]
# ...and a client that does send one keeps its own value, unaltered.
run pcurl -H "Accept-Encoding: gzip, deflate" -d '{}' http://proxy/v1/messages
[ "$(jq -r '.headers["accept-encoding"]' <<<"$output")" = "gzip, deflate" ]
}
@test "repeated headers survive as repeats instead of collapsing to the last" {
# Rebuilding the block through a dict silently keeps only the last value.
start_proxy
run pcurl -H "X-Dup: one" -H "X-Dup: two" -d '{}' http://proxy/v1/messages
[ "$(jq -r '[.header_lines[] | select(.[0] | ascii_downcase == "x-dup") | .[1]] | join("|")' <<<"$output")" = "one|two" ]
}
@test "the injected token is sent once even when the client repeats its placeholder" {
# The replacement is per-header-name, not per-occurrence: a duplicated
# Authorization must not become two copies of the real token.
start_proxy
run curl -s --unix-socket "$SOCK" -H "Authorization: Bearer $PLACEHOLDER" \
-H "Authorization: Bearer $PLACEHOLDER" -d '{}' http://proxy/v1/messages
[ "$(jq -r '[.header_lines[] | select(.[0] | ascii_downcase == "authorization")] | length' <<<"$output")" = "1" ]
[ "$(jq -r '.headers.authorization' <<<"$output")" = "Bearer real-access-token" ]
[[ "$output" != *"$PLACEHOLDER"* ]]
}
@test "an anthropic-beta that already carries the OAuth value is forwarded untouched" {
# Re-joining a list that needs no change would normalize the client's
# spacing - the same class of tell as the casing. This is the common case
# in the tls transport, where Claude Code sends the header itself.
start_proxy
run pcurl -H "anthropic-beta: oauth-2025-04-20, context-1m-2025-08-07" \
-d '{}' http://proxy/v1/messages
[ "$(jq -r '.headers["anthropic-beta"]' <<<"$output")" = "oauth-2025-04-20, context-1m-2025-08-07" ]
}
@test "a bodyless request gains no Content-Length the client never sent" {
start_proxy
run pcurl http://proxy/v1/models
[ "$(jq -r '[.header_lines[][0] | ascii_downcase | select(. == "content-length")] | length' <<<"$output")" = "0" ]
}
@test "a chunked request is de-framed into a Content-Length upstream" {
# The proxy has to buffer to inject the token, so chunked framing cannot
# survive - but the length it declares must match what it forwards, and
# the hop-by-hop Transfer-Encoding must not travel with it.
start_proxy
run curl -s --unix-socket "$SOCK" -H "Transfer-Encoding: chunked" \
-H "Authorization: Bearer $PLACEHOLDER" \
-d '{"hello":1}' http://proxy/v1/messages
[ "$(jq -r '.body' <<<"$output")" = '{"hello":1}' ]
[ "$(jq -r '.headers["content-length"]' <<<"$output")" = "11" ]
[ "$(jq -r '.headers["transfer-encoding"] // "absent"' <<<"$output")" = "absent" ]
}
@test "Host is rewritten for the upstream, in place" {
# It must name the upstream, not the socket the container reached - but it
# is replaced where the client put it rather than dropped and re-added,
# which is what keeps the rest of the block in order.
start_proxy
run pcurl -d '{}' http://proxy/v1/messages
[ "$(jq -r '.headers.host' <<<"$output")" = "127.0.0.1:$PORT" ]
[ "$(jq -r '[.header_lines[][0] | ascii_downcase] | index("host")' <<<"$output")" = "0" ]
}
@test "passes upstream status and body through unchanged" {
start_proxy
run bash -c 'curl -s -o "$1/body" -w "%{http_code}" --unix-socket "$2" http://proxy/status-503' _ "$TD" "$SOCK"
@@ -184,6 +277,7 @@ teardown() {
@test "forwarder bridges TCP to the proxy's unix socket" {
start_proxy
SANDBOX_ANTHROPIC_PROXY_SOCKET="$SOCK" SANDBOX_ANTHROPIC_PROXY_PORT=18399 \
SANDBOX_ANTHROPIC_TRANSPORT=loopback \
node "$REPO_ROOT/docker/anthropic-proxy-forwarder.js" &
FWD_PID=$!
ok=""
@@ -242,3 +336,80 @@ PYEOF
run cat "$TD/stderr"
[[ "$output" != *"501"* ]]
}
# The forwarder is the container-side half of the chain. In tls mode it
# terminates TLS for api.anthropic.com on loopback so Claude Code needs no
# ANTHROPIC_BASE_URL at all, then pipes raw bytes into the same unix socket
# the loopback mode uses - the host proxy cannot tell the two apart.
start_forwarder() { # start_forwarder <transport> <port>; extra env passes through
SANDBOX_ANTHROPIC_PROXY_SOCKET="$SOCK" \
SANDBOX_ANTHROPIC_TRANSPORT="$1" \
SANDBOX_ANTHROPIC_PROXY_PORT="$2" \
SANDBOX_ANTHROPIC_TLS_CERT="$TD/cert.pem" \
SANDBOX_ANTHROPIC_TLS_KEY="$TD/key.pem" \
node "$REPO_ROOT/docker/anthropic-proxy-forwarder.js" 2>"$TD/fwd-stderr" &
FWD_PID=$!
for _ in $(seq 1 50); do
(exec 3<>"/dev/tcp/127.0.0.1/$2") 2>/dev/null && return 0
sleep 0.1
done
return 1
}
make_cert() {
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
-keyout "$TD/key.pem" -out "$TD/cert.pem" \
-subj "/CN=api.anthropic.com" \
-addext "subjectAltName=DNS:api.anthropic.com" >/dev/null 2>&1
}
@test "forwarder tls mode: https to api.anthropic.com reaches the proxy, which injects the real token" {
make_cert
start_proxy
start_forwarder tls 18443
# --resolve is what stands in for the container's /etc/hosts entry: the
# name must really be api.anthropic.com for certificate verification to
# be a meaningful check, not a hostname the test picked to be easy.
run curl -s --cacert "$TD/cert.pem" \
--resolve "api.anthropic.com:18443:127.0.0.1" \
-X POST "https://api.anthropic.com:18443/v1/messages" -d '{}'
kill "$FWD_PID" 2>/dev/null || true
[ "$status" -eq 0 ]
[[ "$output" == *'"authorization": "Bearer real-access-token"'* ]]
# No Authorization header was sent at all - the proxy supplied it. This is
# the auth fingerprint closing: no placeholder token in the container.
[[ "$output" != *"$PLACEHOLDER"* ]]
}
@test "forwarder tls mode pins ALPN to http/1.1 so the client cannot negotiate h2" {
# The upstream is a Python http.server, which speaks only HTTP/1.1: an h2
# negotiation would deliver frames it cannot parse, and the byte pipe has
# no HTTP awareness to catch it.
make_cert
start_proxy
start_forwarder tls 18444
run openssl s_client -connect 127.0.0.1:18444 -alpn h2,http/1.1 \
-servername api.anthropic.com -CAfile "$TD/cert.pem" </dev/null
kill "$FWD_PID" 2>/dev/null || true
[[ "$output" == *"ALPN protocol: http/1.1"* ]]
}
@test "forwarder loopback mode is unchanged: plaintext http on the old port" {
start_proxy
start_forwarder loopback 18445
run curl -s -X POST "http://127.0.0.1:18445/v1/messages" -d '{}'
kill "$FWD_PID" 2>/dev/null || true
[ "$status" -eq 0 ]
[[ "$output" == *'"authorization": "Bearer real-access-token"'* ]]
}
@test "forwarder tls mode refuses to start without a cert, rather than silently downgrading" {
start_proxy
# `run env ...`, not a `VAR=x run ...` prefix: an assignment prefixed to a
# shell *function* persists in the calling shell rather than scoping to
# the call, so it would leak into every later test in this file.
run env SANDBOX_ANTHROPIC_PROXY_SOCKET="$SOCK" SANDBOX_ANTHROPIC_TRANSPORT=tls \
node "$REPO_ROOT/docker/anthropic-proxy-forwarder.js"
[ "$status" -ne 0 ]
[[ "$output" == *"SANDBOX_ANTHROPIC_TLS_CERT"* ]]
}
+2684
View File
File diff suppressed because it is too large Load Diff
+364 -80
View File
@@ -73,12 +73,25 @@ setup_file() {
docker network rm "$network" >/dev/null 2>&1 || true
docker network create "$network" >/dev/null
# No GITEA_*/GH_TOKEN/LOKI_URL: this suite only asserts the default
# allowlist and enforcement, which shouldn't depend on any of them.
# No GITEA_*/LOKI_URL: this suite only asserts the default allowlist and
# enforcement, which shouldn't depend on either.
#
# GH_TOKEN is forwarded rather than dropped, and every container start in
# this file does the same. It doesn't change the allowlist - it only
# authenticates init-firewall.sh's api.github.com/meta fetch, which
# returns the same ranges either way. What it changes is the quota: that
# endpoint allows 60 requests an hour per source IP unauthenticated, and
# this file starts sixteen containers, so two runs in an hour exhaust it
# and every container after that dies in init-firewall.sh with a rate
# limit error rather than anything to do with what was being tested.
# Locally it's usually unset and expands empty, which both init-firewall
# and entrypoint treat exactly as absent - so the no-token path stays the
# default here and only CI, where the volume is, passes a real one.
docker run -d --name "$container" \
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$network" \
-e SANDBOX_TEST_MODE=1 \
-e GH_TOKEN="${GH_TOKEN:-}" \
-e ANTHROPIC_BASE_URL= \
-e ANTHROPIC_AUTH_TOKEN= \
--entrypoint /usr/local/bin/entrypoint.sh \
@@ -93,6 +106,19 @@ teardown_file() {
}
teardown() {
# Host listeners started by start_host_listener (unset for every other
# test - each test re-sources this file in its own shell, so nothing
# leaks in from the previous one). Killing them here rather than at the
# end of the test body is what makes the teardown unconditional: bats
# runs test bodies under errexit, so any failed assertion abandons the
# rest of the body, and a kill sitting there would leave a python
# process holding a port on the operator's host. teardown runs in the
# same shell as the body even after the body aborted, so the PIDs it
# set are visible here. An EXIT trap in the body is not the
# alternative - it replaces the one bats installs and the run dies
# reporting zero executed tests.
local pid
for pid in ${HOST_LISTENER_PIDS:-}; do kill "$pid" 2>/dev/null || true; done
docker rm -f "dejima-test-override-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rmi "dejima-test-override:$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-sshkey-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
@@ -107,6 +133,9 @@ teardown() {
docker rm -f "dejima-test-derived-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rmi "dejima-test-derived:$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-ctxreq-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-tls-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-nohosts-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-badtransport-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
}
dexec() {
@@ -134,6 +163,7 @@ dexec_node() {
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
-e SANDBOX_TEST_MODE=1 \
-e GH_TOKEN="${GH_TOKEN:-}" \
--entrypoint /usr/local/bin/entrypoint.sh \
"$test_image" >/dev/null
@@ -161,6 +191,7 @@ dexec_node() {
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
-e SANDBOX_TEST_MODE=1 \
-e GH_TOKEN="${GH_TOKEN:-}" \
-v "$key_dir/testkey:/root/.ssh-sandbox-key-src:ro" \
--entrypoint /usr/local/bin/entrypoint.sh \
"$IMAGE" >/dev/null
@@ -215,6 +246,7 @@ dexec_node() {
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
-e SANDBOX_TEST_MODE=1 \
-e GH_TOKEN="${GH_TOKEN:-}" \
-v "$mounted_copy:/home/node/.claude.json" \
--entrypoint /usr/local/bin/entrypoint.sh \
"$IMAGE" >/dev/null
@@ -248,6 +280,17 @@ dexec_node() {
}
@test "an allowlisted domain resolves via the DNS proxy" {
run dexec dig +short registry.npmjs.org
[ -n "$output" ]
}
@test "api.anthropic.com resolves in proxy-off mode (kill switch / API-key auth)" {
# api.anthropic.com is left off the allowlist when the credential proxy is
# on, so a bypass of the /etc/hosts pin that resolves the name by DNS gets
# no address. When the proxy is off (kill switch or API-key auth), DNS is
# the only path to the API and fixed-domains.sh allowlists it. This file's
# shared container runs with no SANDBOX_ANTHROPIC_PROXY_SOCKET, so it is
# in proxy-off mode and the name must resolve.
run dexec dig +short api.anthropic.com
[ -n "$output" ]
}
@@ -273,6 +316,7 @@ dexec_node() {
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
-e SANDBOX_TEST_MODE=1 \
-e GH_TOKEN="${GH_TOKEN:-}" \
-e ALLOW_GITHUB_PUSH=1 \
--entrypoint /usr/local/bin/entrypoint.sh \
"$IMAGE" >/dev/null
@@ -291,6 +335,7 @@ dexec_node() {
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
-e SANDBOX_TEST_MODE=1 \
-e GH_TOKEN="${GH_TOKEN:-}" \
-e GITEA_SSH_HOST=gitea.internal.example \
--entrypoint /usr/local/bin/entrypoint.sh \
"$IMAGE" >/dev/null
@@ -318,6 +363,7 @@ dexec_node() {
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
-e SANDBOX_TEST_MODE=1 \
-e GH_TOKEN="${GH_TOKEN:-}" \
-v "$src/skills:/home/node/.claude/skills:ro" \
-v "$src/commands:/home/node/.claude/commands:ro" \
-v "$src/agents:/home/node/.claude/agents:ro" \
@@ -333,81 +379,75 @@ dexec_node() {
rm -rf "$src"
}
@test "statusline.sh read-only mount blocks replacement when it's a plain regular file" {
# Companion to the overlay-dirs test above, but for bin/dejima's
# narrower statusline.sh-onto-itself mount (see its comment there) rather
# than a whole subdirectory. This test does NOT use bin/dejima or
# docker-compose.yml directly - it builds the same mount shape by hand: a
# whole ~/.claude directory bind-mounted read-write (docker-compose.yml's
# own ~/.claude mount), with statusline.sh itself bind-mounted read-only
# back onto its own path on top, source and destination the same literal
# path exactly as bin/dejima constructs it (container HOME
# matches host HOME - see Dockerfile). This shared container isn't reused
# for this (it has no ~/.claude mount at all - see setup_file).
@test "a staged statusline.sh is copied in root-owned, and replacing it never reaches the host" {
# Companion to the overlay-dirs test above, but for the config
# bin/dejima stages at /etc/dejima-claude for entrypoint.sh to copy into
# ~/.claude (see its comment there) rather than mounting at its real
# path. This test does NOT use bin/dejima or docker-compose.yml directly
# - it builds the same shape by hand. This shared container isn't reused
# for it (it has no staging mount at all - see setup_file).
local test_container="dejima-test-statusline-regular-$(basename "$BATS_FILE_TMPDIR")"
local dotclaude; dotclaude="$(mktemp -d)"
printf '#!/bin/sh\necho original\n' > "$dotclaude/statusline.sh"
chmod +x "$dotclaude/statusline.sh"
# mktemp -d is mode 700, which only works because the uid running these
# tests is usually the same one the container's node maps to. Where it
# isn't - CI runs as root, node is 1000 - node cannot even traverse this
# directory. Writable, so that if the container's copy somehow did lead
# back here the write below would succeed and this test would say so.
chmod 777 "$dotclaude"
docker run -d --name "$test_container" \
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
-e SANDBOX_TEST_MODE=1 \
-v "$dotclaude:/home/node/.claude" \
-v "$dotclaude/statusline.sh:/home/node/.claude/statusline.sh:ro" \
-e GH_TOKEN="${GH_TOKEN:-}" \
-v "$dotclaude/statusline.sh:/etc/dejima-claude/statusline.sh:ro" \
--entrypoint /usr/local/bin/entrypoint.sh \
"$IMAGE" >/dev/null
wait_ready "$test_container"
# A `rm` on a bind-mounted file fails from ordinary mount-point-busy
# semantics regardless of the :ro flag, so `rm -f X && echo pwned > X`
# alone doesn't prove anything about read-only enforcement - the `&&`
# short-circuits before the write it was meant to test ever runs, so
# this would pass identically even without `:ro`. The write that
# actually depends on the read-only flag is an in-place overwrite that
# never unlinks the file first.
run docker exec -u node "$test_container" cat /home/node/.claude/statusline.sh
[[ "$output" == *"original"* ]]
run docker exec -u node "$test_container" stat -c '%U %a' /home/node/.claude/statusline.sh
[[ "$output" == "root 555" ]]
# An in-place overwrite fails on the mode alone. Unlinking and replacing
# it succeeds - ~/.claude belongs to the session - but the copy is the
# only thing that changes: nothing on the host is reachable from it.
run docker exec -u node "$test_container" sh -c \
'echo pwned > /home/node/.claude/statusline.sh'
[ "$status" -ne 0 ]
run docker exec -u node "$test_container" sh -c \
'truncate -s 0 /home/node/.claude/statusline.sh'
[ "$status" -ne 0 ]
'rm -f /home/node/.claude/statusline.sh && echo pwned > /home/node/.claude/statusline.sh'
[ "$status" -eq 0 ]
run docker exec -u node "$test_container" cat /home/node/.claude/statusline.sh
run cat "$dotclaude/statusline.sh"
[[ "$output" == *"original"* ]]
[[ "$output" != *"pwned"* ]]
docker rm -f "$test_container" >/dev/null 2>&1 || true
rm -rf "$dotclaude"
}
@test "statusline.sh read-only mount does NOT protect it when it's a symlink (Finding 1 - confirmed gap, see bin/dejima-audit)" {
# Same mount shape as the plain-regular-file test above, except
# statusline.sh on the host is a symlink into a separately-mounted
# directory - the common real setup
# (~/.claude/statusline.sh -> ~/dotfiles/statusline.sh). The parent
# ~/.claude mount is established first (same order docker-compose.yml
# uses), so by the time the more-specific statusline.sh mount below is
# processed, the destination path already has that symlink sitting on
# it - and bind-mount destination resolution (runc's SecureJoin) follows
# symlinks within the rootfs. This CONFIRMS the mount resolves through
# the symlink to the target directory (already read-only via the
# separate target_dir mount below, mirroring bin/dejima's own
# symlink-target scan) instead of binding to the symlink dentry itself -
# so the symlink dentry stays a perfectly ordinary, writable entry on
# the read-write ~/.claude mount, with no mountpoint-busy protection
# either. This test is a characterization test of that CONFIRMED,
# known, deliberately-documented limitation (see bin/dejima and
# README "Security notes"), not an aspiration: it asserts the
# replacement SUCCEEDS. The compensating control is detection, not
# prevention - bin/dejima-audit hashes the symlink's target
# string and reports a changed/repointed statusline.sh as a
# host-code-execution finding after the session exits (see
# test/audit.bats). If this test ever starts FAILING, that means
# Docker/runc changed destination-symlink resolution and this gap may
# have closed - that would be good news worth investigating and acting
# on (tightening the comments/audit accordingly), not a regression to
# "fix" back to the old behavior.
@test "staging also protects a symlinked statusline.sh, which the old read-only mount did not (Finding 1)" {
# Same shape as the plain-regular-file test above, except statusline.sh
# on the host is a symlink into a separate directory - the common real
# setup (~/.claude/statusline.sh -> a config checkout elsewhere). That
# case used to be a CONFIRMED, documented gap: statusline.sh was mounted
# read-only back onto its own path inside a read-write ~/.claude mount,
# the parent mount was established first so the destination already had
# the symlink sitting on it, and bind-mount destination resolution
# (runc's SecureJoin) followed it - the :ro mount landed on the target
# and the symlink dentry itself stayed an ordinary writable entry that a
# session could unlink and replace. Detection (bin/dejima-audit hashing
# the target string) was the only compensating control.
#
# Staging closes it outright, and for the opposite reason: source-side
# symlink resolution is exactly what's wanted, so what lands at
# /etc/dejima-claude is the real content, and entrypoint.sh copies it to
# a path with no route back to the host at all. This test asserts the
# replacement no longer reaches either the symlink or its target.
local test_container="dejima-test-statusline-symlink-$(basename "$BATS_FILE_TMPDIR")"
local target_dir dotclaude
target_dir="$(mktemp -d)"
@@ -415,14 +455,17 @@ dexec_node() {
chmod +x "$target_dir/statusline.sh"
dotclaude="$(mktemp -d)"
ln -s "$target_dir/statusline.sh" "$dotclaude/statusline.sh"
# Both mode 700 from mktemp -d; see the regular-file test above. Left
# writable for the same reason: a write that did get through must be
# able to land, or this would pass for reasons unrelated to the gap.
chmod 777 "$target_dir" "$dotclaude"
docker run -d --name "$test_container" \
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
-e SANDBOX_TEST_MODE=1 \
-v "$dotclaude:/home/node/.claude" \
-v "$target_dir:$target_dir:ro" \
-v "$dotclaude/statusline.sh:/home/node/.claude/statusline.sh:ro" \
-e GH_TOKEN="${GH_TOKEN:-}" \
-v "$dotclaude/statusline.sh:/etc/dejima-claude/statusline.sh:ro" \
--entrypoint /usr/local/bin/entrypoint.sh \
"$IMAGE" >/dev/null
wait_ready "$test_container"
@@ -431,8 +474,11 @@ dexec_node() {
'rm -f /home/node/.claude/statusline.sh && echo pwned > /home/node/.claude/statusline.sh'
[ "$status" -eq 0 ]
run docker exec -u node "$test_container" cat /home/node/.claude/statusline.sh
[[ "$output" == *"pwned"* ]]
[ -L "$dotclaude/statusline.sh" ]
[ "$(readlink "$dotclaude/statusline.sh")" = "$target_dir/statusline.sh" ]
run cat "$target_dir/statusline.sh"
[[ "$output" == *"original"* ]]
[[ "$output" != *"pwned"* ]]
docker rm -f "$test_container" >/dev/null 2>&1 || true
rm -rf "$target_dir" "$dotclaude"
@@ -543,7 +589,7 @@ EOF'
[ "$output" = "0" ]
}
@test "credential proxy chain: loopback -> forwarder -> unix socket -> host proxy injects the real token" {
@test "loopback transport: 127.0.0.1 -> forwarder -> unix socket -> host proxy injects the real token" {
TD="$BATS_TEST_TMPDIR"
python3 "$BATS_TEST_DIRNAME/fixtures/fake-anthropic.py" > "$TD/port" &
FAKE_PID=$!
@@ -556,13 +602,24 @@ EOF
--upstream "http://127.0.0.1:$(cat "$TD/port")" &
PROXY_PID=$!
for _ in $(seq 1 50); do [ -S "$TD/proxy.sock" ] && break; sleep 0.1; done
# UnixStreamServer binds at umask default, so the socket lands 755, and
# connecting to a unix socket needs *write* permission on it. In the real
# flow the connecting uid is the one that bound it, so that is fine;
# under CI it is root binding and node connecting, and the whole chain
# this test follows fails at its first hop. Widened rather than left to
# dejima-anthropic-proxy, which has no such problem in production.
# Unconditional: if the loop above timed out, "No such file" here says so
# more clearly than a test that fails on a bare false condition.
chmod 666 "$TD/proxy.sock"
container="dejima-test-proxy-$(basename "$BATS_FILE_TMPDIR")"
docker run -d --name "$container" \
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
-e SANDBOX_TEST_MODE=1 \
-e GH_TOKEN="${GH_TOKEN:-}" \
-e SANDBOX_ANTHROPIC_PROXY_SOCKET="$TD/proxy.sock" \
-e SANDBOX_ANTHROPIC_TRANSPORT=loopback \
-e ANTHROPIC_AUTH_TOKEN=sandbox-credential-proxy-placeholder \
-v "$TD:$TD" \
--entrypoint /usr/local/bin/entrypoint.sh \
@@ -595,47 +652,69 @@ EOF
[ "$env_hits" = "0" ]
}
@test "credential mask: ~/.claude/.credentials.json in-container is empty, never the real token (the actual bin/dejima mask mechanism)" {
@test "the real credential never enters the container, and a host-side refresh can't change that (#50)" {
# Unlike the proxy-chain test above (which hand-builds a docker run to
# exercise the proxy's HTTP path), this test reuses the exact masking
# mechanism bin/dejima itself uses at launch (see
# SANDBOX_CREDENTIALS_MASK_FILE there): a real ~/.claude directory
# bind-mounted read-write, with an empty file layered read-write on top
# at .credentials.json specifically - the same "more specific mount wins"
# trick as the other ~/.claude overlays. This is the check the design
# spec asked for: the real credential file's content must never be
# visible at that path inside the container.
# exercise the proxy's HTTP path), this test reuses the mount shape
# bin/dejima itself launches with: ~/.claude is not mounted, only the
# enumerated directories inside it are, and .credentials.json is not one
# of them. Claude Code still finds a credentials file, because
# entrypoint.sh creates an empty container-local one.
#
# The second half is the regression this test previously lacked, and #50
# is what walked through the gap. The old shape mounted ~/.claude
# read-write with an empty file layered over .credentials.json, and
# asserting only at startup made that look airtight. But a single-file
# bind mount is pinned to the dentry at its mount point: the first
# host-side atomic replace - which is exactly how the proxy writes a
# rotated token back, and how host-side Claude Code writes it too -
# unlinked that dentry, the mask silently detached, and the path fell
# through to the operator's real, current token. So replace the host
# file the way a refresh does, and assert again afterwards.
TD="$BATS_TEST_TMPDIR"
mkdir -p "$TD/dotclaude"
cat > "$TD/dotclaude/.credentials.json" <<EOF
mkdir -p "$TD/dotclaude/projects" "$TD/dotclaude/plans"
write_creds() {
cat > "$1" <<EOF
{"claudeAiOauth":{"accessToken":"real-access-token","refreshToken":"real-refresh-token","expiresAt":$((($(date +%s) + 7200) * 1000)),"refreshTokenExpiresAt":$((($(date +%s) + 86400) * 1000)),"scopes":[],"subscriptionType":"max","rateLimitTier":"default"}}
EOF
chmod -R 666 "$TD/dotclaude/.credentials.json"
chmod 777 "$TD/dotclaude"
mask_file="$TD/mask"
: > "$mask_file"
chmod 666 "$mask_file"
}
write_creds "$TD/dotclaude/.credentials.json"
chmod 666 "$TD/dotclaude/.credentials.json"
chmod -R 777 "$TD/dotclaude"
container="dejima-test-proxy-mask-$(basename "$BATS_FILE_TMPDIR")"
docker run -d --name "$container" \
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
-e SANDBOX_TEST_MODE=1 \
-v "$TD/dotclaude:/home/node/.claude" \
-v "$mask_file:/home/node/.claude/.credentials.json" \
-e GH_TOKEN="${GH_TOKEN:-}" \
-v "$TD/dotclaude/projects:/home/node/.claude/projects" \
-v "$TD/dotclaude/plans:/home/node/.claude/plans" \
--entrypoint /usr/local/bin/entrypoint.sh \
"$IMAGE" >/dev/null
wait_ready "$container"
run docker exec -u node "$container" cat /home/node/.claude/.credentials.json
[ "$status" -eq 0 ]
[ -z "$output" ]
run docker exec -u node "$container" grep -rl real-access-token /home/node/.claude
[ "$status" -ne 0 ]
# A token refresh, as os.replace() performs it: write a sibling temp file
# and rename it over the credential.
write_creds "$TD/dotclaude/.credentials.json.new"
chmod 666 "$TD/dotclaude/.credentials.json.new"
mv -f "$TD/dotclaude/.credentials.json.new" "$TD/dotclaude/.credentials.json"
run docker exec -u node "$container" cat /home/node/.claude/.credentials.json
status_creds=$status; output_creds=$output
run docker exec -u node "$container" grep -rl real-access-token /home/node/.claude
status_grep=$status
docker rm -f "$container" >/dev/null 2>&1 || true
[ "$status_creds" -eq 0 ]
[ -z "$output_creds" ]
[[ "$output_creds" != *"real-access-token"* ]]
[[ "$output_creds" != *"real-refresh-token"* ]]
[ "$status_grep" -ne 0 ]
}
@test "mid-session allowed domains get ongoing ipset tracking, not a one-time snapshot" {
@@ -704,13 +783,22 @@ EOF
printf 'declared-not-allowed.example\n' > "$ws/.dejima/allowed-domains"
chmod -R 755 "$ws"
# Mounted from a copy, not straight out of the checkout. Whatever daemon
# starts this container resolves -v sources against its own host, and
# under CI the checkout lives in a volume that has no host path at all -
# so the repo copy would mount as an empty directory rather than fail.
# $BATS_TEST_TMPDIR sits under TMPDIR, which CI points somewhere shared.
local hook="$BATS_TEST_TMPDIR/sandbox-context.sh"
cp "$BATS_TEST_DIRNAME/../hooks/sandbox-context.sh" "$hook"
local test_container="dejima-test-ctxreq-$(basename "$BATS_FILE_TMPDIR")"
docker run -d --name "$test_container" \
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
-e SANDBOX_TEST_MODE=1 \
-e GH_TOKEN="${GH_TOKEN:-}" \
-v "$ws:$ws" \
-v "$BATS_TEST_DIRNAME/../hooks/sandbox-context.sh:/usr/local/bin/sandbox-context.sh:ro" \
-v "$hook:/usr/local/bin/sandbox-context.sh:ro" \
--entrypoint /usr/local/bin/entrypoint.sh \
"$IMAGE" >/dev/null
wait_ready "$test_container"
@@ -752,6 +840,7 @@ EOF
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
-e SANDBOX_TEST_MODE=1 \
-e GH_TOKEN="${GH_TOKEN:-}" \
--entrypoint /usr/local/bin/entrypoint.sh \
"$derived" >/dev/null
wait_ready "$test_container"
@@ -779,6 +868,7 @@ EOF
PROXY_PID=$!
for _ in $(seq 1 50); do [ -S "$TD/proxy.sock" ] && break; sleep 0.1; done
SANDBOX_ANTHROPIC_PROXY_SOCKET="$TD/proxy.sock" SANDBOX_ANTHROPIC_PROXY_PORT=18399 \
SANDBOX_ANTHROPIC_TRANSPORT=loopback \
node "$BATS_TEST_DIRNAME/../docker/anthropic-proxy-forwarder.js" &
FWD_PID=$!
sleep 0.5
@@ -789,3 +879,197 @@ EOF
[ "$status" -eq 0 ]
[[ "$output" == *"proxy-ok"* ]]
}
# Starts a host-side TCP listener bound to $1, waits until it accepts a
# connection, and leaves its port in HOST_LISTENER_PORT. Binding port 0 and
# reading the port back (the same trick fixtures/fake-anthropic.py uses) is
# what keeps the test below honest: the kernel only hands out a port nothing
# else holds, so whatever answers is provably this listener and not some
# unrelated service that happened to own a hardcoded number. The handler has
# no do_* methods, so it serves nothing at all - only the accept() matters,
# and the gateway-bound instance is briefly reachable by every container on
# this host's bridge. Called plainly, never in a command substitution: it has
# to append to HOST_LISTENER_PIDS in the caller's shell for teardown to see it.
start_host_listener() {
local bind_addr="$1" port_file="$BATS_TEST_TMPDIR/listener-port-$1"
python3 -c 'import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
s = ThreadingHTTPServer((sys.argv[1], 0), BaseHTTPRequestHandler)
print(s.server_address[1], flush=True)
s.serve_forever()' "$bind_addr" > "$port_file" 2>/dev/null &
HOST_LISTENER_PIDS="${HOST_LISTENER_PIDS:-} $!"
for _ in $(seq 1 50); do [ -s "$port_file" ] && break; sleep 0.1; done
HOST_LISTENER_PORT="$(cat "$port_file")"
[ -n "$HOST_LISTENER_PORT" ]
timeout 5 bash -c "cat < /dev/null > /dev/tcp/$bind_addr/$HOST_LISTENER_PORT"
}
@test "a host service bound to loopback is unreachable from inside the container, one bound to the bridge gateway is reachable" {
# The regression test for the escape path bin/dejima-ttyd's
# --interface 127.0.0.1 exists to close. init-firewall.sh deliberately
# ACCEPTs OUTPUT to the Docker bridge gateway, and a tmux client attached
# through ttyd is a host shell - the prefix key opens a window outside the
# container - so a ttyd the sandbox could reach would be a full escape.
# Prove the containment property holds rather than trusting the flag.
local container host_ip loopback_port gateway_port
container="$(container_name)"
# Derived exactly as init-firewall.sh derives HOST_IP, so this is the one
# host address the container's OUTPUT chain accepts at all.
host_ip="$(docker exec "$container" sh -c "ip route | grep default | cut -d' ' -f3")"
[ -n "$host_ip" ]
start_host_listener 127.0.0.1
loopback_port="$HOST_LISTENER_PORT"
start_host_listener "$host_ip"
gateway_port="$HOST_LISTENER_PORT"
# Setup check, from the host's own vantage point: the loopback listener's
# port must answer on 127.0.0.1 only. bind(0) on two different addresses
# can legally return the same port number, and if it had, the container
# probe below would be hitting the gateway listener and failing for a
# reason that has nothing to do with containment.
run timeout 5 bash -c "cat < /dev/null > /dev/tcp/$host_ip/$loopback_port"
[ "$status" -ne 0 ]
# Positive control, and the reason the negative one means anything: same
# container, same docker exec, same timeout/bash /dev/tcp probe, same
# gateway address - only the port differs. Without it a `status -ne 0` is
# equally satisfied by docker exec failing (125), bash or timeout missing
# from the image (127), or a container that can reach nothing at all, none
# of which say anything about loopback.
run docker exec "$container" timeout 5 bash -c "cat < /dev/null > /dev/tcp/$host_ip/$gateway_port"
[ "$status" -eq 0 ]
# Deliberately not asserting the exact status. 1 (the host RSTs, since
# nothing is bound on the gateway address at this port) is what it should
# be, but a host that DROPs on its own docker bridge instead yields 124
# from timeout, and that is still containment.
run docker exec "$container" timeout 5 bash -c "cat < /dev/null > /dev/tcp/$host_ip/$loopback_port"
[ "$status" -ne 0 ]
}
@test "tls transport: https://api.anthropic.com from inside the container reaches the host proxy" {
TD="$BATS_TEST_TMPDIR"
python3 "$BATS_TEST_DIRNAME/fixtures/fake-anthropic.py" > "$TD/port" &
FAKE_PID=$!
for _ in $(seq 1 50); do [ -s "$TD/port" ] && break; sleep 0.1; done
cat > "$TD/credentials.json" <<EOF
{"claudeAiOauth":{"accessToken":"real-access-token","refreshToken":"r","expiresAt":$((($(date +%s) + 7200) * 1000)),"refreshTokenExpiresAt":$((($(date +%s) + 86400) * 1000)),"scopes":[],"subscriptionType":"max","rateLimitTier":"default"}}
EOF
"$BATS_TEST_DIRNAME/../bin/dejima-anthropic-proxy" \
--socket "$TD/proxy.sock" --credentials "$TD/credentials.json" \
--upstream "http://127.0.0.1:$(cat "$TD/port")" &
PROXY_PID=$!
for _ in $(seq 1 50); do [ -S "$TD/proxy.sock" ] && break; sleep 0.1; done
# See the loopback test above for why this chmod is here.
chmod 666 "$TD/proxy.sock"
container="dejima-test-tls-$(basename "$BATS_FILE_TMPDIR")"
# No ANTHROPIC_* env at all - that absence is the thing under test.
docker run -d --name "$container" \
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
-e SANDBOX_TEST_MODE=1 \
-e GH_TOKEN="${GH_TOKEN:-}" \
-e SANDBOX_ANTHROPIC_PROXY_SOCKET="$TD/proxy.sock" \
-v "$TD:$TD" \
--entrypoint /usr/local/bin/entrypoint.sh \
"$IMAGE" >/dev/null
wait_ready "$container"
# NODE_EXTRA_CA_CERTS has to be passed explicitly: docker exec starts from
# the image's ENV, not from the entrypoint process's exported environment.
# The session command itself is a child of the entrypoint and does inherit
# it - which is what the /proc/1/environ check below confirms.
run docker exec -e NODE_EXTRA_CA_CERTS=/etc/dejima-anthropic-tls/cert.pem \
"$container" node -e '
fetch("https://api.anthropic.com/v1/messages", {method: "POST", body: "{}"})
.then(r => r.text()).then(t => { console.log(t); })
.catch(e => { console.error(e); process.exit(1); })'
status_tls=$status; output_tls=$output
run docker exec -u node "$container" sh -c "tr '\0' '\n' < /proc/1/environ | grep -c '^ANTHROPIC' || true"
env_anthropic=$output
run docker exec -u node "$container" sh -c "tr '\0' '\n' < /proc/1/environ | grep -c NODE_EXTRA_CA_CERTS || true"
env_ca=$output
run docker exec -u node "$container" sh -c "tr '\0' '\n' < /proc/1/environ | grep -c real-access-token || true"
env_token=$output
run docker exec "$container" grep -c '127.0.0.1 api.anthropic.com' /etc/hosts
hosts_hits=$output
# /etc/hosts resolves the name locally, but that only wins inside
# getaddrinfo - the DNS proxy itself must still refuse it, same as any
# other name that isn't on the allowlist (see the DNS-proxy test above),
# so a bypass of the pin that looks the name up by DNS gets no address.
# It is not a route check: the API's address stays reachable as an IP
# literal for as long as allowlisted platform.claude.com resolves to it.
run docker exec "$container" dig api.anthropic.com
dig_output=$output
docker rm -f "$container" >/dev/null 2>&1 || true
kill "$PROXY_PID" "$FAKE_PID" 2>/dev/null || true
[ "$status_tls" -eq 0 ]
# The client sent no Authorization header; the host proxy supplied one.
[[ "$output_tls" == *'"authorization": "Bearer real-access-token"'* ]]
# Both fingerprints are gone: no base URL, no placeholder token...
[ "$env_anthropic" = "0" ]
# ...while the trust anchor the session actually needs did get inherited.
[ "$env_ca" = "1" ]
[ "$env_token" = "0" ]
[ "$hosts_hits" = "1" ]
[[ "$dig_output" == *"status: REFUSED"* ]]
}
@test "the kill switch leaves api.anthropic.com unpinned, so the real API stays reachable" {
# SANDBOX_CREDENTIAL_PROXY=0 mounts the real credential and starts no
# forwarder. If the hosts entry were unconditional (a compose extra_hosts
# entry, say), this container would send every API call into a loopback
# port nothing is listening on.
container="dejima-test-nohosts-$(basename "$BATS_FILE_TMPDIR")"
docker run -d --name "$container" \
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
-e SANDBOX_TEST_MODE=1 \
-e GH_TOKEN="${GH_TOKEN:-}" \
--entrypoint /usr/local/bin/entrypoint.sh \
"$IMAGE" >/dev/null
wait_ready "$container"
run docker exec "$container" grep -c 'api.anthropic.com' /etc/hosts
hosts_hits=$output
docker rm -f "$container" >/dev/null 2>&1 || true
[ "$hosts_hits" = "0" ]
}
@test "an unrecognised transport aborts the container instead of half-configuring it" {
# `Loopback` is not `loopback`: bin/dejima would take its tls branch while
# this container took its loopback one, and every API call in the session
# would fail with nothing naming the cause.
container="dejima-test-badtransport-$(basename "$BATS_FILE_TMPDIR")"
docker run -d --name "$container" \
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
-e SANDBOX_TEST_MODE=1 \
-e GH_TOKEN="${GH_TOKEN:-}" \
-e SANDBOX_ANTHROPIC_TRANSPORT=Loopback \
--entrypoint /usr/local/bin/entrypoint.sh \
"$IMAGE" >/dev/null
# Without the check the entrypoint runs on into SANDBOX_TEST_MODE's `sleep
# infinity` and never exits - the timeout turns that regression into a
# failure here rather than a hung suite.
run timeout 120 docker wait "$container"
exit_code=$output
logs=$(docker logs "$container" 2>&1)
docker rm -f "$container" >/dev/null 2>&1 || true
[ "$exit_code" = "1" ]
[[ "$logs" == *"SANDBOX_ANTHROPIC_TRANSPORT is set to 'Loopback'"* ]]
[[ "$logs" == *"'tls'"* ]]
[[ "$logs" == *"'loopback'"* ]]
}
+651 -76
View File
@@ -49,6 +49,48 @@ setup() {
grep -qE '^\s*ENV\s+NODE_PATH=' "$SANDBOX_DIR/Dockerfile"
}
# One awk pass rather than a grep chain: a filtering grep exits 1 when it
# discards every line, which here is the "nothing wrong" case, so the chain
# would report its own success as a failure. Comment lines are skipped - one
# of them quotes, verbatim, the invocation the second test bans.
@test "every build-time download in the Dockerfile retries rather than dying on one blip" {
# These fetches all sit below the SANDBOX_* ARGs, so any build overriding
# one of those - test/runtime.bats' first test, or simply a host whose uid
# isn't 1000 - invalidates the cache and re-runs every one of them. The
# odds that matter are then the odds of *any* of them blipping, not of a
# particular one, and a single-shot fetch turns that into a dead build.
local unguarded
unguarded=$(awk '
/^[[:space:]]*#/ { next }
/wget/ && !/--tries=[0-9]/ { print FNR": "$0 }
/curl/ && !/--retry [0-9]/ { print FNR": "$0 }
' "$SANDBOX_DIR/Dockerfile")
[ -z "$unguarded" ] || {
echo "these downloads have no retry flags:" >&2
echo "$unguarded" >&2
false
}
}
@test "the zsh installer runs from a file, so a failed download fails the build" {
# Upstream documents `sh -c "$(wget -O- ...)"`. In that form a failed
# download substitutes to an empty string and `sh -c ""` exits 0, so the
# build goes green having installed nothing - the kind of failure that
# only surfaces later, as a shell that is inexplicably unconfigured.
local substituted
substituted=$(awk '
/^[[:space:]]*#/ { next }
/sh -c "\$\((wget|curl)/ { print FNR": "$0 }
' "$SANDBOX_DIR/Dockerfile")
[ -z "$substituted" ] || {
echo "a failed download here would exit 0 and install nothing:" >&2
echo "$substituted" >&2
false
}
grep -q -- '-O /tmp/zsh-in-docker.sh' "$SANDBOX_DIR/Dockerfile"
grep -qE '^\s*sh /tmp/zsh-in-docker.sh' "$SANDBOX_DIR/Dockerfile"
}
@test "every ipset add in init-firewall.sh is duplicate-safe" {
# Same bug class that broke firewall startup twice (718f6b7, a86fefc):
# ipset add without -exist errors on a duplicate entry, and set -e turns
@@ -87,25 +129,64 @@ setup() {
[ "$status" -ne 0 ]
}
@test "~/.claude settings/CLAUDE.md/plugins are layered read-only over the read-write parent mount" {
grep -qE '^\s*-\s*\$\{HOME\}/\.claude:\$\{HOME\}/\.claude\s*$' "$SANDBOX_DIR/docker-compose.yml"
# settings.json specifically comes from a generated $SANDBOX_SETTINGS_FILE
# (see below), not the real file directly - it still has to be read-only
# in the container, just from a different, sandbox-specific source.
grep -qE '^\s*-\s*\$\{SANDBOX_SETTINGS_FILE.*\}:\$\{HOME\}/\.claude/settings\.json:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/CLAUDE\.md:.*:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/plugins:.*:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
@test "~/.claude is not mounted as a whole - its contents are enumerated" {
# It used to be, read-write, with the config inside it layered back
# read-only on top. Every one of those overlays was a mount point nested
# inside a host-writable mount, and a single-file bind mount is pinned to
# the dentry at that path: a host-side rename unlinks it, the mount
# silently detaches, and the path falls through to the real writable host
# file (#47, #48). The mask over .credentials.json went the same way,
# handing the sandbox the operator's real OAuth token (#50). With no
# parent mount, a detachment falls through to container-local emptiness.
run grep -E '^\s*-\s*\$\{HOME\}/\.claude:\$\{HOME\}/\.claude\s*$' "$SANDBOX_DIR/docker-compose.yml"
[ "$status" -ne 0 ]
run grep -E '^\s*-\s*\$\{HOME\}/\.claude/\.credentials' "$SANDBOX_DIR/docker-compose.yml"
[ "$status" -ne 0 ]
}
@test "~/.claude/hooks is layered read-only over the read-write parent mount" {
# Same persistence-path rationale as the settings/CLAUDE.md/plugins
# overlay above, but for the directory install.sh's hook symlinks live
# in - see docker-compose.yml's comment on this mount line. Without
# this, a compromised session can unlink a hook symlink and drop a
# plain malicious file in its place, which the dynamic-mount logic in
# bin/dejima never sees (it only protects symlink targets that
# resolve outside ~/.claude).
@test "the image bakes in a user-owned ~/.claude for the enumerated mounts to land in" {
# Nothing mounts the directory itself any more, so it has to exist and be
# writable by the sandbox user - otherwise Docker auto-creates it root
# owned while setting up the mounts nested under it, and the session
# can't write its own cache or the config entrypoint.sh copies in.
grep -qE '^RUN mkdir -p .*\$SANDBOX_HOME/\.claude' "$SANDBOX_DIR/Dockerfile"
grep -qE 'chown -R \$SANDBOX_USER:\$SANDBOX_USER .*\$SANDBOX_HOME/\.claude' "$SANDBOX_DIR/Dockerfile"
}
@test "~/.claude runtime state that has to survive the session is mounted read-write" {
# Directories, which is what makes them safe: a directory bind mount
# can't detach the way a file one does, because a rename replaces an
# entry *within* the mounted directory rather than the mount point.
# projects/ is what host-side `claude --resume` reads; the rest is the
# edit-undo record and the task/plan state skills write.
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/projects:\$\{HOME\}/\.claude/projects\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/file-history:\$\{HOME\}/\.claude/file-history\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/backups:\$\{HOME\}/\.claude/backups\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/tasks:\$\{HOME\}/\.claude/tasks\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/plans:\$\{HOME\}/\.claude/plans\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/sessions:\$\{HOME\}/\.claude/sessions\s*$' "$SANDBOX_DIR/docker-compose.yml"
}
@test "~/.claude config files are staged for copying, not mounted at their real paths" {
# A copy can't be detached by a host-side rename at all. entrypoint.sh
# installs them root-owned and read-only; see its comment and the mount
# inventory in docker-compose.yml.
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/settings\.json:/etc/dejima-claude/settings\.json:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/CLAUDE\.md:/etc/dejima-claude/CLAUDE\.md:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
run grep -E '^\s*-\s*\$\{HOME\}/\.claude/(settings\.json|CLAUDE\.md):\$\{HOME\}/' "$SANDBOX_DIR/docker-compose.yml"
[ "$status" -ne 0 ]
grep -q 'install -o root -g root -m 444 "/etc/dejima-claude/\$staged"' "$SANDBOX_DIR/entrypoint.sh"
}
@test "~/.claude/hooks and plugins are mounted read-only" {
# Hooks run automatically with no approval gate, and plugins carry code a
# later unsandboxed host session loads - see docker-compose.yml's comment
# on these mount lines. Without them, a compromised session can unlink a
# hook symlink and drop a plain malicious file in its place, which the
# dynamic-mount logic in bin/dejima never sees (it only protects symlink
# targets that resolve outside ~/.claude).
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/hooks:\$\{HOME\}/\.claude/hooks:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/plugins:\$\{HOME\}/\.claude/plugins:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
}
@test "~/.claude.json is a throwaway per-session copy, not the real file mounted directly" {
@@ -131,6 +212,34 @@ setup() {
grep -q 'mktemp -d.*gitconfig' "$REPO_ROOT/bin/dejima"
}
@test "every name cleanup() dereferences is bound by the time the EXIT trap can fire" {
# bin/dejima runs under `set -u`, and a failing command in an EXIT trap
# replaces the shell's exit status - so an unbound name in cleanup()
# aborts the trap partway, before it reaches the audit that is
# deliberately its last act. Structural, not behavioral: the window
# between `trap cleanup EXIT` and a later assignment holds only comments
# today, so nothing can currently exit inside it (a failed `VAR="$(...)"`
# still binds VAR to the empty string before set -e fires). This guards
# the invariant against the edit that puts something exitable there.
#
# A name is satisfied by EITHER a first assignment above the trap OR a
# `${NAME:-}` form somewhere in cleanup() - the latter covers the bare
# uses that sit inside their own `[ -n "${NAME:-}" ]` guard.
d="$REPO_ROOT/bin/dejima"
trap_line="$(grep -n '^trap cleanup EXIT' "$d" | cut -d: -f1)"
[ -n "$trap_line" ]
body="$(sed -n '/^cleanup() {/,/^}/p' "$d")"
[ -n "$body" ]
for v in $(printf '%s\n' "$body" | grep -oE '\$\{?[A-Za-z_][A-Za-z0-9_]*\}?' \
| tr -d '${}' | sort -u); do
printf '%s\n' "$body" | grep -q "\${$v:-" && continue
asg="$(grep -nE "^[[:space:]]*(export )?$v=" "$d" | head -1 | cut -d: -f1)"
[ -n "$asg" ] || { echo "cleanup() uses \$$v, never assigned at all"; false; }
[ "$asg" -lt "$trap_line" ] \
|| { echo "cleanup() uses \$$v unguarded, first assigned at line $asg, after the trap at $trap_line"; false; }
done
}
@test "docker-compose.yml never hardcodes /home/node - the container home is host-parametrized" {
# ${HOME:-/home/node} (the SANDBOX_HOME build arg's own default value,
# mirroring the Dockerfile's default) is the one legitimate exception -
@@ -183,13 +292,49 @@ setup() {
grep -q 'extra_mounts+=(-v "\$path:\$path:ro")' "$REPO_ROOT/bin/dejima"
}
@test "bin/dejima forces sandbox.enabled to false in the generated settings.json" {
@test "bin/dejima forces sandbox.enabled to false in the generated override layer" {
# Claude Code's own internal per-command sandboxing is redundant on top
# of this container and can't actually function inside it (bwrap can't
# create a namespace here) - settings.json is read-only precisely so it
# can't be patched from inside, so this has to happen in the generation
# step on the host, before the container starts.
grep -qE '\.sandbox\.enabled\s*=\s*false' "$REPO_ROOT/bin/dejima"
# create a namespace here) - a session can't be trusted to turn it off
# itself, so this has to happen in the generation step on the host,
# before the container starts.
grep -qE 'jq -n .\{sandbox: \{enabled: false\}\}' "$REPO_ROOT/bin/dejima"
}
@test "the generated override layer is a delta, not a copy of the operator's settings" {
# jq -n reads no input: what ships is only what the sandbox overrides,
# and everything else still comes from the operator's real settings.json
# through Claude Code's own layering. A copy would go stale the moment
# the host edited the original, and would duplicate every hook it
# carried across two layers that get merged.
grep -qE '^jq -n .* > "\$SANDBOX_SETTINGS_FILE"$' "$REPO_ROOT/bin/dejima"
# The operator's settings.json is read only to check that it parses. It
# must never become an input to the override layer.
run grep -E 'HOME/\.claude/settings\.json.*SANDBOX_SETTINGS_FILE' "$REPO_ROOT/bin/dejima"
[ "$status" -ne 0 ]
}
@test "the override layer is mounted outside ~/.claude and passed as --settings" {
# Mounting it over ~/.claude/settings.json put the mount point inside the
# read-write ~/.claude mount, where a host-side rename of the real file
# detached it mid-session and silently reverted every override (#47).
# A container-local path can't be renamed over from the host.
grep -qE '^\s*-\s*\$\{SANDBOX_SETTINGS_FILE.*\}:/etc/dejima-settings\.json:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
run grep -E '^\s*-\s*\$\{SANDBOX_SETTINGS_FILE.*\}:\$\{HOME\}/' "$SANDBOX_DIR/docker-compose.yml"
[ "$status" -ne 0 ]
# Which only takes effect if the flag is actually passed. It goes in the
# wrapper rather than the entrypoint so it also covers invocations the
# entrypoint never sees (an interactive shell, claudecode.nvim).
grep -qE -- '--settings /etc/dejima-settings\.json' "$SANDBOX_DIR/claude-wrapper.sh"
grep -qE -- '"\$\{settings\[@\]\}"' "$SANDBOX_DIR/claude-wrapper.sh"
}
@test "bin/dejima pre-creates the ~/.claude config files so neither is mounted as a directory" {
# Docker creates a missing bind source as a directory - without this, a
# host that has never run install.sh gets a junk directory at
# ~/.claude/settings.json and a session with no settings at all.
grep -qE '\[ -f "\$HOME/\.claude/settings\.json" \] \|\| echo' "$REPO_ROOT/bin/dejima"
grep -qE '\[ -f "\$HOME/\.claude/CLAUDE\.md" \] \|\|' "$REPO_ROOT/bin/dejima"
}
@test "Dockerfile never installs bubblewrap or socat" {
@@ -209,6 +354,19 @@ setup() {
grep -q '.worktrees/' "$REPO_ROOT/hooks/sandbox-context.sh"
}
@test "sandbox-context.sh tells the agent to close issues via the PR body" {
# The session ends at the PR and the merge happens later, without the
# agent - so anything it defers to "after the merge" is something it
# won't be there to do, and an issue a merged PR fixed just stays open.
# A closing keyword in the body is the only part of that it can control.
grep -q 'Fixes #N' "$REPO_ROOT/hooks/sandbox-context.sh"
# And the counterpart, so partial work doesn't close anything.
grep -q 'Refs #N' "$REPO_ROOT/hooks/sandbox-context.sh"
# The short form specifically: an issue's full URL renders as the same
# link but does not trigger the close.
grep -q "not the issue's URL" "$REPO_ROOT/hooks/sandbox-context.sh"
}
@test "entrypoint runs as root and drops to node via gosu, not a baked-in USER node" {
# The whole privilege-drop model depends on entrypoint.sh itself starting
# as root (to set up the firewall) - a stray `USER node` before
@@ -316,13 +474,85 @@ setup() {
grep -q 'branch" = "main"\|branch" = "master"' "$REPO_ROOT/hooks/git-push-guard.sh"
}
@test "gitea-pr never passes the token as a command-line argument" {
@test "neither gitea CLI passes the token as a command-line argument" {
# /proc makes argv world-readable, so a token in a curl invocation is
# visible to every other process for the life of the request - and this
# runs alongside agents that read process listings.
run grep -n 'curl.*Authorization: token' "$REPO_ROOT/bin/gitea-pr"
[ "$status" -ne 0 ]
grep -q 'curl -sS -w .*-K -' "$REPO_ROOT/bin/gitea-pr"
#
# Both files, because they are deliberate copies of each other rather
# than a shared library (see bin/gitea-issue's header for why), and a
# duplicated security property is exactly the kind that drifts.
local f
for f in bin/gitea-pr bin/gitea-issue; do
run grep -n 'curl.*Authorization: token' "$REPO_ROOT/$f"
[ "$status" -ne 0 ]
grep -q 'curl -sS -w .*-K -' "$REPO_ROOT/$f"
done
}
@test "both gitea CLIs take a body from a file, and refuse to guess between two" {
# Prose on a command line has to survive shell quoting, and an
# apostrophe or a backtick is the common case, not the edge one. A body
# that arrives via a file never enters argv at all.
local f
for f in bin/gitea-pr bin/gitea-issue; do
grep -q -- '--body-file' "$REPO_ROOT/$f"
# '-' means stdin, so a heredoc works without a temp file.
grep -q 'file" = "-"' "$REPO_ROOT/$f"
# Passing both is an error rather than a precedence rule: silently
# picking one ships a body the caller did not mean to send.
grep -q 'not both' "$REPO_ROOT/$f"
done
}
@test "gitea-issue list asks for issues only, never pull requests" {
# Gitea's issues endpoint returns PRs too - a PR is an issue with a
# branch attached - so without type=issues every open PR shows up as an
# issue and the work list reads as twice as long as it is.
grep -q 'type=issues' "$REPO_ROOT/bin/gitea-issue"
}
@test "gitea-issue is installed into the image alongside gitea-pr" {
grep -qx 'COPY bin/gitea-issue /usr/local/bin/gitea-issue' "$SANDBOX_DIR/Dockerfile"
# Copied without the executable bit surviving is the failure that only
# shows up at runtime, so the chmod list is checked too.
grep -q 'chmod +x .*/usr/local/bin/gitea-issue' "$SANDBOX_DIR/Dockerfile"
}
@test "gitea-merge-guard.sh blocks a sandbox merge and is a no-op on the host" {
local hook="$REPO_ROOT/hooks/gitea-merge-guard.sh"
local payload
# Blocked in the sandbox, including path-qualified - /usr/local/bin is
# where the image puts it, so a bare-name-only pattern would miss the
# form an agent reaching past PATH would type.
local cmd
for cmd in 'gitea-pr merge 24' '/usr/local/bin/gitea-pr merge 24' 'cd /tmp && gitea-pr merge 24'; do
payload=$(jq -n --arg c "$cmd" '{tool_input: {command: $c}}')
run bash -c "printf '%s' \"\$1\" | DEJIMA=1 bash '$hook'" _ "$payload"
[ "$status" -eq 2 ]
[[ "$output" == *"gitea-merge-guard.sh"* ]]
done
# The other verbs are the whole point of the tool and must pass through.
for cmd in 'gitea-pr create --title x' 'gitea-pr comments 24' 'gitea-issue close 14'; do
payload=$(jq -n --arg c "$cmd" '{tool_input: {command: $c}}')
run bash -c "printf '%s' \"\$1\" | DEJIMA=1 bash '$hook'" _ "$payload"
[ "$status" -eq 0 ]
done
# Outside the sandbox it does nothing at all: an operator at a host shell
# can still be asked to merge, which is what the CLAUDE.md rule allows.
# env -u rather than DEJIMA= because the image sets DEJIMA=1 globally, so
# a test running inside dejima would otherwise assert against itself.
payload=$(jq -n '{tool_input: {command: "gitea-pr merge 24"}}')
run bash -c "printf '%s' \"\$1\" | env -u DEJIMA bash '$hook'" _ "$payload"
[ "$status" -eq 0 ]
}
@test "install.sh links and registers the gitea merge guard" {
grep -qx 'link_hook gitea-merge-guard.sh' "$REPO_ROOT/install.sh"
grep -q 'register_hook PreToolUse Bash gitea-merge-guard.sh' "$REPO_ROOT/install.sh"
}
@test "dns-proxy.sh enables dnsmasq's stop-dns-rebind protection" {
@@ -345,20 +575,29 @@ setup() {
grep -q 'ip6tables -L >/dev/null 2>&1' "$SANDBOX_DIR/init-firewall.sh"
}
@test "~/.claude skills/commands/agents/output-styles are layered read-only over the read-write parent mount" {
# Same persistence-path rationale as the hooks overlay: these carry
@test "~/.claude skills/commands/agents/output-styles are mounted read-only" {
# Same persistence-path rationale as the hooks mount: these carry
# executable skill scripts / injectable command, agent, and output-style
# prompts that a later unsandboxed host session would run or load.
# Without an overlay (and without pre-creation, since they may not
# exist) a compromised session can create and populate them.
# Read-only, and pre-created by bin/dejima since existence is what lets
# Docker mount them at all.
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/skills:\$\{HOME\}/\.claude/skills:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/commands:\$\{HOME\}/\.claude/commands:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/agents:\$\{HOME\}/\.claude/agents:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/output-styles:\$\{HOME\}/\.claude/output-styles:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
}
@test "bin/dejima pre-creates the code-carrying ~/.claude overlay dirs" {
grep -q 'mkdir -p "$HOME/.claude/skills" "$HOME/.claude/commands" "$HOME/.claude/agents" "$HOME/.claude/output-styles"' "$REPO_ROOT/bin/dejima"
@test "bin/dejima pre-creates every enumerated ~/.claude mount source" {
# Docker answers a missing bind source by creating a root-owned directory
# in its place - on the host for the source, and at the destination for
# the code-carrying dirs, which would leave a session with no skills or
# hooks and junk on the host either way.
for dir in skills commands agents output-styles hooks plugins \
projects file-history backups tasks plans sessions; do
grep -q "\"\$HOME/.claude/$dir\"" "$REPO_ROOT/bin/dejima"
done
grep -q 'mkdir -p "$HOME/.claude/skills"' "$REPO_ROOT/bin/dejima"
grep -q 'mkdir -p "$HOME/.claude/projects"' "$REPO_ROOT/bin/dejima"
}
@test "bin/dejima overlays the workspace .git/hooks read-only when .git is a real dir" {
@@ -402,40 +641,85 @@ setup() {
}
@test "bin/dejima's exit-time audit still runs when the container run fails or is interrupted" {
local run_line
local run_line trap_line
run_line=$(grep -n 'docker compose .* run --rm' "$REPO_ROOT/bin/dejima" | cut -d: -f1)
trap_line=$(grep -n '^trap cleanup EXIT' "$REPO_ROOT/bin/dejima" | head -1 | cut -d: -f1)
[ -n "$run_line" ]
# The compose invocation must not be a bare command under set -e - a
# nonzero/interrupted exit there would otherwise abort the script before
# the audit compare and exit below ever run.
[ -n "$trap_line" ]
# cleanup() is installed as the EXIT trap before the compose run, so it
# fires - and runs the audit - on every exit from the run below,
# including a nonzero/interrupted one under set -e that would otherwise
# abort the script before a bare audit call placed after the run ever
# ran.
[ "$trap_line" -lt "$run_line" ]
sed -n "1,${trap_line}p" "$REPO_ROOT/bin/dejima" | grep -q '"\$SCRIPT_DIR/dejima-audit" compare "\$WORKSPACE_DIR"'
# The compose invocation must not itself be a bare command under set -e
# either - a nonzero/interrupted exit there would otherwise skip the
# credential-proxy check and the propagated exit code below it.
grep -q '|| compose_status=\$?' <(sed -n "${run_line}p" "$REPO_ROOT/bin/dejima")
sed -n "1,${run_line}p" "$REPO_ROOT/bin/dejima" | grep -q 'compose_status=0'
tail -n "+$run_line" "$REPO_ROOT/bin/dejima" | grep -q '"\$SCRIPT_DIR/dejima-audit" compare "\$WORKSPACE_DIR"'
tail -n "+$run_line" "$REPO_ROOT/bin/dejima" | grep -q 'exit "\$compose_status"'
}
# --- Task 10: the exit-time audit is the structural last writer ---
@test "the exit-time audit is the last thing that writes to the terminal" {
audit_line=$(grep -n 'dejima-audit" compare' "$REPO_ROOT/bin/dejima" | head -1 | cut -d: -f1)
trap_line=$(grep -n '^trap cleanup EXIT' "$REPO_ROOT/bin/dejima" | head -1 | cut -d: -f1)
marker_line=$(grep -n 'could not remove the marker directory' "$REPO_ROOT/bin/dejima" | head -1 | cut -d: -f1)
[ -n "$marker_line" ]
[ "$audit_line" -lt "$trap_line" ]
# Not just "inside cleanup()" but after the one line in it a session can
# deliberately provoke to print - the actual last-writer invariant.
[ "$marker_line" -lt "$audit_line" ]
run grep -c 'could not remove the marker directory' "$REPO_ROOT/bin/dejima"
[ "$output" -eq 1 ]
}
@test "credential proxy is on by default, gated only by SANDBOX_CREDENTIAL_PROXY=0" {
grep -q '"\${SANDBOX_CREDENTIAL_PROXY:-1}" != "0"' "$REPO_ROOT/bin/dejima"
}
@test "bin/dejima masks the credentials file and mounts the proxy socket dir" {
# The mask is what keeps the real token out of the (otherwise read-write)
# ~/.claude mount; the socket dir is how API traffic gets back out to the
# host-side proxy that holds the real credential.
grep -q 'mktemp.*dejima-credentials-mask' "$REPO_ROOT/bin/dejima"
grep -q 'extra_mounts+=(-v "\$SANDBOX_CREDENTIALS_MASK_FILE:\$HOME/\.claude/\.credentials\.json")' "$REPO_ROOT/bin/dejima"
@test "bin/dejima mounts the proxy socket dir and masks nothing" {
# The socket dir is how API traffic gets back out to the host-side proxy
# that holds the real credential. The credential itself, and the
# .credentials-proxy-tmp staging directory the proxy writes rotated
# tokens through, used to be covered by empty-file/-directory masks
# mounted into the read-write ~/.claude mount. That was #50: the proxy's
# own write-back renamed the credential out from under its mask,
# detaching it and exposing the real token from the first refresh on.
# Neither path is mounted at all now, so there is nothing left to mask -
# the fake credential the tls transport mounts (see below) is a file the
# container would otherwise not have, not a mask over the operator's.
grep -q 'extra_mounts+=(-v "\$SANDBOX_ANTHROPIC_PROXY_DIR:\$SANDBOX_ANTHROPIC_PROXY_DIR:ro")' "$REPO_ROOT/bin/dejima"
run grep -n 'CREDENTIALS_MASK_FILE\|CREDENTIALS_TMPDIR_MASK\|dejima-credentials-mask\|dejima-credentials-tmpdir-mask' "$REPO_ROOT/bin/dejima"
[ "$status" -ne 0 ]
}
@test "bin/dejima also masks the proxy's write-back staging subdirectory" {
# _write() in bin/dejima-anthropic-proxy stages the rotated
# credential in ~/.claude/.credentials-proxy-tmp before the atomic
# rename - that subdirectory is inside the otherwise read-write ~/.claude
# mount, so without its own mask a sibling temp file would be
# container-readable for the whole write window (see proxy.bats).
grep -q 'mktemp -d.*dejima-credentials-tmpdir-mask' "$REPO_ROOT/bin/dejima"
grep -q 'extra_mounts+=(-v "\$SANDBOX_CREDENTIALS_TMPDIR_MASK:\$HOME/\.claude/\.credentials-proxy-tmp:ro")' "$REPO_ROOT/bin/dejima"
grep -q '"\${SANDBOX_CREDENTIALS_TMPDIR_MASK:-}"' "$REPO_ROOT/bin/dejima"
@test "bin/dejima mounts the real credential only when the proxy is off" {
# With ~/.claude no longer mounted as a whole, the documented
# SANDBOX_CREDENTIAL_PROXY=0 downgrade no longer gets the credential for
# free - it has to be stated. Read-write, because Claude Code refreshes
# it in place. The only mount of it anywhere, and it sits under the
# proxy-disabled branch.
run grep -c 'extra_mounts+=(-v "\$HOME/\.claude/\.credentials\.json:\$HOME/\.claude/\.credentials\.json")' "$REPO_ROOT/bin/dejima"
[ "$output" = "1" ]
local mount_line branch_line
mount_line=$(grep -n 'extra_mounts+=(-v "\$HOME/\.claude/\.credentials\.json' "$REPO_ROOT/bin/dejima" | cut -d: -f1)
branch_line=$(grep -n 'elif \[ -f "\$HOME/\.claude/\.credentials\.json" \]; then' "$REPO_ROOT/bin/dejima" | cut -d: -f1)
[ -n "$branch_line" ]
[ "$branch_line" -lt "$mount_line" ]
}
@test "entrypoint.sh gives the container an empty credentials file of its own" {
# The mask used to provide one; Claude Code shouldn't have to be trusted
# to treat an absent credentials file the same as an empty one. Guarded
# on non-existence so it can't clobber either of the credentials
# bin/dejima does mount - the fake one under the tls transport, the real
# one under SANDBOX_CREDENTIAL_PROXY=0 - since mounts are established
# before the entrypoint runs.
grep -q 'if \[ ! -e "\$SANDBOX_HOME/\.claude/\.credentials\.json" \]; then' "$SANDBOX_DIR/entrypoint.sh"
grep -q 'install -o "\$SANDBOX_USER" -g "\$SANDBOX_USER" -m 600 /dev/null "\$SANDBOX_HOME/\.claude/\.credentials\.json"' "$SANDBOX_DIR/entrypoint.sh"
}
@test "bin/dejima refuses to launch when the proxy fails to start" {
@@ -445,9 +729,8 @@ setup() {
grep -q 'SANDBOX_CREDENTIAL_PROXY=0' "$REPO_ROOT/bin/dejima"
}
@test "cleanup reaps the proxy and its socket dir and mask file" {
@test "cleanup reaps the proxy and its socket dir" {
grep -q 'kill "\$SANDBOX_ANTHROPIC_PROXY_PID"' "$REPO_ROOT/bin/dejima"
grep -q '"\${SANDBOX_CREDENTIALS_MASK_FILE:-}"' "$REPO_ROOT/bin/dejima"
grep -q '"\${SANDBOX_ANTHROPIC_PROXY_DIR:-}"' "$REPO_ROOT/bin/dejima"
}
@@ -493,27 +776,34 @@ setup() {
[ "$status" -ne 0 ]
}
@test "bin/dejima mounts targets of top-level ~/.claude symlinks (statusline.sh)" {
# settings.json's statusLine points at ~/.claude/statusline.sh, which on
# this setup is a symlink into dotfiles - previously only the five
# overlay subdirectories were scanned for symlinks, so the target dir
# never got mounted and the statusline silently died in the sandbox.
grep -q 'for overlay_dir in \. hooks skills commands agents output-styles' "$REPO_ROOT/bin/dejima"
@test "bin/dejima scans only the mounted ~/.claude dirs for symlink targets" {
# A symlink inside one of these has to have its target reachable, or the
# hook/skill it points at can't run in the container. Top-level files
# (settings.json, CLAUDE.md, statusline.sh) are staged as copies instead,
# and Docker resolves any symlink among them when it sets up that mount -
# so scanning "." as well would mount a whole config checkout read-only
# into the container for nothing.
grep -q 'for mounted_dir in hooks skills commands agents output-styles' "$REPO_ROOT/bin/dejima"
}
@test "bin/dejima mounts ~/.claude/statusline.sh itself read-only, when present" {
# 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, none of which have this gap) - settings.json's
# statusLine.command points at it and Claude Code executes it on the
# host. Mounting a symlink's *target* dir read-only (the test above)
# doesn't help: the symlink/file at ~/.claude/statusline.sh itself still
# lives on the writable mount and a compromised session can unlink it and
# drop its own script in its place. Must be conditional on existence
# (not every host has a statusline, and mounting a nonexistent source
# would make Docker create a root-owned directory at the target
# instead).
grep -q 'extra_mounts+=(-v "\$HOME/\.claude/statusline\.sh:\$HOME/\.claude/statusline\.sh:ro")' "$REPO_ROOT/bin/dejima"
@test "bin/dejima stages ~/.claude/statusline.sh for copying, when present" {
# settings.json's statusLine.command points at it and Claude Code
# executes it on the host, so it's the same class of file as the config
# staged at /etc/dejima-claude by docker-compose.yml, and staged the same
# way. A read-only mount at its real path protected only the
# plain-regular-file case: when statusline.sh is itself a symlink (the
# common case, into a dotfiles checkout), bind-mount destination
# resolution followed it, so the mount landed on the target and left the
# symlink dentry writable and replaceable. Docker resolves that symlink
# when it sets up the staging mount instead, so what entrypoint.sh copies
# in is the real content at a path nothing on the host can reach. Still
# conditional on existence: not every host has a statusline, and mounting
# a nonexistent source would make Docker create a root-owned directory at
# the target instead.
grep -q 'extra_mounts+=(-v "\$HOME/\.claude/statusline\.sh:/etc/dejima-claude/statusline\.sh:ro")' "$REPO_ROOT/bin/dejima"
grep -q 'install -o root -g root -m 555 /etc/dejima-claude/statusline\.sh' "$SANDBOX_DIR/entrypoint.sh"
run grep -F 'extra_mounts+=(-v "$HOME/.claude/statusline.sh:$HOME/.claude/statusline.sh' "$REPO_ROOT/bin/dejima"
[ "$status" -ne 0 ]
}
@test "bin/dejima refuses to launch rather than silently skip a dangling statusline.sh symlink" {
@@ -531,10 +821,11 @@ setup() {
}
@test "bin/dejima-audit monitors the home-level ~/.claude/statusline.sh (Finding 1 compensating control)" {
# The read-only mount above does not reliably protect statusline.sh when
# it's a symlink (confirmed live-container gap - see test/runtime.bats
# and bin/dejima's comment on the mount) - detection is the
# compensating control. Full behavioral coverage (snapshot/compare
# Staging it (the test above) is what actually closes the symlink gap the
# old read-only mount left open; this stays as defence in depth, since a
# session reaching the host statusline by some other route is exactly the
# kind of thing an exit-time audit exists to surface. Full behavioral
# coverage (snapshot/compare
# against real fixtures, including the symlink-swap exploit) lives in
# test/audit.bats; this just confirms the wiring exists.
grep -q 'STATUSLINE_PATH=' "$REPO_ROOT/bin/dejima-audit"
@@ -824,7 +1115,291 @@ EOF
grep -q 'dejima-dep add cargo:cargo-nextest --global' "$REPO_ROOT/README.md"
}
@test "README documents that the exit-time audit's findings are not attributed to the session" {
# Presence-only grep - it confirms the caveat sentence exists in the
# Security notes teaser, not that the linked docs/security.md
# explanation it points readers to is itself correct or complete.
grep -q 'not a verdict on who caused them' "$REPO_ROOT/README.md"
}
@test "docs/security.md documents the ~/.claude/projects cross-session false-positive case" {
# Presence-only grep, same caveat as above: confirms the specific
# ~/.claude/projects wording landed, not that the surrounding
# reasoning is sound.
grep -q 'CROSS-PROJECT. finding' "$REPO_ROOT/docs/security.md"
grep -q 'directory shared by' "$REPO_ROOT/docs/security.md"
}
@test "sandbox-context.sh no longer mentions ALLOWED_DOMAINS" {
run grep -n 'ALLOWED_DOMAINS' "$REPO_ROOT/hooks/sandbox-context.sh"
[ "$status" -ne 0 ]
}
# The repo is public; the operator's own network is not. Both shapes below
# leaked into it once (a .localdomain Gitea host in bin/gitea-pr, a real LAN
# address as the Loki example in docker-compose.yml), so they are pinned
# here. Documentation addresses belong in RFC 5737's ranges (192.0.2.0/24,
# 198.51.100.0/24, 203.0.113.0/24) and documentation names under RFC 2606's
# reserved TLDs.
#
# test/ is deliberately out of scope: fixtures stub real resolver answers
# (project.bats feeds dig a 10.0.0.1) and those are inputs to a test, not
# addresses an operator is being told to use.
#
# This catches address- and hostname-shaped leaks only. A personal domain
# that looks like any other public one is not mechanically distinguishable
# and still rests on review.
SCANNED_PATHS=(README.md docs docker bin hooks install.sh systemd .gitea)
@test "no private or link-local address is used as a documentation example" {
run grep -rnIE '\b(10|127\.0\.0\.[2-9]|169\.254|192\.168|172\.(1[6-9]|2[0-9]|3[01]))\.[0-9]{1,3}\.[0-9]{1,3}\b' \
"${SCANNED_PATHS[@]/#/$REPO_ROOT/}"
[ "$status" -ne 0 ] || {
echo "use an RFC 5737 documentation address instead:" >&2
echo "$output" >&2
false
}
}
@test "no hostname under a private-network suffix is used as a documentation example" {
run grep -rnIE '[A-Za-z0-9-]+\.(localdomain|lan|internal|home\.arpa)\b' \
"${SCANNED_PATHS[@]/#/$REPO_ROOT/}"
[ "$status" -ne 0 ] || {
echo "use an RFC 2606 reserved name (example.com, .invalid, .test) instead:" >&2
echo "$output" >&2
false
}
}
# --- Task 9: startup warning about unacknowledged CRITICAL audit history ---
@test "dejima warns about unacknowledged critical history before starting" {
grep -q 'CRITICAL.txt' "$REPO_ROOT/bin/dejima"
}
@test "the startup warning is emitted before the container runs" {
warn_line=$(grep -n 'unacknowledged CRITICAL' "$REPO_ROOT/bin/dejima" | head -1 | cut -d: -f1)
run_line=$(grep -n 'docker compose -f "\$COMPOSE_FILE" run' "$REPO_ROOT/bin/dejima" | head -1 | cut -d: -f1)
[ "$warn_line" -lt "$run_line" ]
}
@test "the startup warning does not block when stdin is not a tty" {
grep -q 'DEJIMA_AUDIT_STRICT' "$REPO_ROOT/bin/dejima"
grep -q '\-t 0' "$REPO_ROOT/bin/dejima"
}
# The three tests above are static (they hold even for a warning that never
# actually runs correctly). The ones below drive bin/dejima's real startup
# path end-to-end against a stubbed `docker` - same technique as
# test/project.bats' launch/launch_setup and test/remote.bats' stub_launch -
# so the warning is checked doing its actual job, not just present in the
# source.
teardown() {
rm -rf ${DEJIMA_T:+"$DEJIMA_T"}
}
startup_warning_setup() {
DEJIMA_T="$(mktemp -d)"
mkdir -p "$DEJIMA_T/home/.claude" "$DEJIMA_T/config" "$DEJIMA_T/audit" "$DEJIMA_T/ws"
echo '{}' > "$DEJIMA_T/home/.claude/settings.json"
cat > "$DEJIMA_T/docker" <<'EOF'
#!/bin/bash
echo "$*" >> "${SANDBOX_TEST_DOCKER_LOG:-/dev/null}"
exit 0
EOF
chmod +x "$DEJIMA_T/docker"
: > "$DEJIMA_T/sandbox-key"
chmod 600 "$DEJIMA_T/sandbox-key"
}
# Redirected from /dev/null, so this is deterministically the "stdin is not a
# tty" path - real interactive [c]ontinue/[q]uit behavior needs a pty, which
# isn't available here (see the task report).
startup_warning_launch() {
HOME="$DEJIMA_T/home" PATH="$DEJIMA_T:$PATH" \
DEJIMA_CONFIG_DIR="$DEJIMA_T/config" \
DEJIMA_AUDIT_DIR="$DEJIMA_T/audit" \
SANDBOX_TEST_DOCKER_LOG="$DEJIMA_T/docker-log" \
SANDBOX_CREDENTIAL_PROXY=0 \
SANDBOX_SSH_KEY="$DEJIMA_T/sandbox-key" \
SSH_AUTH_SOCK= ALLOWED_DOMAINS= \
"$REPO_ROOT/bin/dejima" "$DEJIMA_T/ws" < /dev/null
}
startup_warning_slug() { printf '%s' "$DEJIMA_T/ws" | sed 's/[^a-zA-Z0-9]/-/g'; }
@test "bin/dejima's startup path stays silent about the audit with no unacknowledged CRITICAL history" {
startup_warning_setup
run startup_warning_launch
[ "$status" -eq 0 ]
[[ "$output" != *"unacknowledged CRITICAL"* ]]
grep -q ' run --rm ' "$DEJIMA_T/docker-log"
}
@test "bin/dejima's startup warning fires for an unacknowledged CRITICAL report and still launches headlessly" {
startup_warning_setup
mkdir -p "$DEJIMA_T/audit/$(startup_warning_slug)"
: > "$DEJIMA_T/audit/$(startup_warning_slug)/2020-01-01-000000-CRITICAL.txt"
run startup_warning_launch
[ "$status" -eq 0 ]
[[ "$output" == *"1 unacknowledged CRITICAL"* ]]
[[ "$output" == *"dejima-audit ack"* ]]
grep -q ' run --rm ' "$DEJIMA_T/docker-log"
}
@test "DEJIMA_AUDIT_STRICT=1 refuses to start over unacknowledged CRITICAL history when stdin is not a tty" {
startup_warning_setup
mkdir -p "$DEJIMA_T/audit/$(startup_warning_slug)"
: > "$DEJIMA_T/audit/$(startup_warning_slug)/2020-01-01-000000-CRITICAL.txt"
DEJIMA_AUDIT_STRICT=1 run startup_warning_launch
[ "$status" -ne 0 ]
[[ "$output" == *"refusing to start"* ]]
! grep -q ' run --rm ' "$DEJIMA_T/docker-log" 2>/dev/null
}
@test "bin/dejima's startup path is unaffected when the audit dir does not exist yet" {
# The common case for a fresh install / a workspace with no history at
# all: DEJIMA_AUDIT_DIR itself (not just the per-workspace subdir) is
# absent, so `find` on it fails - this must not abort the script under
# bin/dejima's `set -euo pipefail`.
startup_warning_setup
rm -rf "$DEJIMA_T/audit"
run startup_warning_launch
[ "$status" -eq 0 ]
[[ "$output" != *"unacknowledged CRITICAL"* ]]
grep -q ' run --rm ' "$DEJIMA_T/docker-log"
}
@test "entrypoint terminates TLS for api.anthropic.com on loopback by default" {
# The transport is normalized in exactly one place so the forwarder, the
# cert generation, and the readiness probe cannot disagree about which
# mode this session is in.
grep -q 'SANDBOX_ANTHROPIC_TRANSPORT="\${SANDBOX_ANTHROPIC_TRANSPORT:-tls}"' "$SANDBOX_DIR/entrypoint.sh"
# CN alone is not enough - Node verifies the SAN.
grep -q 'subjectAltName=DNS:api.anthropic.com' "$SANDBOX_DIR/entrypoint.sh"
grep -q 'CN=api.anthropic.com' "$SANDBOX_DIR/entrypoint.sh"
grep -q 'NODE_EXTRA_CA_CERTS' "$SANDBOX_DIR/entrypoint.sh"
grep -q '127.0.0.1 api.anthropic.com' "$SANDBOX_DIR/entrypoint.sh"
}
@test "entrypoint aborts when the forwarder never binds" {
# A silent timeout would leave api.anthropic.com pointed at a loopback
# port with nothing behind it - every API call then fails with a
# connection error that gives no hint where it came from.
grep -q 'the credential-proxy forwarder never bound' "$SANDBOX_DIR/entrypoint.sh"
grep -q 'SANDBOX_ANTHROPIC_TRANSPORT=loopback' "$SANDBOX_DIR/entrypoint.sh"
}
@test "the hosts entry is written only when the proxy is actually running" {
# Under SANDBOX_CREDENTIAL_PROXY=0 there is no listener on :443, so a
# hosts entry would black-hole the real API - which is why this cannot be
# a compose extra_hosts entry. It must sit inside the proxy-socket guard.
local guard body
guard=$(grep -n 'if \[ -n "\${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}" \]' "$SANDBOX_DIR/entrypoint.sh" | cut -d: -f1)
[ -n "$guard" ]
body=$(grep -n '127.0.0.1 api.anthropic.com' "$SANDBOX_DIR/entrypoint.sh" | cut -d: -f1)
[ "$body" -gt "$guard" ]
}
@test "openssl is installed explicitly, not relied on transitively" {
# entrypoint.sh generates the terminator's cert at startup; a transitive
# dependency dropping out of the base image would break the credential
# path at runtime rather than at build time.
grep -qE '^\s+openssl \\$' "$SANDBOX_DIR/Dockerfile"
}
@test "entrypoint fails loudly, with a reason, if openssl cert generation fails" {
# set -e already aborts on openssl's nonzero exit before chown/export can
# touch a missing cert, so a failure here can't silently proceed or leak
# the credential - but redirecting openssl's stderr to /dev/null would
# still leave it dying silent-but-loud: nonzero exit, no explanation.
grep -q 'if ! openssl_err=\$(openssl req -x509' "$SANDBOX_DIR/entrypoint.sh"
grep -q 'FATAL: openssl failed to generate the TLS terminator cert' "$SANDBOX_DIR/entrypoint.sh"
grep -q 'echo "\$openssl_err" >&2' "$SANDBOX_DIR/entrypoint.sh"
}
@test "compose passes the transport through and lets an unprivileged process bind :443" {
grep -qE '^\s*SANDBOX_ANTHROPIC_TRANSPORT:\s*\$\{SANDBOX_ANTHROPIC_TRANSPORT:-\}\s*$' "$SANDBOX_DIR/docker-compose.yml"
# Docker already defaults this to 0, but the terminator runs as the
# unprivileged sandbox user: pinning it means a future default change
# fails at launch instead of silently breaking the credential path.
grep -qE '^\s*net\.ipv4\.ip_unprivileged_port_start:\s*0\s*$' "$SANDBOX_DIR/docker-compose.yml"
}
@test "compose does not pin api.anthropic.com to loopback unconditionally" {
# That belongs in entrypoint.sh, inside the proxy guard - see the
# entrypoint test above for why an unconditional entry breaks the
# SANDBOX_CREDENTIAL_PROXY=0 path.
! grep -q 'extra_hosts' "$SANDBOX_DIR/docker-compose.yml"
}
@test "bin/dejima defaults to the tls transport and exports it" {
grep -q 'SANDBOX_ANTHROPIC_TRANSPORT="\${SANDBOX_ANTHROPIC_TRANSPORT:-tls}"' "$REPO_ROOT/bin/dejima"
grep -q 'export .*SANDBOX_ANTHROPIC_TRANSPORT' "$REPO_ROOT/bin/dejima"
}
@test "tls mode mounts a fake credential, never the real one, and sets no ANTHROPIC_* pair" {
# tls mode is the one case that puts a credentials file into the
# container: Claude Code reads .credentials.json directly when there is no
# ANTHROPIC_AUTH_TOKEN, so the empty container-local file entrypoint.sh
# would otherwise leave isn't enough - it needs a well-formed document,
# with the secrets faked and the non-secret fields
# (scopes/subscriptionType/rateLimitTier/clientId) copied so the session's
# own UI state stays accurate.
grep -q 'extra_mounts+=(-v "\$SANDBOX_FAKE_CREDENTIALS_FILE:\$HOME/\.claude/\.credentials\.json")' "$REPO_ROOT/bin/dejima"
grep -q 'sandbox-credential-proxy-placeholder' "$REPO_ROOT/bin/dejima"
grep -q 'subscriptionType' "$REPO_ROOT/bin/dejima"
grep -q 'rateLimitTier' "$REPO_ROOT/bin/dejima"
# Expiry is deliberately NOT mirrored from the real credential: a
# near-term expiresAt makes Claude Code refresh with the fake refresh
# token, fail, and log the session out.
grep -q 'refreshTokenExpiresAt' "$REPO_ROOT/bin/dejima"
}
@test "the loopback transport still sets the base URL and placeholder token" {
grep -q 'SANDBOX_ANTHROPIC_BASE_URL="http://127.0.0.1:18300"' "$REPO_ROOT/bin/dejima"
grep -q 'SANDBOX_ANTHROPIC_AUTH_TOKEN="sandbox-credential-proxy-placeholder"' "$REPO_ROOT/bin/dejima"
}
@test "api.anthropic.com is in the fixed domains only when the proxy is off" {
# Not unconditionally allowlisted: with the proxy on, the session reaches
# the API through the forwarder rather than by resolving this name, so
# leaving it unresolvable defeats a bypass that looks it up by DNS. When
# the proxy is off (kill
# switch or API-key auth) DNS is the only path to the API, so it has to be
# allowlisted - and in the *fixed* list, not EXTRA_ALLOWED_DOMAINS, which
# dns-proxy.sh exempts from stop-dns-rebind and init-firewall.sh treats as
# non-fatal when it fails to resolve.
run bash -c "SANDBOX_ANTHROPIC_PROXY_SOCKET=/tmp/proxy.sock; source '$SANDBOX_DIR/fixed-domains.sh'; printf '%s\n' \"\${FIXED_DOMAINS[@]}\""
[ "$status" -eq 0 ]
! grep -qx 'api.anthropic.com' <<< "$output"
run bash -c "SANDBOX_ANTHROPIC_PROXY_SOCKET=; source '$SANDBOX_DIR/fixed-domains.sh'; printf '%s\n' \"\${FIXED_DOMAINS[@]}\""
[ "$status" -eq 0 ]
grep -qx 'api.anthropic.com' <<< "$output"
# Unset (a bare `docker run` that passes no socket) must behave like empty.
# Under `set -u`, as both consumers run, so that dropping the `:-` default
# fails here rather than at container start.
run bash -c "set -u; unset SANDBOX_ANTHROPIC_PROXY_SOCKET; source '$SANDBOX_DIR/fixed-domains.sh'; printf '%s\n' \"\${FIXED_DOMAINS[@]}\""
[ "$status" -eq 0 ]
grep -qx 'api.anthropic.com' <<< "$output"
# ...and entrypoint.sh no longer adds it to the extra list, which would
# reinstate both of the exemptions above.
! grep -q 'extra_domains.*api.anthropic.com' "$SANDBOX_DIR/entrypoint.sh"
}
@test "an unrecognised SANDBOX_ANTHROPIC_TRANSPORT is rejected, not silently treated as loopback" {
# bin/dejima branches on `loopback`, entrypoint.sh and the forwarder on
# `tls` - so a typo configures the two halves for different transports and
# every API call fails with nothing naming the cause.
grep -q 'FATAL: SANDBOX_ANTHROPIC_TRANSPORT is set to' "$SANDBOX_DIR/entrypoint.sh"
grep -q "The only valid values are 'tls' (the default) and 'loopback'" "$SANDBOX_DIR/entrypoint.sh"
# Ahead of the proxy-socket guard, so it catches the typo whatever else
# this session is configured for.
local check guard
check=$(grep -n 'FATAL: SANDBOX_ANTHROPIC_TRANSPORT is set to' "$SANDBOX_DIR/entrypoint.sh" | cut -d: -f1)
guard=$(grep -n 'if \[ -n "\${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}" \]' "$SANDBOX_DIR/entrypoint.sh" | cut -d: -f1)
[ "$check" -lt "$guard" ]
}