Compare commits

...
Author SHA1 Message Date
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 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 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 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 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 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
26 changed files with 6322 additions and 471 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
+322 -334
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
@@ -65,11 +134,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 +275,29 @@ 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.
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 +307,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,7 +317,7 @@ 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
@@ -112,10 +325,11 @@ SANDBOX_EXTRA_MOUNTS=~/dotfiles/bin bin/dejima ~/repos/some-project
`~/.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.)
itself doesn't enter the container directly (see
[security notes](docs/security.md) 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](docs/security.md) for exactly what that means.)
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 +343,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 +372,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 +389,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 +405,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`.
@@ -222,6 +439,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
@@ -246,7 +474,7 @@ default:
- 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 +506,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 +699,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 +720,30 @@ 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, and the deliberate
trade-offs behind `--dangerously-skip-permissions`.
- `--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 +754,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` read-only overlay lines are present, `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 +777,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 +805,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 +817,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
+153 -7
View File
@@ -139,12 +139,6 @@ 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"
# 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.
@@ -188,12 +182,44 @@ 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() {
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 "${DEJIMA_SESSION_FILE:-}" "${SANDBOX_REMOTE_SETTINGS_TMP:-}"
# The one removal here a session can make fail, and the last command of an
# EXIT trap: 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
}
trap cleanup EXIT
@@ -382,7 +408,7 @@ 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
# bin/dejima-anthropic-proxy and docs/security.md), 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
@@ -434,6 +460,126 @@ if [ "${SANDBOX_CREDENTIAL_PROXY:-1}" != "0" ] \
fi
export SANDBOX_ANTHROPIC_BASE_URL SANDBOX_ANTHROPIC_AUTH_TOKEN SANDBOX_ANTHROPIC_PROXY_SOCKET
# 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 generated copy 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 copy, so this rewrite cannot reach the
# operator's real settings.json; the copy is mounted read-only, so the
# session cannot unregister itself either. $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).
"$SCRIPT_DIR/dejima-audit" snapshot "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
+2 -3
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
@@ -131,7 +130,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}}
+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
+1 -1
View File
@@ -5,7 +5,7 @@
# 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:-}"
+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}"
+136 -93
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
@@ -59,45 +60,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,6 +83,120 @@ ENV DEJIMA=1
# this user, so it doesn't need to own anything itself.
RUN useradd -r -s /usr/sbin/nologin ulogd
# 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 --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"
# gosu lets entrypoint.sh (root) drop to the node user for the actual
# session after setting up the firewall, without leaving any setuid/sudo
# escalation path available to node afterward. Verified against the
# published SHA256SUMS since it runs as root.
ARG GOSU_VERSION=1.19
RUN ARCH=$(dpkg --print-architecture) && \
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
# Install the latest stable Neovim release. Debian's packaged version is too
# 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 --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 --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
# Install the latest stable Go toolchain. go.dev's own release JSON gives
# both the download URL and a SHA256 per file, verified below since this
# runs as root - same pattern as gosu above. `go build`/`go mod download`
# need proxy.golang.org and sum.golang.org reachable at runtime - see
# fixed-domains.sh.
RUN ARCH=$(dpkg --print-architecture) && \
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 --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
ENV PATH=$PATH:/usr/local/go/bin
# System libraries Chromium needs (fonts, libnss, libatk, etc.) for
# Playwright, installed here since it needs apt/root - the actual
# `playwright` package and browser binary install as node, further down,
# so they land in node's own npm prefix/cache correctly.
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
@@ -131,58 +213,6 @@ RUN mkdir -p $SANDBOX_HOME/.ssh && \
RUN mkdir -p $SANDBOX_HOME/.local/share/nvim $SANDBOX_HOME/.local/state/nvim $SANDBOX_HOME/.cache/nvim && \
chown -R $SANDBOX_USER:$SANDBOX_USER $SANDBOX_HOME/.local $SANDBOX_HOME/.cache
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" && \
dpkg -i "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
rm "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb"
# gosu lets entrypoint.sh (root) drop to the node user for the actual
# session after setting up the firewall, without leaving any setuid/sudo
# escalation path available to node afterward. Verified against the
# 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" && \
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
# Install the latest stable Neovim release. Debian's packaged version is too
# 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 \
| jq -r '[.assets[] | select(.name | test("linux.*x86_64.*\\.tar\\.gz$"))][0].browser_download_url') && \
wget -O nvim.tar.gz "$asset_url" && \
tar -C /usr/local --strip-components=1 -xzf nvim.tar.gz && \
rm nvim.tar.gz
# Install the latest stable Go toolchain. go.dev's own release JSON gives
# both the download URL and a SHA256 per file, verified below since this
# runs as root - same pattern as gosu above. `go build`/`go mod download`
# 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') && \
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}" && \
echo "${sha256} go.tar.gz" | sha256sum -c - && \
tar -C /usr/local -xzf go.tar.gz && \
rm go.tar.gz
ENV PATH=$PATH:/usr/local/go/bin
# System libraries Chromium needs (fonts, libnss, libatk, etc.) for
# Playwright, installed here since it needs apt/root - the actual
# `playwright` package and browser binary install as node, further down,
# so they land in node's own npm prefix/cache correctly.
ARG PLAYWRIGHT_VERSION=1.61.1
RUN npx -y playwright@${PLAYWRIGHT_VERSION} install-deps chromium
# Set up non-root user
USER $SANDBOX_USER
@@ -205,14 +235,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 +285,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 +301,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
+2 -9
View File
@@ -62,7 +62,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,7 +78,7 @@ 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
@@ -152,13 +152,6 @@ services:
# 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
+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;
}
}
+671
View File
@@ -0,0 +1,671 @@
# 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 copy
it already generates for that session, never into your real
`~/.claude/settings.json`, which every sandbox's copy derives from and your
host's own Claude Code reads directly. That copy 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 settings copy, not in your real `settings.json`:
`grep -c dejima-notify ~/.claude/settings.json` from inside should be 2.
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.
+306
View File
@@ -0,0 +1,306 @@
# 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 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.
- 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. `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 `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
`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 `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, `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" 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
+28 -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,18 @@ 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.
- 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
+2 -2
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" {
+2530
View File
File diff suppressed because it is too large Load Diff
+141 -3
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 thirteen 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
@@ -134,6 +160,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 +188,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 +243,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
@@ -273,6 +302,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 +321,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 +349,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" \
@@ -348,11 +380,19 @@ dexec_node() {
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, and the read below fails having proved nothing. Writable
# rather than merely readable so that the :ro mount stays the only
# reason the two writes above it fail.
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 \
-e GH_TOKEN="${GH_TOKEN:-}" \
-v "$dotclaude:/home/node/.claude" \
-v "$dotclaude/statusline.sh:/home/node/.claude/statusline.sh:ro" \
--entrypoint /usr/local/bin/entrypoint.sh \
@@ -415,11 +455,18 @@ 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. This one
# asserts the overwrite *succeeds*, so node needs to be able to unlink
# and create in $dotclaude and write through the symlink into
# $target_dir - otherwise the confirmed gap this documents would look
# closed for a reason that has nothing to do with 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 \
-e GH_TOKEN="${GH_TOKEN:-}" \
-v "$dotclaude:/home/node/.claude" \
-v "$target_dir:$target_dir:ro" \
-v "$dotclaude/statusline.sh:/home/node/.claude/statusline.sh:ro" \
@@ -556,12 +603,22 @@ 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 ANTHROPIC_AUTH_TOKEN=sandbox-credential-proxy-placeholder \
-v "$TD:$TD" \
@@ -621,6 +678,7 @@ EOF
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
-e SANDBOX_TEST_MODE=1 \
-e GH_TOKEN="${GH_TOKEN:-}" \
-v "$TD/dotclaude:/home/node/.claude" \
-v "$mask_file:/home/node/.claude/.credentials.json" \
--entrypoint /usr/local/bin/entrypoint.sh \
@@ -704,13 +762,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 +819,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"
@@ -789,3 +857,73 @@ 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 ]
}
+154 -4
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
@@ -316,13 +358,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" {
@@ -828,3 +942,39 @@ EOF
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
}
}