Compare commits

...
Author SHA1 Message Date
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 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
branderson 45d25f6017 README: soften intro wording now that Dockerfile/init-firewall.sh have been reworked
Now that the licensing pass rewrote the near-verbatim blocks, 'Based on'
overstates how much of the current code traces directly to Anthropic's
reference devcontainer.
2026-07-27 17:43:58 -07:00
branderson 70f1d9a490 Add GPLv3-or-later license
Adds the canonical GPLv3 LICENSE text plus a README note. Also rewrites
the near-verbatim blocks in docker/Dockerfile and docker/init-firewall.sh
that were copied from Anthropic's proprietary reference devcontainer, so
none of that proprietary source remains under this repo's new license.
Behavior is unchanged; test/static.bats still passes 94/94.
2026-07-27 17:40:10 -07:00
branderson 8db4161deb Remove migration note 2026-07-27 15:59:17 -07:00
branderson 1ccf10cffc Merge pull request 'Rename claude-sandbox to dejima' (#12) from rename-dejima into main
Reviewed-on: branderson/dejima#12
2026-07-27 15:56:11 -07:00
branderson b3ab3d229b Rename claude-sandbox to dejima
The old name baked a single vendor into a tool that is otherwise
model-agnostic in every respect that matters: it sandboxes a process
against a bind-mounted project behind an egress firewall. Nothing about
the container, firewall, credential proxy, or PR workflow is
Claude-specific. The name was the only thing holding it to one agent.

Dejima was an artificial island in Nagasaki harbour, the only Japanese
territory open to Westerners for over two centuries, joined to the
mainland by one guarded bridge that nothing crossed in either direction
without permission. It fits better than the alternatives because the
containment runs both ways - the firewall and credential proxy keep the
world out, and the island keeps the agent in - and because the constraint
was generative rather than merely restrictive.

Mechanical surface: 7 bin/ scripts renamed, CLAUDE_SANDBOX* -> DEJIMA*
(including the bare in-container marker set by the Dockerfile), compose
project name, image tags, dejima-net/dejima-dns, ~/.config/dejima,
~/.local/share/dejima/shared, and the Loki conversation job label.

Deliberately unchanged: SANDBOX_* env vars carry no vendor name and
renaming them would break every user's shell profile for no gain -
"sandbox" stays the generic noun, "dejima" is the product name.
claude-wrapper.sh genuinely wraps the claude binary. The default entry
point is still claude; real model-agnosticism is future work, this only
removes the naming obstacle to it.

install.sh now removes dangling ~/.local/bin/claude-sandbox* symlinks -
it created them, so it retires them. It only deletes symlinks that are
actually broken, so an unrelated install that still resolves is left
alone. README documents the approval-store and share-dir moves, the
orphaned Docker volumes, and the changed Loki label.

static/project/audit/proxy: 204 passed, 0 failed. runtime.bats needs
Docker and could not run in the sandbox.
2026-07-27 15:50:39 -07:00
branderson 1021cf1837 Merge pull request 'Per-project and global scoped allowed domains and dependencies' (#8) from per-project-config into main
Reviewed-on: branderson/claude-sandbox#8
2026-07-27 15:36:37 -07:00
branderson b54887c6d3 README: drop the ALLOWED_DOMAINS deprecation notes entirely
Documenting the removed env input, even to say it's ignored, is the only
thing keeping it visible: nobody outside this repo ever used it, so there
is no one to warn. Treat it as though it never existed.

The launcher still refuses to merge an exported ALLOWED_DOMAINS, and
test/project.bats still pins that - the behaviour is unchanged, it just
isn't advertised.
2026-07-27 15:35:18 -07:00
branderson 8a7d558b9b test/runtime: use run ! for the derived image's sudo assertion
The derived-image test copied the base image's hardening assertions but
not the idiom the sudo one needs. `sudo -n true` exits 127, not a
permission error, because sudo isn't installed in the image at all, so
`run` + `[ "$status" -ne 0 ]` passes while emitting a BW01 warning
telling us to check the return code properly.

"node has no working sudo" already settled this 300 lines above: use
`run !`, which accepts any nonzero exit, rather than pinning the test to
one specific way sudo stays unavailable. The iptables assertion beside it
keeps the plain form - iptables is installed, so it fails with a
permission error and never trips BW01.
2026-07-27 15:35:18 -07:00
branderson 51244c1833 dns-proxy: ALLOWED_DOMAINS is no longer operator-supplied
It is the launcher-to-container channel carrying the host-side approval
store's contents; there is no operator-facing input any more.
2026-07-27 15:35:18 -07:00
branderson 8e64749684 claude-sandbox-audit: drop the dead config/env entry from the TCB snapshot
config/env was deleted with the env flow; the [ -f ] guard made the
reference inert.
2026-07-27 15:35:18 -07:00
branderson d869b232c2 test/project: close the untested global-scope paths the final review found
- ensure_global_store's mkdir -p had no discriminating test: setup()
  pre-creates CONFIG_DIR, so [ -d ] was true either way. Start from a
  removed config root, and add a CLI-level test for the first --global
  approval on a host that has no config root yet.
- claude-sandbox-allow --global with no container running had no test at
  all; the three existing --global tests either skip the session block or
  report a running container.
- the launcher's abort on a corrupt allowed-domains in either store was
  verified by hand twice and never tested.
2026-07-27 15:35:18 -07:00
branderson 3de9955d4b claude-sandbox-dep: refuse a global remove when no global store exists
remove --global against a config root that does not exist printed
"Removed <spec> from the global dependencies" and exited 0, while the
project path prints "No approved config for <path>." and exits 1. Match
the project path: a typo'd or premature remove should not be confirmed.
2026-07-27 15:35:18 -07:00
branderson fb0e9405df README: note ALLOWED_DOMAINS is now ignored, fix review findings 2026-07-27 15:35:18 -07:00
branderson cca2b5e0b5 README, sandbox-context: document global scope, drop config/env 2026-07-27 15:35:18 -07:00
branderson 1813eef9a3 claude-sandbox-project: propagate a corrupt global store as a hard failure 2026-07-27 15:35:18 -07:00
branderson 74e88dfcf1 claude-sandbox-project: treat global approvals as approved 2026-07-27 15:35:18 -07:00
branderson feeb0a969d test/project, test/runtime: assert the FROM chain, drop the unreachable layering test 2026-07-27 15:35:18 -07:00
branderson 332c809107 claude-sandbox: layer global dependencies below the project image 2026-07-27 15:35:18 -07:00
branderson 6a2ea8d036 test/project: assert the launcher still works when it ignores ALLOWED_DOMAINS
The exported-ALLOWED_DOMAINS test asserted only that evil.example was absent
from the container env, which a launcher that set no domains at all would
satisfy just as well. Seeding an approved global domain and asserting it
survives makes the test fail for both regressions rather than one.
2026-07-27 15:35:18 -07:00
branderson 72fa0b08a7 claude-sandbox: read allowed domains from the store; drop config/env 2026-07-27 15:35:18 -07:00
branderson bfa7902c11 claude-sandbox-allow: --global persists to the approval store 2026-07-27 15:35:18 -07:00
branderson 0775f28811 claude-sandbox-dep: --global scope 2026-07-27 15:35:17 -07:00
branderson 287a254540 per-project config: global approval store helper 2026-07-27 15:35:17 -07:00
branderson d63e968b4d project-lib: heal a store that lost its slug-collision guard
ensure_project_store used to write projects/<slug>/path unconditionally.
Since it started attaching to whatever resolve_project_store finds, a store
that already exists without a path file is attached to silently and stays
that way - and path is the only thing resolve_project_store checks to catch
two projects whose absolute paths sanitize to the same slug, so such a store
would be permanently unguarded and could hand one project another's
approvals. Only reachable from a degraded state (an interrupted write, a
hand-restored directory), but it is a real strength reduction.

Write the file when the resolved store lacks one. The owner recorded is the
ancestor whose slug produced that directory, not the path being approved:
for a worktree those differ, and recording the worktree would make every
later read from the real owner fail as a collision. Healing also restores
the inheritance Note, which an empty owner used to suppress.

Also drop the teardown line for the runtime test removed in 44ee1fb, and
correct the hook's claim that it reports on exactly what `approve` acts on -
within the shared cap it is a strict subset, since a dotless domain and one
longer than MAX_ENTRY_LEN are approvable host-side but never reported. Both
skips are fail-closed; the comment was the only thing wrong.
2026-07-27 15:35:17 -07:00
branderson 5b7ac7ebac test/runtime.bats: drop a test that proved nothing about approvals
It named itself after the approval property but never touched the approval
store or bin/claude-sandbox - it set ALLOWED_DOMAINS directly on a
container it started itself, so "approved" and "declared" were just two
domains, one in the env var and one not. What it actually asserted is
already asserted, on the shared container, by "a non-allowlisted domain is
refused by the DNS proxy" and "an allowlisted domain resolves via the DNS
proxy"; the cost was a second container boot per run.

The real property - an approved domain reaches ALLOWED_DOMAINS and a merely
declared one never does - is now covered against the actual launcher in
test/project.bats.
2026-07-27 15:35:17 -07:00
branderson e1b5370f98 test/static.bats: anchor the two invariants no behavioral test can reach
The dnsmasq servers file is an absolute path only the container has, so
the hook's BRE escaping of a declared domain can't be driven from a
host-side test - anchor the assertion to the matching line itself instead
of grepping the whole file, so the same expression elsewhere can't satisfy
it. Without the escaping a project declaring "aexample.com" matches the
server= line of an approved "a.example.com" and the hook reports an
unapproved domain as already allowed.

The approval-store mount check is design section 8's invariant, and had no
test at all: the store is the only thing that grants anything, so it must
appear in no compose volume and no dynamic -v either.
2026-07-27 15:35:17 -07:00
branderson 46167bbb1c test/project.bats: drive the real launcher under a stub docker
Everything covering bin/claude-sandbox was a grep over its source, which a
deleted line satisfies as readily as a working one - the merge of approved
project domains into ALLOWED_DOMAINS, the switch to the derived image, and
the store-inside-the-workspace warning all had no behavioral test at all.

Run the launcher for real against a stub `docker` that records the
environment of the `compose run` it would have made, in a throwaway $HOME
with the credential proxy off, and assert on what the container would have
been handed. Verified by mutation: dropping the ALLOWED_DOMAINS merge, or
the SANDBOX_IMAGE="$derived" assignment, or the warning block each fails
exactly one of the three new tests.
2026-07-27 15:35:17 -07:00
branderson 4fbd43685d claude-sandbox-allow: persist --project before anything needs a container
--project persistence sat after both the live-session update and the
reachability probe, so it never ran on the documented path:

- Approving dependencies requires leaving the sandbox and relaunching, so the
  normal moment to approve is when nothing is running - and with no running
  container claude-sandbox-allow exited 1 before persisting anything. Under
  claude-sandbox-project approve's set -e that killed the whole loop after the
  dependencies, silently skipping every domain.
- A declared domain that legitimately doesn't serve HTTPS on 443 (an internal
  service, an SSH-only host, an apex that only serves a subdomain) exited 2 at
  the probe, with the same effect from the first such entry onward.

Recording an approval is the user's decision and takes effect at the next
launch; patching a running session and verifying reachability are claims about
*this* run. Persist first, then treat both as best-effort and say when they
didn't happen. With --project and no container there is nothing left to do and
nothing wrong, so that exits 0.

--global semantics are unchanged and now stated as the deliberate asymmetry
they are: it stays gated on the probe, so one run cannot add a demonstrably
broken domain to every project's allowlist, and it still needs a container.

claude-sandbox-project approve now collects domains that could not be applied
to a running session and reports them at the end instead of aborting at the
first one. README prose and the probe comment updated to match.
2026-07-27 15:35:17 -07:00
branderson bbf2f73e7e project-lib: writers must resolve to the store readers will find
Every reader (bin/claude-sandbox, claude-sandbox-project, dep list) resolves
a store by walking ancestors, so a git worktree under the project inherits
the project's approvals - which is the workflow README promises and
sandbox-context.sh tells agents to use. Every writer created a store at the
exact path instead. The first approval made from inside a worktree therefore
created a store that shadowed the project's for every later read, and the
worktree silently lost every approval it had been inheriting: the derived
image lost its packages, the firewall lost its domains, and nothing said so.

ensure_project_store now returns the resolved store when one exists and only
creates a new one when nothing above covers the path. Recording into the
ancestor widens that ancestor's scope, which is the lesser surprise but still
a surprise, so it prints where the approval landed and what it now covers.
Slug-collision detection is unchanged - resolve_project_store checks the path
file at every level and fails, which now short-circuits the writer too.
2026-07-27 15:35:17 -07:00
branderson 237aaa541d sandbox-context: bound what a session-writable declaration can cost
.claude-sandbox/{dependencies,allowed-domains} live on the read-write
workspace mount, so their contents are attacker-controlled text on its way
into the next session's context window. Nothing bounded that:

- 700 long declared domains made the assembled context exceed Linux's
  128 KiB MAX_ARG_STRLEN on the `jq --arg` call, so jq failed and the hook
  emitted *nothing* - every later session in the project silently lost the
  sandbox notice, the worktree rule and the never-push-to-main rule, along
  with the report that would have named the junk.
- 5000 declared domains stayed under that limit but injected 72 KB of
  attacker-chosen tokens verbatim.
- 400 declared npm: entries forked `npm ls -g` 400 times, over two minutes,
  past Claude Code's hook timeout - which drops the context the same way.

Three bounds, plus one structural change:

- MAX_DECLARED=200 entries considered, the same cap read_declared() applies
  host-side, so the hook reports on exactly the window
  `claude-sandbox-project approve` will act on. Reading stops there.
- MAX_LISTED=20 entries named in the report; the rest are counted only.
- one cached listing per ecosystem instead of one probe per entry, so probe
  cost is O(ecosystems) rather than O(entries). Matching is now grep -qxF
  against that list, which also removes the per-name BRE escaping the cargo
  probe needed.
- the context is fed to jq on stdin (-R -s) rather than as an argv element,
  removing the MAX_ARG_STRLEN failure mode rather than staying just inside it.

Also require at least one dot in a declared domain. valid_domain()'s label
group is optional, so a bare word like IGNORE-PREVIOUS-INSTRUCTIONS validated
and was echoed verbatim under a heading that reads as the project speaking.
jq escapes it correctly, so this was never a JSON break - it was the
prompt-injection surface the missing cap made worth using.
2026-07-27 15:35:17 -07:00
branderson c0e4310797 test/static.bats: tighten install.sh wiring test to anchor on link_bin calls 2026-07-27 15:35:17 -07:00
branderson 93cf522b86 install.sh, README: document per-project domains and dependencies 2026-07-27 15:35:17 -07:00
branderson 23cf75dd49 sandbox-context: fix domain BRE escaping, trailing-newline drop, quoting, and add length cap 2026-07-27 15:35:17 -07:00
branderson 1216e2ccec sandbox-context: report declared project requirements this session lacks 2026-07-27 15:35:17 -07:00
branderson 31079f1728 claude-sandbox: tighten dep-spec validation, close derived-image test coverage gaps
- valid_dep_spec now requires the package name to start with an
  alphanumeric, so a leading-dash spec (e.g. apt:--allow-unauthenticated)
  can no longer validate as a name and land in a generated RUN line as an
  option.
- static.bats gains a test pinning the explicit base-image build to the
  base tag on that exact line, so nothing can silently drop the build step
  or its SANDBOX_IMAGE pin while the suite stays green.
- project.bats' corrupt-deps test now uses a valid line followed by a
  corrupt one and asserts zero stdout bytes, so it actually catches a
  partial-Dockerfile emission rather than just a nonzero exit status.
- runtime.bats' derived-image test now asserts the built image's own
  Config.Entrypoint via docker inspect, since the test's own
  --entrypoint override would otherwise mask a missing ENTRYPOINT line.
2026-07-27 15:35:17 -07:00
branderson bdf0b0591c claude-sandbox: build a derived image from approved project dependencies 2026-07-27 15:35:17 -07:00
branderson d5e77cd518 test/runtime: fix comment overclaiming to mirror the refusal test's convention 2026-07-27 15:34:58 -07:00
branderson 187fcdd755 test/runtime: assert DNS-proxy REFUSED positively in the project-domain test 2026-07-27 15:34:58 -07:00
branderson cd993d442e claude-sandbox: apply approved per-project domains at launch 2026-07-27 15:34:58 -07:00
branderson 56918e6dbe claude-sandbox-project: fix rejected-entry quoting, surface truncation, handle EOF on approve prompt
- Quote $rejected through sed instead of an unquoted printf, so a rejected
  entry's literal text survives word-splitting and globbing when reported.
- Surface the 200-entry truncation warning in approve (status already did),
  so a declaration over the cap doesn't look fully reviewed.
- Treat EOF on the approve confirmation prompt as a decline instead of
  letting set -e kill the script silently.

Adds 3 regression tests to test/project.bats for the above.
2026-07-27 15:34:58 -07:00
branderson 0ff1d2a9fd claude-sandbox-project: status and approve flow over declarations 2026-07-27 15:34:58 -07:00
branderson c3dfb60a7c claude-sandbox-allow: add a --project persistence scope 2026-07-27 15:34:58 -07:00
branderson cdbcd58ff0 claude-sandbox-dep: manage approved per-project dependencies 2026-07-27 15:34:58 -07:00
branderson f175390c6f resolve_project_store: guard against relative paths to prevent infinite loop 2026-07-27 15:34:58 -07:00
branderson 099172e452 project-lib: approval store, slug, ancestor resolution 2026-07-27 15:34:58 -07:00
branderson a8c9130f67 project-lib: validators and declaration parsing 2026-07-27 15:34:58 -07:00
branderson 5beecc8d9c Merge pull request 'Stop the credential proxy from printing tracebacks and 501s onto the session's terminal' (#11) from proxy-client-disconnect into main
Reviewed-on: branderson/claude-sandbox#11
2026-07-27 15:33:08 -07:00
branderson 18978751a4 proxy: stop reporting client disconnects as crashes, and route HEAD/OPTIONS
Two sources of noise on the host terminal, which this proxy shares with the
session it serves.

A container-side client that goes away mid-response - the session
interrupting a streaming reply, exiting with requests in flight, or dropping
a request it no longer wants - leaves the proxy writing into a closed
socket. The forwarder makes this immediate: it destroys the unix-socket
connection the moment its TCP client errors. socketserver's default
handle_error dumps a fifteen-line traceback for that, so a handful of
interrupts scrolls the user's UI away. It is an expected condition with
nothing left to deliver, so it is now silent - and only it: any other
exception still gets its full traceback.

Separately, HEAD and OPTIONS had no do_* handler, so BaseHTTPRequestHandler
answered 501 and logged one line per request. The container reads that as
the API rejecting the method, when in fact the proxy never routed it. The
accepted method set should be the upstream API's, not an artifact of which
handlers were defined here.

Reproduced both before fixing: one interrupted SSE stream plus an OPTIONS
and a HEAD produced 1140 bytes of stderr; the same sequence now produces 0.
2026-07-27 15:28:11 -07:00
branderson a171e2d00d Merge pull request 'Reap orphaned processes with an init, and authenticate the GitHub /meta fetch' (#9) from container-init into main
Reviewed-on: branderson/claude-sandbox#9
2026-07-27 15:19:17 -07:00
branderson 07d3fa3a72 Merge pull request 'gitea-pr: keep the API token out of curl's command line' (#10) from gitea-pr-token into main
Reviewed-on: branderson/claude-sandbox#10
2026-07-27 15:18:59 -07:00
branderson 41f6438c16 docker-compose: run tini as PID 1 so orphaned processes get reaped
Long sessions were dying with "Cannot fork" once the pids cgroup filled
up: 19027 of the 19109 available PIDs were unreapable <defunct> zombies.

PID 1 in the container is the session's own command - claude, or nvim
under SANDBOX_NVIM_CONFIG - and neither reaps orphans. Any child a
process leaves behind therefore survives as a zombie for the container's
whole life rather than being cleaned up by an init. The scripts do leak
some: `claude-sandbox-audit compare` leaves ten unreaped
process-substitution children per invocation and claude-sandbox-project
about two, so one full test cycle costs ~370 PIDs and roughly fifty
cycles exhausts the limit.

Fixing the leaks one at a time would not close the class - it is normal
for a shell to leave process-substitution children for init to reap, and
any tool the sandbox runs can do the same. Giving the container a real
init fixes all of them, present and future, which is what init: true is
for.
2026-07-27 14:58:12 -07:00
branderson 742d32b018 init-firewall: authenticate the GitHub /meta fetch, and report API errors
Every container start fetches https://api.github.com/meta to build the
github-domains ipset, unauthenticated. That draws on GitHub's limit of 60
requests per hour per source IP - shared across every sandbox launch and
anything else on the host - so a session that starts containers in a loop
exhausts it, and from then on every launch dies during firewall setup.

GH_TOKEN is already in the container's environment at that point
(entrypoint.sh uses it a few lines later to set up the git credential
helper); it was simply never sent here. Authenticated the limit is 5000/hr,
which this cannot realistically reach.

The failure was also misreported. A rate-limited response is a JSON body
with a `message` and no ranges - non-empty, so it passes the emptiness
check and lands on the field check, which announced "GitHub API response
missing required fields". That points at GitHub returning malformed data
rather than at a quota that resets on its own in under an hour. Surface
the API's own message when there is one, and keep the malformed-response
wording for responses that genuinely lack both.
2026-07-27 14:35:13 -07:00
39 changed files with 8187 additions and 672 deletions
+675
View File
@@ -0,0 +1,675 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+989 -84
View File
File diff suppressed because it is too large Load Diff
-140
View File
@@ -1,140 +0,0 @@
#!/bin/bash
# Add a domain to the egress firewall allowlist of a running claude-sandbox
# container, from outside it. The sandbox itself has no privilege to do this
# (node has no sudo/setuid path back to root - see this repo's README's
# Security notes section); if a sandboxed agent hits a blocked domain it
# actually needs, it should tell you the exact command to run here rather
# than try to work around the firewall.
#
# Usage: claude-sandbox-allow <domain> [--global] [--container <name>]
# claude-sandbox-allow crates.io # this running session only
# claude-sandbox-allow crates.io --global # + persist for future sessions
set -euo pipefail
domain=""
global=0
container=""
while [ "$#" -gt 0 ]; do
case "$1" in
--global) global=1; shift ;;
--container) container="$2"; shift 2 ;;
-*) echo "Unknown option: $1" >&2; exit 1 ;;
*)
if [ -n "$domain" ]; then
echo "Usage: claude-sandbox-allow <domain> [--global] [--container <name>]" >&2
exit 1
fi
domain="$1"
shift
;;
esac
done
if [ -z "$domain" ]; then
echo "Usage: claude-sandbox-allow <domain> [--global] [--container <name>]" >&2
exit 1
fi
# Validated up front, not just passed as a positional arg below: this also
# has to be a well-formed hostname to be a valid dnsmasq server=/domain/...
# line (e.g. no embedded "/"), on top of ruling out shell metacharacters. A
# sandboxed agent can suggest this exact command to the user to copy-paste
# (see sandbox-context.sh), so a prompt-injected agent handing back a
# malicious "domain" is a real path here, not just a theoretical one.
if [[ ! "$domain" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,62}\.)*[a-zA-Z0-9-]{1,63}$ ]]; then
echo "Not a valid domain: $domain" >&2
exit 1
fi
if [ -z "$container" ]; then
containers=$(docker ps --filter "label=com.docker.compose.project=claude-sandbox" --filter "status=running" --format '{{.Names}}')
count=$(echo "$containers" | grep -c . || true)
if [ "$count" -eq 0 ]; then
echo "No running claude-sandbox container found." >&2
exit 1
elif [ "$count" -gt 1 ]; then
echo "Multiple running claude-sandbox containers found; specify one with --container:" >&2
echo "$containers" >&2
echo "(a sandboxed agent's own container ID is printed in its startup context," >&2
echo "or by 'cat /etc/hostname' inside that sandbox - don't let it guess one)" >&2
exit 1
fi
container="$containers"
fi
# The DNS proxy (dns-proxy.sh) NXDOMAINs anything not in its allowlist, so
# this has to happen *before* resolving the domain below - otherwise the dig
# right after this would itself get blocked. Append a server= line to
# dnsmasq's servers-file (idempotent - skip if already present from an
# earlier call) and SIGHUP it to reload just that file, no restart needed.
# $domain is passed as a positional arg to the inner script (`$1`, inside a
# single-quoted heredoc) rather than interpolated into the script text - it's
# validated above, but this keeps it that way even if the validation regex
# above is ever loosened later.
docker exec -u root "$container" bash -c '
domain="$1"
entry="server=/${domain}/127.0.0.11"
grep -qxF "$entry" /etc/dnsmasq-sandbox-servers.conf 2>/dev/null || echo "$entry" >> /etc/dnsmasq-sandbox-servers.conf
kill -HUP "$(cat /var/run/dnsmasq-sandbox.pid)"
' -- "$domain"
echo "Resolving $domain inside $container..."
ips=$(docker exec "$container" dig +short A "$domain" | grep -E '^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$' || true)
if [ -z "$ips" ]; then
echo "Failed to resolve $domain inside the container." >&2
exit 1
fi
for ip in $ips; do
echo "Allowing $ip ($domain)"
docker exec -u root "$container" ipset add allowed-domains "$ip" -exist
done
echo "$domain allowed for the running session ($container)."
# TCP-level truth check from inside the very container that was just
# updated - this script's job is the firewall, so "TCP connect succeeded"
# is the claim to verify. curl exit 6 (resolve failed), 7 (connect
# refused/blocked) and 28 (connect or overall timeout) mean still
# unreachable; anything else (0, or a TLS/HTTP-layer code like 35/52) means
# the connection itself went through the firewall fine. --max-time bounds
# the whole request, not just the connect phase - without it, a server that
# accepts the TCP connection and then stalls (never sends TLS/HTTP bytes)
# would hang this script indefinitely; curl maps that case to the same
# exit 28 the case below already handles, so no new branch is needed. Runs
# before --global persistence so a domain that demonstrably doesn't work
# isn't persisted for future sessions on the strength of this run.
echo "Verifying $domain is reachable from $container..."
rc=0
docker exec "$container" curl -sI --connect-timeout 5 --max-time 10 "https://$domain/" >/dev/null 2>&1 || rc=$?
case "$rc" in
6|7|28)
echo "WARNING: $domain is still NOT reachable from $container (curl exit $rc)." >&2
echo "If the service doesn't listen on 443 this check can fail spuriously;" >&2
echo "otherwise the allow did not take effect - re-check the container ID." >&2
exit 2
;;
*)
echo "Verified: $domain is reachable from $container."
;;
esac
if [ "$global" -eq 1 ]; then
# readlink -f resolves through the ~/.local/bin symlink install.sh sets
# up - see bin/claude-sandbox's SCRIPT_DIR comment for why this matters.
env_file="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)/config/env"
if [ -f "$env_file" ] && grep -q '^export ALLOWED_DOMAINS=' "$env_file"; then
current=$(sed -nE 's/^export ALLOWED_DOMAINS="(.*)"$/\1/p' "$env_file")
if [[ " $current " == *" $domain "* ]]; then
echo "$domain already in the global ALLOWED_DOMAINS (config/env)."
else
new="${current:+$current }$domain"
sed -i "s|^export ALLOWED_DOMAINS=.*|export ALLOWED_DOMAINS=\"$new\"|" "$env_file"
echo "Added $domain to config/env's ALLOWED_DOMAINS (picked up automatically by the next bin/claude-sandbox invocation - no need to source anything)."
fi
else
echo "Couldn't find 'export ALLOWED_DOMAINS=' in $env_file to update." >&2
exit 1
fi
fi
-41
View File
@@ -1,41 +0,0 @@
#!/bin/bash
# Sourceable helper for bin/claude-sandbox. Parses ALLOWED_DOMAINS out of the
# repo's config/env WITHOUT executing the file: config/env sits inside this
# repo, which is itself a mountable (and thus writable-from-inside)
# workspace, so `source`-ing it would let a compromised self-mounted session
# plant arbitrary host shell for the next launch to run. Read the one value
# we actually persist there, validate it, and hand it back as a string.
# Echoes a validated ALLOWED_DOMAINS value (space-separated domain list) from
# the given file, or empty string if absent/unparseable. Never executes file
# contents.
parse_allowed_domains_env() {
local file="$1" line value
[ -f "$file" ] || return 0
while IFS= read -r line || [ -n "$line" ]; do
# strip leading whitespace; skip blanks and comments
line="${line#"${line%%[![:space:]]*}"}"
case "$line" in
''|'#'*) continue ;;
esac
# accept only: [export ]ALLOWED_DOMAINS=VALUE or ="VALUE"
case "$line" in
'export ALLOWED_DOMAINS='*) value="${line#export ALLOWED_DOMAINS=}" ;;
'ALLOWED_DOMAINS='*) value="${line#ALLOWED_DOMAINS=}" ;;
*) continue ;;
esac
# strip a single pair of surrounding double quotes, if present
if [ "${value#\"}" != "$value" ] && [ "${value%\"}" != "$value" ]; then
value="${value#\"}"; value="${value%\"}"
fi
# reject anything with shell-active characters - a domain list never
# legitimately contains these, and rejecting keeps a loosened writer
# from smuggling something executable back in downstream.
case "$value" in
*['$`;&|()<>'$'\n''\']*) value=""; continue ;;
esac
printf '%s' "$value"
return 0
done < "$file"
return 0
}
+251 -40
View File
@@ -1,19 +1,19 @@
#!/bin/bash
# Launch the claude-sandbox container, with a project directory bind-mounted
# Launch the dejima container, with a project directory bind-mounted
# at its real host path. Defaults to running bare `claude` inside (with
# --dangerously-skip-permissions); set SANDBOX_CMD to run something else
# instead (see README).
#
# Usage: claude-sandbox [path-to-project] [-- extra args...]
# claude-sandbox # sandbox the current directory
# claude-sandbox ~/repos/myproject
# Usage: dejima [path-to-project] [-- extra args...]
# dejima # sandbox the current directory
# dejima ~/repos/myproject
set -euo pipefail
# readlink -f resolves through the ~/.local/bin/claude-sandbox symlink
# readlink -f resolves through the ~/.local/bin/dejima symlink
# install.sh sets up - without it, BASH_SOURCE[0] is the symlink's own path
# when invoked by name via PATH, so dirname finds ~/.local/bin instead of
# this repo's real bin/, and everything below (REPO_ROOT, COMPOSE_FILE,
# config/env) resolves against the wrong directory entirely.
# this repo's real bin/, and everything below (REPO_ROOT, COMPOSE_FILE)
# resolves against the wrong directory entirely.
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
REPO_ROOT="$SCRIPT_DIR/.."
COMPOSE_FILE="$REPO_ROOT/docker/docker-compose.yml"
@@ -31,20 +31,39 @@ fi
WORKSPACE_DIR="$(cd "$WORKSPACE_DIR" && pwd)"
# Load persisted config (currently just ALLOWED_DOMAINS) before it's read
# below - lets `bin/claude-sandbox-allow --global` persist a domain across
# sessions without depending on the user's own shell profile chain, which a
# standalone install of this repo can't assume exists.
# config/env is parsed, never sourced: it lives inside this repo, which is
# itself a mountable/writable workspace, so executing it would be a
# host-shell persistence path for a self-mounted session. A real env var
# (exported before invoking this script - e.g. from the user's shell
# profile) still takes precedence, unchanged.
source "$SCRIPT_DIR/claude-sandbox-env.sh"
if [ -z "${ALLOWED_DOMAINS:-}" ]; then
ALLOWED_DOMAINS="$(parse_allowed_domains_env "$REPO_ROOT/config/env")"
# Allowed domains come only from the host-side approval store (see
# bin/dejima-project-lib.sh), global then per-project. The first
# assignment below is plain, not ${ALLOWED_DOMAINS:-}-preserving: the store
# is the single host-side source of truth, and an exported ALLOWED_DOMAINS
# in the user's shell profile must not be able to silently widen every
# sandbox's firewall with nothing recording that it did.
#
# Per-project domains are read from the approval store only - deliberately
# NOT from the project's own in-repo declaration directory: that lives on
# the read-write workspace mount, so treating it as a grant would let a
# session widen its own firewall for the next run. The declaration is only
# ever reported (see hooks/sandbox-context.sh) and approved by hand.
# shellcheck source=dejima-project-lib.sh
source "$SCRIPT_DIR/dejima-project-lib.sh"
ALLOWED_DOMAINS="$(approved_entries "$(config_root)" allowed-domains | tr '\n' ' ')"
PROJECT_STORE="$(resolve_project_store "$WORKSPACE_DIR")"
if [ -n "$PROJECT_STORE" ]; then
project_domains="$(approved_entries "$PROJECT_STORE" allowed-domains | tr '\n' ' ')"
ALLOWED_DOMAINS="${ALLOWED_DOMAINS}${project_domains:+ $project_domains}"
fi
# The approval store is only out of the container's reach while it isn't
# inside the mounted workspace - same class of warning as the self-mounted
# repo TCB warning below.
case "$(config_root)/" in
"$WORKSPACE_DIR"/*)
echo "Warning: the approval store ($(config_root)) is inside the mounted" >&2
echo "workspace this session - both the global approvals and every" >&2
echo "project's under it are writable from inside the sandbox, so they" >&2
echo "are no longer out of reach. Review them after this session." >&2
;;
esac
SANDBOX_SSH_KEY="${SANDBOX_SSH_KEY:-}"
if [ -n "$SANDBOX_SSH_KEY" ] && [ ! -f "$SANDBOX_SSH_KEY" ]; then
echo "SANDBOX_SSH_KEY is set but not a file: $SANDBOX_SSH_KEY" >&2
@@ -117,7 +136,7 @@ fi
# touching the actual project checkout. Persists across sessions (it's a
# real host directory, not a container volume) since the point is sharing
# things with the host, not just scratch space for one run.
SANDBOX_SHARE_DIR="${SANDBOX_SHARE_DIR:-$HOME/.local/share/claude-sandbox/shared}"
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
@@ -140,7 +159,7 @@ mkdir -p "$HOME/.claude/skills" "$HOME/.claude/commands" "$HOME/.claude/agents"
# tamper with it, so it can't be patched from inside - generate a sandbox-
# specific copy here instead and mount that in place of the real one.
# Everything else in it (hooks, permissions, plugins) carries over unchanged.
SANDBOX_SETTINGS_FILE="$(mktemp "${TMPDIR:-/tmp}/claude-sandbox-settings.XXXXXX.json")"
SANDBOX_SETTINGS_FILE="$(mktemp "${TMPDIR:-/tmp}/dejima-settings.XXXXXX.json")"
jq '.sandbox.enabled = false' "$HOME/.claude/settings.json" > "$SANDBOX_SETTINGS_FILE"
# ~/.claude.json mixes live app state (project registry, usage stats,
@@ -160,21 +179,53 @@ jq '.sandbox.enabled = false' "$HOME/.claude/settings.json" > "$SANDBOX_SETTINGS
# sandbox sessions never contend over the same file - each gets its own
# copy. See README "Throwaway ~/.claude.json" for exactly what that means
# doesn't carry over from a sandboxed session.
SANDBOX_CLAUDE_JSON_FILE="$(mktemp "${TMPDIR:-/tmp}/claude-sandbox-claude-json.XXXXXX.json")"
SANDBOX_CLAUDE_JSON_FILE="$(mktemp "${TMPDIR:-/tmp}/dejima-claude-json.XXXXXX.json")"
if [ -f "$HOME/.claude.json" ]; then
cp "$HOME/.claude.json" "$SANDBOX_CLAUDE_JSON_FILE"
else
echo '{}' > "$SANDBOX_CLAUDE_JSON_FILE"
fi
SANDBOX_AUDIT_STATE="$(mktemp "${TMPDIR:-/tmp}/claude-sandbox-audit.XXXXXX")"
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:-}"
rm -rf "$SANDBOX_GITCONFIG_DIR" "${SANDBOX_ANTHROPIC_PROXY_DIR:-}" "${SANDBOX_CREDENTIALS_TMPDIR_MASK:-}"
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
@@ -191,7 +242,7 @@ trap cleanup EXIT
# copy via GIT_CONFIG_GLOBAL instead of relying on it being at ~/.gitconfig.
# Nothing written in here ever reaches the user's real ~/.gitconfig either
# way, since it's a copy in its own directory, not the real file/parent.
SANDBOX_GITCONFIG_DIR="$(mktemp -d "${TMPDIR:-/tmp}/claude-sandbox-gitconfig.XXXXXX")"
SANDBOX_GITCONFIG_DIR="$(mktemp -d "${TMPDIR:-/tmp}/dejima-gitconfig.XXXXXX")"
if [ -f "$HOME/.gitconfig" ]; then
cp "$HOME/.gitconfig" "$SANDBOX_GITCONFIG_DIR/.gitconfig"
else
@@ -275,7 +326,7 @@ if [ -n "${SANDBOX_NVIM_CONFIG:-}" ]; then
# volume below already keeps the actual installed plugin files across
# sessions - next to what a fully writable config directory would risk:
# arbitrary Lua that runs unsandboxed the next time nvim starts for real.
SANDBOX_NVIM_LOCKFILE="$(mktemp "${TMPDIR:-/tmp}/claude-sandbox-nvim-lock.XXXXXX.json")"
SANDBOX_NVIM_LOCKFILE="$(mktemp "${TMPDIR:-/tmp}/dejima-nvim-lock.XXXXXX.json")"
if [ -f "$SANDBOX_NVIM_CONFIG/lazy-lock.json" ]; then
cp "$SANDBOX_NVIM_CONFIG/lazy-lock.json" "$SANDBOX_NVIM_LOCKFILE"
fi
@@ -336,9 +387,9 @@ fi
# replaceable. There is no mount-based fix for this: ~/.claude can't be made
# read-only (session history has to stay writable), and mounting over the
# resolved target instead of the symlink path isn't an option either, since
# bin/claude-sandbox has no way to force the *destination* resolution to stop
# bin/dejima has no way to force the *destination* resolution to stop
# at the symlink. The compensating control is detection, not prevention:
# bin/claude-sandbox-audit hashes the symlink's target string (so a
# bin/dejima-audit hashes the symlink's target string (so a
# repointed or replaced statusline.sh is caught) and reports it as a
# host-code-execution finding after the session exits. Keep this mount
# anyway - it's a real, verified fix for the regular-file case, and a no-op
@@ -363,7 +414,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/claude-sandbox-anthropic-proxy and README "Security notes"), mount an
# bin/dejima-anthropic-proxy and README "Security notes"), mount an
# empty file over ~/.claude/.credentials.json and an empty read-only
# directory over ~/.claude/.credentials-proxy-tmp (the proxy's write-back
# staging directory - the parent ~/.claude mount must stay read-write, so
@@ -381,9 +432,9 @@ SANDBOX_CREDENTIALS_TMPDIR_MASK=""
SANDBOX_ANTHROPIC_PROXY_PID=""
if [ "${SANDBOX_CREDENTIAL_PROXY:-1}" != "0" ] \
&& jq -e '.claudeAiOauth.accessToken' "$HOME/.claude/.credentials.json" >/dev/null 2>&1; then
SANDBOX_ANTHROPIC_PROXY_DIR="$(mktemp -d "${TMPDIR:-/tmp}/claude-sandbox-anthropic-proxy.XXXXXX")"
SANDBOX_ANTHROPIC_PROXY_DIR="$(mktemp -d "${TMPDIR:-/tmp}/dejima-anthropic-proxy.XXXXXX")"
SANDBOX_ANTHROPIC_PROXY_SOCKET="$SANDBOX_ANTHROPIC_PROXY_DIR/proxy.sock"
"$SCRIPT_DIR/claude-sandbox-anthropic-proxy" \
"$SCRIPT_DIR/dejima-anthropic-proxy" \
--socket "$SANDBOX_ANTHROPIC_PROXY_SOCKET" \
--credentials "$HOME/.claude/.credentials.json" &
SANDBOX_ANTHROPIC_PROXY_PID=$!
@@ -400,8 +451,8 @@ if [ "${SANDBOX_CREDENTIAL_PROXY:-1}" != "0" ] \
echo "credential is then mounted into the container, as before)." >&2
exit 1
fi
SANDBOX_CREDENTIALS_MASK_FILE="$(mktemp "${TMPDIR:-/tmp}/claude-sandbox-credentials-mask.XXXXXX")"
SANDBOX_CREDENTIALS_TMPDIR_MASK="$(mktemp -d "${TMPDIR:-/tmp}/claude-sandbox-credentials-tmpdir-mask.XXXXXX")"
SANDBOX_CREDENTIALS_MASK_FILE="$(mktemp "${TMPDIR:-/tmp}/dejima-credentials-mask.XXXXXX")"
SANDBOX_CREDENTIALS_TMPDIR_MASK="$(mktemp -d "${TMPDIR:-/tmp}/dejima-credentials-tmpdir-mask.XXXXXX")"
extra_mounts+=(-v "$SANDBOX_ANTHROPIC_PROXY_DIR:$SANDBOX_ANTHROPIC_PROXY_DIR:ro")
extra_mounts+=(-v "$SANDBOX_CREDENTIALS_MASK_FILE:$HOME/.claude/.credentials.json")
# Mount target must exist first, same as the skills/commands/etc. overlays
@@ -415,29 +466,189 @@ if [ "${SANDBOX_CREDENTIAL_PROXY:-1}" != "0" ] \
fi
export SANDBOX_ANTHROPIC_BASE_URL SANDBOX_ANTHROPIC_AUTH_TOKEN SANDBOX_ANTHROPIC_PROXY_SOCKET
# Optional remote access (see README "Remote access"). 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/claude-sandbox-audit).
"$SCRIPT_DIR/claude-sandbox-audit" snapshot "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
# anything this session changed (advisory - see bin/dejima-audit).
"$SCRIPT_DIR/dejima-audit" snapshot "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
# When the sandbox is pointed at its own repo, its TCB (bin/, hooks/,
# docker/, install.sh, config/env, test/) is mounted writable - warn, and
# the audit below will report any change to it.
# docker/, install.sh, test/) is mounted writable - warn, and the audit
# below will report any change to it.
if [ "$(readlink -f "$WORKSPACE_DIR")" = "$(readlink -f "$REPO_ROOT")" ]; then
echo "Warning: sandbox's own repo is the mounted workspace - its trusted" >&2
echo "computing base (bin/, hooks/, docker/, install.sh) is writable this" >&2
echo "session; the exit audit will report any changes to review." >&2
fi
# Compose builds the shared base image under its own tag, explicitly - with
# image: now honoring $SANDBOX_IMAGE, letting the derived tag leak into this
# build would rebuild the base Dockerfile under the derived name.
SANDBOX_IMAGE="dejima:${SANDBOX_USER}" \
docker compose -f "$COMPOSE_FILE" build claude
SANDBOX_IMAGE="dejima:${SANDBOX_USER}"
# Each layer builds FROM whatever the previous one left in $SANDBOX_IMAGE, so
# global packages are shared by every project instead of being rebuilt into
# each one's image.
build_dep_layer() {
local tag="$1" store="$2"
generate_derived_dockerfile "$SANDBOX_IMAGE" "$store" > "$SANDBOX_DERIVED_DOCKERFILE"
echo "Building $tag (approved dependencies)..."
if ! docker build -t "$tag" -f "$SANDBOX_DERIVED_DOCKERFILE" "$SANDBOX_DERIVED_CONTEXT"; then
# No fallback to a less-equipped image: that would hand the session an
# environment missing the dependencies it was told it has.
echo "Failed to build $tag." >&2
echo "Fix the approved dependencies (dejima-dep list) and retry." >&2
exit 1
fi
SANDBOX_IMAGE="$tag"
}
if [ -s "$(config_root)/dependencies" ] || { [ -n "$PROJECT_STORE" ] && [ -s "$PROJECT_STORE/dependencies" ]; }; then
SANDBOX_DERIVED_DOCKERFILE="$(mktemp "${TMPDIR:-/tmp}/dejima-derived.XXXXXX")"
# An empty context, never $WORKSPACE_DIR: the project directory is
# session-writable, and a root-context build with it as context would let a
# compromised session feed files into the image it then runs in.
SANDBOX_DERIVED_CONTEXT="$(mktemp -d "${TMPDIR:-/tmp}/dejima-derived-ctx.XXXXXX")"
fi
if [ -s "$(config_root)/dependencies" ]; then
build_dep_layer "dejima:${SANDBOX_USER}-global" "$(config_root)"
fi
if [ -n "$PROJECT_STORE" ] && [ -s "$PROJECT_STORE/dependencies" ]; then
build_dep_layer "dejima:${SANDBOX_USER}-$(project_slug "$WORKSPACE_DIR")" "$PROJECT_STORE"
fi
export SANDBOX_IMAGE
# Not exec'd (the EXIT trap cleans up the generated temp files) and not a
# bare command under set -e (a nonzero/interrupted container run - e.g.
# Ctrl-C returning 130 - would otherwise abort the script here, skipping the
# audit entirely). Capture the status explicitly so the advisory audit runs
# on every exit path, then propagate the real container exit code unchanged.
compose_status=0
docker compose -f "$COMPOSE_FILE" run --rm --build ${extra_mounts[@]+"${extra_mounts[@]}"} claude "$@" || compose_status=$?
docker compose -f "$COMPOSE_FILE" run --rm ${extra_mounts[@]+"${extra_mounts[@]}"} claude "$@" || compose_status=$?
if [ -n "${SANDBOX_ANTHROPIC_PROXY_PID:-}" ] && ! kill -0 "$SANDBOX_ANTHROPIC_PROXY_PID" 2>/dev/null; then
echo "Warning: the credential proxy exited before the session ended - API calls in" >&2
echo "the session may have been failing. Check for proxy errors in the output above." >&2
fi
"$SCRIPT_DIR/claude-sandbox-audit" compare "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
"$SCRIPT_DIR/dejima-audit" compare "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
exit "$compose_status"
+166
View File
@@ -0,0 +1,166 @@
#!/bin/bash
# Add a domain to the egress firewall allowlist of a running dejima
# container, from outside it. The sandbox itself has no privilege to do this
# (node has no sudo/setuid path back to root - see this repo's README's
# Security notes section); if a sandboxed agent hits a blocked domain it
# actually needs, it should tell you the exact command to run here rather
# than try to work around the firewall.
#
# Usage: dejima-allow <domain> [--global] [--project <path>] [--container <name>]
# dejima-allow crates.io # this running session only
# dejima-allow crates.io --global # + persist for every project
# dejima-allow crates.io --project ~/myproj # + persist for one project
set -euo pipefail
domain=""
global=0
project=""
container=""
while [ "$#" -gt 0 ]; do
case "$1" in
--global) global=1; shift ;;
--project) project="$2"; shift 2 ;;
--container) container="$2"; shift 2 ;;
-*) echo "Unknown option: $1" >&2; exit 1 ;;
*)
if [ -n "$domain" ]; then
echo "Usage: dejima-allow <domain> [--global] [--project <path>] [--container <name>]" >&2
exit 1
fi
domain="$1"
shift
;;
esac
done
if [ -z "$domain" ]; then
echo "Usage: dejima-allow <domain> [--global] [--project <path>] [--container <name>]" >&2
exit 1
fi
# Validated up front, not just passed as a positional arg below: this also
# has to be a well-formed hostname to be a valid dnsmasq server=/domain/...
# line (e.g. no embedded "/"), on top of ruling out shell metacharacters. A
# sandboxed agent can suggest this exact command to the user to copy-paste
# (see sandbox-context.sh), so a prompt-injected agent handing back a
# malicious "domain" is a real path here, not just a theoretical one.
if [[ ! "$domain" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,62}\.)*[a-zA-Z0-9-]{1,63}$ ]]; then
echo "Not a valid domain: $domain" >&2
exit 1
fi
# Recorded first, and unconditionally: --project/--global are the user's
# approval decision for future sessions, not a claim about this one. Gating
# either on a live container would break the documented workflow outright -
# approving dependencies requires leaving the sandbox, so the usual moment to
# run this is precisely when nothing is running. Gating on the reachability
# probe below would refuse any declared domain that legitimately doesn't serve
# HTTPS on 443. The two scopes are independent, not exclusive: both may be
# given together, and each persists to its own store.
if [ -n "$project" ] || [ "$global" -eq 1 ]; then
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=dejima-project-lib.sh
source "$SCRIPT_DIR/dejima-project-lib.sh"
fi
if [ -n "$project" ]; then
project="$(cd "$project" && pwd)"
store="$(ensure_project_store "$project")"
approve_entry "$store" allowed-domains "$domain"
echo "Added $domain to the approved domains for $project (applies to every future session in it)."
fi
if [ "$global" -eq 1 ]; then
store="$(ensure_global_store)"
approve_entry "$store" allowed-domains "$domain"
echo "Added $domain to the global approved domains (applies to every future session)."
fi
# The live-session update below needs a running container to exec into.
# Skipped only by the test suite (test/project.bats), which exercises
# --project persistence on its own, without a container.
if [ "${DEJIMA_ALLOW_SKIP_SESSION:-0}" != "1" ]; then
if [ -z "$container" ]; then
containers=$(docker ps --filter "label=com.docker.compose.project=dejima" --filter "status=running" --format '{{.Names}}')
count=$(echo "$containers" | grep -c . || true)
if [ "$count" -eq 0 ]; then
echo "No running dejima container found." >&2
# With --project or --global (or both) there is nothing left to
# do and nothing went wrong: the approval is already recorded and
# applies from the next launch.
if [ -n "$project" ] || [ "$global" -eq 1 ]; then
echo "Nothing to patch for this session; $domain applies from the next launch." >&2
exit 0
fi
exit 1
elif [ "$count" -gt 1 ]; then
echo "Multiple running dejima containers found; specify one with --container:" >&2
echo "$containers" >&2
echo "(a sandboxed agent's own container ID is printed in its startup context," >&2
echo "or by 'cat /etc/hostname' inside that sandbox - don't let it guess one)" >&2
exit 1
fi
container="$containers"
fi
# The DNS proxy (dns-proxy.sh) NXDOMAINs anything not in its allowlist, so
# this has to happen *before* resolving the domain below - otherwise the dig
# right after this would itself get blocked. Append a server= line to
# dnsmasq's servers-file (idempotent - skip if already present from an
# earlier call) and SIGHUP it to reload just that file, no restart needed.
# $domain is passed as a positional arg to the inner script (`$1`, inside a
# single-quoted heredoc) rather than interpolated into the script text - it's
# validated above, but this keeps it that way even if the validation regex
# above is ever loosened later.
docker exec -u root "$container" bash -c '
domain="$1"
entry="server=/${domain}/127.0.0.11"
grep -qxF "$entry" /etc/dnsmasq-sandbox-servers.conf 2>/dev/null || echo "$entry" >> /etc/dnsmasq-sandbox-servers.conf
kill -HUP "$(cat /var/run/dnsmasq-sandbox.pid)"
' -- "$domain"
echo "Resolving $domain inside $container..."
ips=$(docker exec "$container" dig +short A "$domain" | grep -E '^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$' || true)
if [ -z "$ips" ]; then
echo "Failed to resolve $domain inside the container." >&2
exit 1
fi
for ip in $ips; do
echo "Allowing $ip ($domain)"
docker exec -u root "$container" ipset add allowed-domains "$ip" -exist
done
echo "$domain allowed for the running session ($container)."
# TCP-level truth check from inside the very container that was just
# updated - this script's job is the firewall, so "TCP connect succeeded"
# is the claim to verify. curl exit 6 (resolve failed), 7 (connect
# refused/blocked) and 28 (connect or overall timeout) mean still
# unreachable; anything else (0, or a TLS/HTTP-layer code like 35/52) means
# the connection itself went through the firewall fine. --max-time bounds
# the whole request, not just the connect phase - without it, a server that
# accepts the TCP connection and then stalls (never sends TLS/HTTP bytes)
# would hang this script indefinitely; curl maps that case to the same
# exit 28 the case below already handles, so no new branch is needed. This
# is a report on this run, not a gate on either scope's approval: both
# --project and --global are already recorded above, deliberately - a
# declared host may legitimately not answer on 443 (an internal service,
# an SSH-only host, an apex that only serves a subdomain), and the natural
# moment to add a global approval is precisely when nothing is running to
# probe in the first place. The exit 2 below still means what it says:
# the live patch to this session did not verify.
echo "Verifying $domain is reachable from $container..."
rc=0
docker exec "$container" curl -sI --connect-timeout 5 --max-time 10 "https://$domain/" >/dev/null 2>&1 || rc=$?
case "$rc" in
6|7|28)
echo "WARNING: $domain is still NOT reachable from $container (curl exit $rc)." >&2
echo "If the service doesn't listen on 443 this check can fail spuriously;" >&2
echo "otherwise the allow did not take effect - re-check the container ID." >&2
exit 2
;;
*)
echo "Verified: $domain is reachable from $container."
;;
esac
fi
@@ -1,13 +1,13 @@
#!/usr/bin/env python3
# Host-side credential-injecting proxy for sandboxed Claude Code sessions.
# The container only ever holds a placeholder token; this proxy (launched on
# the host by bin/claude-sandbox, reached via a bind-mounted unix socket and
# the host by bin/dejima, reached via a bind-mounted unix socket and
# an in-container loopback forwarder) strips the placeholder and injects the
# 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-claude-sandbox-credential-proxy-design.md.
# ~/specs/2026-07-24-dejima-credential-proxy-design.md.
import argparse
import http.client
import json
@@ -86,9 +86,9 @@ class Credentials:
# Staged in a dedicated subdirectory, not ~/.claude directly - that
# directory is bind-mounted read-write into the sandbox container, and
# only the single ~/.claude/.credentials.json path is masked from it
# (see bin/claude-sandbox); a sibling temp file created directly in
# (see bin/dejima); a sibling temp file created directly in
# ~/.claude would be container-readable for the whole write window.
# bin/claude-sandbox masks this subdirectory entirely (empty read-only
# bin/dejima masks this subdirectory entirely (empty read-only
# overlay), and the rename below stays same-filesystem (a subdirectory
# of the same parent), so atomicity is unaffected either way - what the
# container's own bind mount shows is irrelevant to a host-side rename.
@@ -114,7 +114,7 @@ class Handler(BaseHTTPRequestHandler):
pass # per-request lines would interleave with the session's own UI
def log_error(self, fmt, *args):
sys.stderr.write("claude-sandbox-anthropic-proxy: " + fmt % args + "\n")
sys.stderr.write("dejima-anthropic-proxy: " + fmt % args + "\n")
def _read_body(self):
if self.headers.get("Transfer-Encoding", "").lower() == "chunked":
@@ -210,7 +210,12 @@ class Handler(BaseHTTPRequestHandler):
finally:
conn.close()
do_GET = do_POST = do_PUT = do_DELETE = do_PATCH = _proxy
# HEAD and OPTIONS included: the method set this accepts should be the
# upstream API's, not whichever handlers happen to be defined here. A
# method with no do_* lands in BaseHTTPRequestHandler's 501, which the
# container sees as the API rejecting it and the host sees as a logged
# error - both misattributed to a request the proxy simply didn't route.
do_GET = do_POST = do_PUT = do_DELETE = do_PATCH = do_HEAD = do_OPTIONS = _proxy
class ProxyServer(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
@@ -227,6 +232,20 @@ class ProxyServer(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
request, _ = self.socket.accept()
return request, ("sandbox", 0)
def handle_error(self, request, client_address):
# The container side going away mid-response - the session
# interrupting a streaming reply, exiting with requests still in
# flight, or abandoning a request it no longer wants - reaches us as a
# broken pipe or a reset while writing the rest of the body. Nothing
# is left to deliver and nothing is wrong, but socketserver's default
# dumps a full traceback for it, onto the same terminal the session is
# drawing its UI on: this proxy runs on the host, sharing that
# terminal. Stay silent for exactly that case. Every other exception
# is a real fault and still gets its traceback.
if isinstance(sys.exc_info()[1], (BrokenPipeError, ConnectionResetError)):
return
super().handle_error(request, client_address)
def main():
p = argparse.ArgumentParser()
@@ -245,14 +264,14 @@ def main():
creds._read()["claudeAiOauth"]["accessToken"]
except Exception as e:
sys.stderr.write(
"claude-sandbox-anthropic-proxy: unusable credentials file %s: %s\n"
"dejima-anthropic-proxy: unusable credentials file %s: %s\n"
% (args.credentials, e))
return 1
expires = creds._read()["claudeAiOauth"].get("refreshTokenExpiresAt")
if expires is not None and expires / 1000 < time.time():
sys.stderr.write(
"claude-sandbox-anthropic-proxy: the refresh token in %s has "
"dejima-anthropic-proxy: the refresh token in %s has "
"expired - run `claude` on the host and log in again first\n"
% args.credentials)
return 1
+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 README "Remote
# access".
#
# 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
+10 -10
View File
@@ -1,5 +1,5 @@
#!/bin/bash
# Host-side exit-time audit for bin/claude-sandbox. Snapshots the workspace's
# Host-side exit-time audit for bin/dejima. Snapshots the workspace's
# host-persistence surfaces before the container runs, and after it exits
# reports (advisory only - never blocks) anything a session changed that
# would execute or load OUTSIDE the sandbox: git config keys that run a
@@ -10,22 +10,22 @@ set -uo pipefail
cmd="${1:-}"; ws="${2:-}"; state="${3:-}"
PROJECTS_DIR="${CLAUDE_SANDBOX_AUDIT_PROJECTS_DIR:-$HOME/.claude/projects}"
PROJECTS_DIR="${DEJIMA_AUDIT_PROJECTS_DIR:-$HOME/.claude/projects}"
# The one top-level ~/.claude entry docker-compose.yml's read-only overlays
# don't cover (settings.json/CLAUDE.md/plugins/hooks/skills/commands/agents/
# output-styles all are) - settings.json's statusLine.command runs it on the
# HOST, unsandboxed, and bin/claude-sandbox's own read-only mount onto it
# HOST, unsandboxed, and bin/dejima's own read-only mount onto it
# doesn't reliably protect it when it's a symlink (the common case - runc
# resolves the mount destination through the symlink; confirmed against a
# live container, see test/runtime.bats). Detection here is the compensating
# control for that gap. Overridable for tests.
STATUSLINE_PATH="${CLAUDE_SANDBOX_AUDIT_STATUSLINE:-$HOME/.claude/statusline.sh}"
STATUSLINE_PATH="${DEJIMA_AUDIT_STATUSLINE:-$HOME/.claude/statusline.sh}"
# This script's own repo root - the sandbox's trusted computing base (see
# tcb_snapshot below) is only monitored when the workspace being audited IS
# this repo (a self-mounted session). Overridable for tests.
AUDIT_REPO_ROOT="${CLAUDE_SANDBOX_AUDIT_REPO_ROOT:-$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)}"
AUDIT_REPO_ROOT="${DEJIMA_AUDIT_REPO_ROOT:-$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)}"
# git config keys whose values run a command on the next git invocation, or
# (include.path/includeif.*.path) indirect to another config file that could
@@ -96,7 +96,7 @@ tcb_snapshot() {
local ws="$1" p
[ "$(readlink -f "$ws" 2>/dev/null)" = "$AUDIT_REPO_ROOT" ] || return 0
{
for p in install.sh config/env; do
for p in install.sh; do
[ -f "$ws/$p" ] && printf '%s\t%s\n' "$p" "$(hash_entry "$ws/$p")"
done
for dir in bin hooks docker test; do
@@ -181,13 +181,13 @@ case "$cmd" in
exit 0
;;
compare)
[ -f "$state" ] || { echo "claude-sandbox audit: no snapshot to compare against, skipping." >&2; exit 0; }
[ -f "$state" ] || { echo "dejima audit: no snapshot to compare against, skipping." >&2; exit 0; }
new="$(mktemp)"; { echo "### gitconfig"; git_config_snapshot "$ws";
echo "### paths"; hash_paths "$ws";
echo "### projects"; projects_snapshot "$PROJECTS_DIR";
echo "### tcb"; tcb_snapshot "$ws";
echo "### statusline"; statusline_snapshot "$STATUSLINE_PATH"; } > "$new" 2>/dev/null || {
echo "claude-sandbox audit: could not complete, skipping." >&2; rm -f "$new"; exit 0; }
echo "dejima audit: could not complete, skipping." >&2; rm -f "$new"; exit 0; }
findings=()
# config diffs: added/changed lines present in new but not old
@@ -258,7 +258,7 @@ case "$cmd" in
{
echo ""
echo "================================================================"
echo " claude-sandbox: this session changed host-persistence surfaces"
echo " dejima: this session changed host-persistence surfaces"
echo " in $ws"
echo " Review these BEFORE running 'git' or 'claude' in that repo:"
echo "----------------------------------------------------------------"
@@ -268,7 +268,7 @@ case "$cmd" in
exit 0
;;
*)
echo "usage: claude-sandbox-audit {snapshot|compare} <workspace> <state-file>" >&2
echo "usage: dejima-audit {snapshot|compare} <workspace> <state-file>" >&2
exit 2
;;
esac
Executable
+107
View File
@@ -0,0 +1,107 @@
#!/bin/bash
# Manage the approved dev-dependency list for a project or, with --global,
# every project. Approvals live host-side (never mounted into the
# container), so a sandboxed session can ask for a package but can never
# grant itself one - the same split bin/dejima-allow uses for
# domains.
#
# Usage: dejima-dep <add|remove|list> [<spec>] [--global | --project <path>]
# dejima-dep add cargo:cargo-nextest --project ~/myproject
# dejima-dep add cargo:cargo-nextest --global
# dejima-dep list
#
# A spec is <apt|npm|pip|cargo>:<name>. The dependencies actually installed
# for a project are the union of its global and project approvals.
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"
action=""
spec=""
project=""
global=0
while [ "$#" -gt 0 ]; do
case "$1" in
--global) global=1; shift ;;
--project) project="$2"; shift 2 ;;
-*) echo "Unknown option: $1" >&2; exit 1 ;;
*)
if [ -z "$action" ]; then action="$1"
elif [ -z "$spec" ]; then spec="$1"
else echo "Unexpected argument: $1" >&2; exit 1
fi
shift
;;
esac
done
usage() {
echo "Usage: dejima-dep <add|remove|list> [<spec>] [--global | --project <path>]" >&2
exit 1
}
[ -n "$action" ] || usage
# An entry belongs to exactly one scope; silently picking one would misplace
# an approval.
if [ "$global" -eq 1 ] && [ -n "$project" ]; then
echo "--global and --project are mutually exclusive." >&2
exit 1
fi
# With --global there is no project path to resolve; doing it anyway would
# put a misleading $PWD-derived path in error messages.
if [ "$global" -eq 0 ]; then
project="$(cd "${project:-$PWD}" && pwd)"
fi
case "$action" in
list)
if [ "$global" -eq 1 ]; then
approved_entries "$(config_root)" dependencies
else
store="$(resolve_project_store "$project")"
[ -n "$store" ] || exit 0
approved_entries "$store" dependencies
fi
;;
add)
[ -n "$spec" ] || usage
valid_dep_spec "$spec" || { echo "Not a valid dependency spec: $spec" >&2; echo "Expected <apt|npm|pip|cargo>:<name>" >&2; exit 1; }
if [ "$global" -eq 1 ]; then
store="$(ensure_global_store)"
approve_entry "$store" dependencies "$spec"
echo "Approved $spec globally (every project)."
else
store="$(ensure_project_store "$project")"
approve_entry "$store" dependencies "$spec"
echo "Approved $spec for $project."
fi
# Unlike dejima-allow, this cannot touch a running session:
# it needs an image rebuild. Say so, or an agent will keep insisting
# the dependency is missing right after the user added it.
echo "Takes effect on the next launch (bin/dejima rebuilds the project image)."
;;
remove)
[ -n "$spec" ] || usage
if [ "$global" -eq 1 ]; then
# Same rule as the project path below: with no store there is
# nothing to remove from, and confirming a removal that never
# happened is how a typo'd or premature remove looks like it
# worked.
store="$(config_root)"
[ -d "$store" ] || { echo "No global approved config." >&2; exit 1; }
unapprove_entry "$store" dependencies "$spec"
echo "Removed $spec from the global dependencies (takes effect on the next launch)."
else
store="$(resolve_project_store "$project")"
[ -n "$store" ] || { echo "No approved config for $project." >&2; exit 1; }
unapprove_entry "$store" dependencies "$spec"
echo "Removed $spec from $project (takes effect on the next launch)."
fi
;;
*) usage ;;
esac
+282
View File
@@ -0,0 +1,282 @@
#!/bin/bash
# Host-side watcher: turns marker files dropped by hooks/dejima-notify.sh
# into ntfy pushes. Runs as a systemd USER unit on the host - never as root,
# never inside a container. See README "Remote access".
#
# 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
+115
View File
@@ -0,0 +1,115 @@
#!/bin/bash
# Review and approve what a project declares it needs.
#
# A project declares its requirements in .dejima/{dependencies,
# allowed-domains}, which are committed and shared - but declaring grants
# nothing. This is where a human turns a declaration into an approval.
#
# Usage: dejima-project <status|approve> [<path>]
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"
action="${1:-}"
project="${2:-$PWD}"
[ -n "$action" ] || { echo "Usage: dejima-project <status|approve> [<path>]" >&2; exit 1; }
project="$(cd "$project" && pwd)"
decl_dir="$project/.dejima"
store="$(resolve_project_store "$project")"
approved_of() {
local kind="$1"
approved_entries "$(config_root)" "$kind" || return 1
[ -n "$store" ] || return 0
approved_entries "$store" "$kind"
}
# Prints "<state> <entry>" per declared entry: approved / pending / rejected.
classify() {
local kind="$1" validator="$2" approved state entry
approved="$(approved_of "$kind")" || return 1
while read -r state entry; do
case "$state" in
rejected) printf 'rejected %s\n' "$entry" ;;
truncated) printf 'truncated %s\n' "$entry" ;;
ok)
if grep -qxF "$entry" <<< "$approved"; then
printf 'approved %s\n' "$entry"
else
printf 'pending %s\n' "$entry"
fi
;;
esac
done < <(read_declared "$decl_dir/$kind" "$validator")
return 0
}
case "$action" in
status)
echo "Project: $project"
echo "Approvals: ${store:-<none yet>}"
echo "Global approvals: $(config_root)"
echo
echo "dependencies:"
classify dependencies valid_dep_spec | sed 's/^/ /'
echo "allowed-domains:"
classify allowed-domains valid_domain | sed 's/^/ /'
;;
approve)
pending_deps=$(classify dependencies valid_dep_spec | sed -n 's/^pending //p')
pending_doms=$(classify allowed-domains valid_domain | sed -n 's/^pending //p')
rejected=$( { classify dependencies valid_dep_spec; classify allowed-domains valid_domain; } | sed -n 's/^rejected //p')
trunc_deps=$(classify dependencies valid_dep_spec | sed -n 's/^truncated //p')
trunc_doms=$(classify allowed-domains valid_domain | sed -n 's/^truncated //p')
if [ -n "$trunc_deps" ]; then
echo "Warning: dependencies declares $trunc_deps entries; only the first $DEJIMA_MAX_DECLARED were considered." >&2
fi
if [ -n "$trunc_doms" ]; then
echo "Warning: allowed-domains declares $trunc_doms entries; only the first $DEJIMA_MAX_DECLARED were considered." >&2
fi
if [ -n "$rejected" ]; then
echo "Refusing to approve these malformed entries (fix the declaration):" >&2
sed 's/^/ /' <<< "$rejected" >&2
echo >&2
fi
if [ -z "$pending_deps" ] && [ -z "$pending_doms" ]; then
echo "Nothing pending approval for $project."
exit 0
fi
echo "Declared but not approved for $project:"
[ -n "$pending_deps" ] && printf ' dependency: %s\n' $pending_deps
[ -n "$pending_doms" ] && printf ' domain: %s\n' $pending_doms
echo
printf 'Approve all of the above? [y/N] '
read -r reply || reply=n
case "$reply" in
y|Y) ;;
*) echo "Nothing approved."; exit 0 ;;
esac
for spec in $pending_deps; do
"$SCRIPT_DIR/dejima-dep" add "$spec" --project "$project"
done
# dejima-allow records the approval before it touches a
# running session, so a nonzero exit here means only that the live
# patch or its reachability probe didn't succeed - a declared host
# that doesn't serve HTTPS on 443, say. Under set -e that used to
# abort the loop and silently skip every remaining domain, so collect
# and report instead.
unapplied=""
for dom in $pending_doms; do
"$SCRIPT_DIR/dejima-allow" "$dom" --project "$project" || unapplied="$unapplied $dom"
done
if [ -n "$unapplied" ]; then
echo
echo "Approved for $project, but not applied to a running session:$unapplied" >&2
echo "They take effect at the next launch either way." >&2
fi
;;
*)
echo "Usage: dejima-project <status|approve> [<path>]" >&2
exit 1
;;
esac
+345
View File
@@ -0,0 +1,345 @@
#!/bin/bash
# Sourceable helper for the per-project config tooling (bin/dejima,
# dejima-allow, dejima-dep, dejima-project).
#
# Two kinds of file are involved and they are NOT equally trusted:
#
# .dejima/{allowed-domains,dependencies} in the project - a
# DECLARATION. The project directory is bind-mounted read-write, so a
# compromised session can write these. They grant nothing; they are read
# only to compare against reality and suggest a command for the user to
# run. Parsed, never sourced - executing a file on a session-writable
# mount is a host-shell persistence path.
#
# ~/.config/dejima/{allowed-domains,dependencies} - a GLOBAL
# APPROVAL, applying to every project.
#
# ~/.config/dejima/projects/<slug>/* - a PROJECT APPROVAL.
#
# Both are written only by the CLIs, which only the user runs, and neither
# is ever mounted into the container. They are the only things that
# actually grant anything.
DEJIMA_MAX_DECLARED=200
# Same expression bin/dejima-allow validates with - a declared
# domain has to survive being written into a dnsmasq server=/domain/ line.
valid_domain() {
[[ "$1" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,62}\.)*[a-zA-Z0-9-]{1,63}$ ]]
}
# <ecosystem>:<name>. The character class is deliberately narrow: an
# approved spec gets interpolated into a generated Dockerfile RUN line, so
# anything shell-active here would be a root-context injection at build.
# The name must start with an alphanumeric - without that, a leading dash
# (e.g. "--allow-unauthenticated") validates as a name and lands in the
# generated RUN line as an option instead, quietly weakening apt/pip/npm/
# cargo's own signature/lockfile checks.
valid_dep_spec() {
[[ "$1" =~ ^(apt|npm|pip|cargo):[A-Za-z0-9][A-Za-z0-9._+-]*$ ]]
}
# read_declared <file> <validator-fn>
# Prints "ok <entry>" / "rejected <entry>" per entry, plus a final
# "truncated <total>" when the cap kicked in. Invalid entries are reported
# rather than dropped - a project declaring something malformed should be
# visibly refused, not silently ignored.
read_declared() {
local file="$1" validator="$2" line count=0 total=0
[ -f "$file" ] || return 0
while IFS= read -r line || [ -n "$line" ]; do
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
case "$line" in
''|'#'*) continue ;;
esac
total=$((total + 1))
[ "$count" -ge "$DEJIMA_MAX_DECLARED" ] && continue
count=$((count + 1))
if "$validator" "$line"; then
printf 'ok %s\n' "$line"
else
printf 'rejected %s\n' "$line"
fi
done < "$file"
if [ "$total" -gt "$DEJIMA_MAX_DECLARED" ]; then
printf 'truncated %s\n' "$total"
fi
return 0
}
config_root() {
printf '%s' "${DEJIMA_CONFIG_DIR:-$HOME/.config/dejima}"
}
# The global store is config_root() itself, not a subdirectory of it - the
# two filenames below can never collide with projects/<slug>, since a slug
# is a sanitized absolute path and always begins with "-".
ensure_global_store() {
local dir
dir="$(config_root)"
mkdir -p "$dir"
printf '%s' "$dir"
}
# Same sanitization docker/log-shipper.sh uses for its Loki project key, so
# one project has one identity everywhere in this repo.
project_slug() {
printf '%s' "$1" | sed 's/[^a-zA-Z0-9-]/-/g'
}
# The store a writer must use: whatever a reader would resolve to, created
# for this exact path only when nothing above already covers it. Creating one
# per path unconditionally would be wrong in the workflow inheritance exists
# for - the first approval made from inside a worktree would create a store
# that shadows the project's for every later read, silently un-applying
# everything the worktree had been inheriting.
#
# Recording into the ancestor widens its scope instead, which is the lesser
# surprise but still a surprise, so it says so. The path file is what makes a
# slug collision detectable (resolve_project_store checks it at every level):
# two different projects can sanitize to the same slug, and silently applying
# one project's approvals to another would be a real grant the user never made.
ensure_project_store() {
local path="$1" dir existing owner
existing="$(resolve_project_store "$path")" || return 1
if [ -n "$existing" ]; then
owner="$(cat "$existing/path" 2>/dev/null)"
if [ -z "$owner" ]; then
# A store with no path file (an interrupted write, a hand-made
# directory) has no collision guard at all, and attaching to it
# would leave it that way permanently. Heal it - which ancestor
# it belongs to is whichever one's slug produced this directory,
# not necessarily $path, and recording the wrong one would make
# every later read from the real owner look like a collision.
owner="$path"
while [ "$(config_root)/projects/$(project_slug "$owner")" != "$existing" ]; do
owner="$(dirname "$owner")"
done
printf '%s\n' "$owner" > "$existing/path"
fi
if [ "$owner" != "$path" ]; then
echo "Note: recorded under the existing approvals for $owner, which" >&2
echo "$path inherits - so it applies to $owner and everything under it." >&2
fi
printf '%s' "$existing"
return 0
fi
dir="$(config_root)/projects/$(project_slug "$path")"
mkdir -p "$dir"
printf '%s\n' "$path" > "$dir/path"
printf '%s' "$dir"
}
# Nearest ancestor with a store, so a git worktree under the project (the
# workflow sandbox-context.sh tells agents to use) inherits the project's
# approvals instead of silently getting none.
resolve_project_store() {
local path="$1" dir
if [[ "$path" != /* ]]; then
echo "resolve_project_store: path must be absolute, got: $path" >&2
return 1
fi
while [ -n "$path" ] && [ "$path" != "/" ]; do
dir="$(config_root)/projects/$(project_slug "$path")"
if [ -d "$dir" ]; then
if [ -f "$dir/path" ] && [ "$(cat "$dir/path")" != "$path" ]; then
echo "Project slug collision: $dir belongs to $(cat "$dir/path"), not $path" >&2
return 1
fi
printf '%s' "$dir"
return 0
fi
path="$(dirname "$path")"
done
return 0
}
_approved_validator() {
case "$1" in
allowed-domains) printf 'valid_domain' ;;
dependencies) printf 'valid_dep_spec' ;;
*) return 1 ;;
esac
}
# Approvals are host-side and trusted, so an entry that fails validation
# means a bug or tampering, not user error - refuse rather than proceed.
approved_entries() {
local store="$1" kind="$2" file validator line
validator="$(_approved_validator "$kind")" || return 1
file="$store/$kind"
[ -f "$file" ] || return 0
while IFS= read -r line || [ -n "$line" ]; do
[ -n "$line" ] || continue
if ! "$validator" "$line"; then
echo "corrupt approval entry in $file: $line" >&2
return 1
fi
printf '%s\n' "$line"
done < "$file"
return 0
}
approve_entry() {
local store="$1" kind="$2" entry="$3" validator
validator="$(_approved_validator "$kind")" || return 1
"$validator" "$entry" || { echo "Invalid $kind entry: $entry" >&2; return 1; }
touch "$store/$kind"
grep -qxF "$entry" "$store/$kind" || printf '%s\n' "$entry" >> "$store/$kind"
}
unapprove_entry() {
local store="$1" kind="$2" entry="$3" tmp
[ -f "$store/$kind" ] || return 0
tmp="$(mktemp)"
grep -vxF "$entry" "$store/$kind" > "$tmp" || true
mv "$tmp" "$store/$kind"
}
# Prints a Dockerfile that layers approved packages onto the shared base
# image. Only validated tokens are interpolated (approved_entries refuses
# anything else), so a RUN line here can't become a root-context injection.
#
# Installs run at BUILD time, where there's no egress firewall - which is
# why this is a derived image rather than an install step in entrypoint.sh:
# the latter would need apt/npm/PyPI/crates.io reachable as root before
# init-firewall.sh locks egress down.
generate_derived_dockerfile() {
local base="$1" store="$2" entries apt npm pip cargo spec name
entries="$(approved_entries "$store" dependencies)" \
|| { echo "refusing to build from a corrupt dependency list in $store" >&2; return 1; }
apt=""; npm=""; pip=""; cargo=""
while IFS= read -r spec; do
[ -n "$spec" ] || continue
name="${spec#*:}"
case "$spec" in
apt:*) apt="$apt $name" ;;
npm:*) npm="$npm $name" ;;
pip:*) pip="$pip $name" ;;
cargo:*) cargo="$cargo $name" ;;
esac
done <<< "$entries"
printf 'FROM %s\n' "$base"
printf 'USER root\n'
[ -n "$apt" ] && printf 'RUN apt-get update && apt-get install -y --no-install-recommends%s \\\n && apt-get clean && rm -rf /var/lib/apt/lists/*\n' "$apt"
[ -n "$pip" ] && printf 'RUN pip install --break-system-packages%s\n' "$pip"
printf 'USER ${SANDBOX_USER}\n'
[ -n "$npm" ] && printf 'RUN npm install -g%s\n' "$npm"
if [ -n "$cargo" ]; then
# The base image has no Rust toolchain - a cargo: spec is the only
# thing that pulls one in, so projects without one pay nothing.
printf 'RUN curl --proto "=https" --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path\n'
printf 'ENV PATH=$PATH:${SANDBOX_HOME}/.cargo/bin\n'
printf 'RUN cargo install%s\n' "$cargo"
fi
# Restore the base image's privilege posture no matter what the layers
# above left behind - entrypoint.sh must still start as root and drop
# via gosu, with no escalation path for the session user.
printf 'USER root\n'
printf 'ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]\n'
return 0
}
# ---------------------------------------------------------------------------
# Remote access (opt-in). See README "Remote access".
#
# 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 README "Remote access".
#
# --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"
+2 -2
View File
@@ -16,12 +16,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
}
-5
View File
@@ -1,5 +0,0 @@
# Sourced by bin/claude-sandbox before reading ALLOWED_DOMAINS. Rewritten by
# `bin/claude-sandbox-allow <domain> --global` to persist a domain past a
# single session. A real environment variable (if already exported before
# invoking bin/claude-sandbox) takes precedence over this file.
export ALLOWED_DOMAINS=""
+19 -14
View File
@@ -1,5 +1,8 @@
# Based on the official Anthropic reference dev container:
# https://github.com/anthropics/claude-code/tree/main/.devcontainer
# Loosely inspired by the general shape of Anthropic's public reference dev
# container (https://github.com/anthropics/claude-code/tree/main/.devcontainer),
# substantially extended and reworked for this project's own sandboxing model
# (parametrized SANDBOX_USER, DNS proxy + ulogd2 logging, gosu-based privilege
# drop, nvim/Go/Playwright/tree-sitter toolchain, gitea-pr helper, etc).
FROM node:22
ARG TZ
@@ -17,7 +20,7 @@ ARG CLAUDE_CODE_VERSION=latest
# redundant on top of this container - it can't even function inside it
# anyway (bwrap can't create a namespace here, and fixing that would need
# privilege escalation approaching --privileged, undoing this container's
# own hardening). bin/claude-sandbox mounts a settings.json copy with
# own hardening). bin/dejima mounts a settings.json copy with
# sandbox.enabled forced to false, so there's nothing here to satisfy.
RUN apt-get update && apt-get install -y --no-install-recommends \
less \
@@ -64,14 +67,14 @@ 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/claude-sandbox - so absolute paths under $HOME (dotfiles, and
# 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/claude-sandbox always does; the test suite
# 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
@@ -89,11 +92,11 @@ ENV SANDBOX_HOME=$SANDBOX_HOME
RUN mkdir -p /usr/local/share/npm-global && \
chown -R $SANDBOX_USER:$SANDBOX_USER /usr/local/share
# Persist bash history.
RUN SNIPPET="export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \
&& mkdir /commandhistory \
&& touch /commandhistory/.bash_history \
&& chown -R $SANDBOX_USER /commandhistory
# 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
@@ -104,7 +107,7 @@ ENV CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
# Lets Claude Code (and the SessionStart hook in ~/.claude/hooks) know it's
# running inside this container rather than directly on the host.
ENV CLAUDE_SANDBOX=1
ENV DEJIMA=1
# Dedicated unprivileged user for ulogd2 (see entrypoint.sh) - unlike
# dnsmasq, the ulogd2 Debian package doesn't create one of its own. ulogd2
@@ -199,14 +202,16 @@ ENV SHELL=/bin/zsh
ENV EDITOR=nano
ENV VISUAL=nano
# Default powerline10k theme
# 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.
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)" -- \
-p git \
-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" \
-a "export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \
-x
# Install Claude Code
+1 -1
View File
@@ -1,6 +1,6 @@
// TCP -> unix-socket byte pipe: Claude Code needs an http://127.0.0.1 base
// URL, but the host-side credential proxy listens on a bind-mounted unix
// socket (no host TCP exposure - see bin/claude-sandbox-anthropic-proxy).
// socket (no host TCP exposure - see bin/dejima-anthropic-proxy).
// Deliberately no HTTP awareness: bytes in, bytes out. Port 18300 is a repo
// constant (the container's network namespace is private, so nothing can
// collide with it); the env override exists only for host-side tests.
+7 -7
View File
@@ -45,7 +45,7 @@ chown dnsmasq:dnsmasq "$LOG_FILE" 2>/dev/null || true
# server=/domain/upstream lines live in their own file (rather than the main
# conf) since dnsmasq only re-reads a --servers-file on SIGHUP - lets
# bin/claude-sandbox-allow add a domain to a running session by appending a
# bin/dejima-allow add a domain to a running session by appending a
# line here and signaling the container's dnsmasq, no restart needed.
{
for domain in "${FIXED_DOMAINS[@]}" "${GITHUB_DNS_SUFFIXES[@]}" ${EXTRA_ALLOWED_DOMAINS:-}; do
@@ -87,11 +87,11 @@ pid-file=/var/run/dnsmasq-sandbox.pid
EOF
# EXTRA_ALLOWED_DOMAINS (GITEA_SSH_HOST/GITEA_API/LOKI_URL's hosts, plus
# any operator-supplied ALLOWED_DOMAINS) are expected to potentially be
# internal/self-hosted addresses, unlike FIXED_DOMAINS/
# GITHUB_DNS_SUFFIXES above, which are always public SaaS domains -
# exempt just these from stop-dns-rebind so a legitimately internal
# Gitea/Loki host still resolves.
# the approved domains the launcher passes in ALLOWED_DOMAINS) are
# expected to potentially be internal/self-hosted addresses, unlike
# FIXED_DOMAINS/GITHUB_DNS_SUFFIXES above, which are always public SaaS
# domains - exempt just these from stop-dns-rebind so a legitimately
# internal Gitea/Loki host still resolves.
for domain in ${EXTRA_ALLOWED_DOMAINS:-}; do
if [[ "$domain" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
continue
@@ -107,7 +107,7 @@ EOF
# name dnsmasq ever answers: only allowlisted names have an upstream at
# all (no-resolv + servers-file), so "gets answered" and "is
# allowlisted" are the same set - including domains added mid-session
# by bin/claude-sandbox-allow, which previously only got a one-time
# by bin/dejima-allow, which previously only got a one-time
# snapshot (ipset= can't live in the SIGHUP-reloadable servers-file).
# The explicit github-domains lines below still win for GitHub names -
# dnsmasq applies the longest matching ipset= domain - which is what
+34 -24
View File
@@ -1,13 +1,13 @@
# Not meant to be run directly with plain `docker compose up`: WORKSPACE_DIR,
# SSH_AUTH_SOCK, etc. must point at real host paths for a specific project.
# Use `bin/claude-sandbox <path-to-project>` instead, which sets these and
# Use `bin/dejima <path-to-project>` instead, which sets these and
# invokes this file.
# Pinned explicitly: bin/claude-sandbox-allow discovers running sandboxes
# Pinned explicitly: bin/dejima-allow discovers running sandboxes
# via the com.docker.compose.project label, which otherwise derives from
# this file's directory name ("docker") and would never match the
# "claude-sandbox" value that script filters on.
name: claude-sandbox
# "dejima" value that script filters on.
name: dejima
services:
claude:
@@ -19,16 +19,26 @@ services:
# Matches the container's own user to the real host user - see
# Dockerfile. Defaults reproduce today's node/1000/1000//home/node
# exactly if these aren't set (e.g. building directly, bypassing
# bin/claude-sandbox).
# bin/dejima).
SANDBOX_USER: ${SANDBOX_USER:-node}
SANDBOX_UID: ${SANDBOX_UID:-1000}
SANDBOX_GID: ${SANDBOX_GID:-1000}
SANDBOX_HOME: ${HOME:-/home/node}
# Tagged per-user, not a fixed :latest - bin/claude-sandbox always
# passes --build, and two different accounts on a shared devbox
# building from their own checkouts would otherwise silently overwrite
# each other's image under the same tag.
image: claude-sandbox:${SANDBOX_USER:-node}
# Tagged per-user, not a fixed :latest - bin/dejima explicitly
# builds this image before the run step below, and two different
# accounts on a shared devbox building from their own checkouts would
# otherwise silently overwrite each other's image under the same tag.
# $SANDBOX_IMAGE lets bin/dejima point a run at a derived,
# per-project image instead, without this build ever producing one
# under that name itself - see bin/dejima.
image: ${SANDBOX_IMAGE:-dejima:${SANDBOX_USER:-node}}
# Run tini as PID 1 so orphaned processes get reaped. Without it PID 1 is
# the session's own command (claude, or nvim under SANDBOX_NVIM_CONFIG),
# which never reaps: every process-substitution child the test suite and
# bin/ scripts leave behind - `dejima-audit compare` alone leaks
# ten per run - becomes a permanent zombie, and a long session eventually
# exhausts the cgroup pids limit and can no longer fork at all.
init: true
cap_add:
- NET_ADMIN
- NET_RAW
@@ -47,19 +57,19 @@ services:
GIT_CONFIG_GLOBAL: ${HOME}/.gitconfig-sandbox/.gitconfig
ALLOWED_DOMAINS: ${ALLOWED_DOMAINS:-}
# What actually runs as the sandbox user - defaults to bare `claude`.
# See entrypoint.sh and bin/claude-sandbox's SANDBOX_NVIM_CONFIG.
# See entrypoint.sh and bin/dejima's SANDBOX_NVIM_CONFIG.
SANDBOX_CMD: ${SANDBOX_CMD:-claude}
# 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.
LOKI_URL: ${LOKI_URL:-}
# Opt-in (default off): also ship session prompts, assistant
# responses, and tool invocations to Loki as the
# claude-sandbox-conversation job. Content only leaves the container
# dejima-conversation job. Content only leaves the container
# if LOKI_URL is also set. See README "Network request logging".
SANDBOX_LOG_CONVERSATION: ${SANDBOX_LOG_CONVERSATION:-0}
SSH_AUTH_SOCK: /ssh-agent
@@ -71,7 +81,7 @@ services:
# Ctrl-H/Backspace disambiguation config/tmux.conf sets up) against
# the terminfo entry the rest of the chain actually expects.
TERM: ${TERM:-xterm-256color}
# Host-side credential proxy (see bin/claude-sandbox and README
# Host-side credential proxy (see bin/dejima and README
# "Security notes"): when enabled, Claude Code is pointed at a
# loopback forwarder and given a placeholder token - the real OAuth
# credential stays on the host, injected per-request by the proxy.
@@ -97,13 +107,13 @@ services:
# caches, etc.) still needs to stay read-write.
#
# settings.json specifically comes from $SANDBOX_SETTINGS_FILE (a
# bin/claude-sandbox-generated copy with sandbox.enabled forced to
# bin/dejima-generated copy with sandbox.enabled forced to
# false), not the real file directly - Claude Code's own internal
# per-command sandboxing is redundant on top of this container (and
# can't actually function inside it anyway), and mounting it read-only
# means it can't be patched from inside, so the substitution has to
# happen on the host before the container ever starts.
- ${SANDBOX_SETTINGS_FILE:?set by bin/claude-sandbox}:${HOME}/.claude/settings.json:ro
- ${SANDBOX_SETTINGS_FILE:?set by bin/dejima}:${HOME}/.claude/settings.json:ro
- ${HOME}/.claude/CLAUDE.md:${HOME}/.claude/CLAUDE.md:ro
- ${HOME}/.claude/plugins:${HOME}/.claude/plugins:ro
# ~/.claude/hooks holds hook *code* too (install.sh symlinks this
@@ -112,7 +122,7 @@ services:
# without this, the directory itself stays part of the read-write
# ~/.claude mount, so a compromised session could unlink one of
# install.sh's symlinks and drop a plain file in its place. The
# dynamic per-symlink-target mounts bin/claude-sandbox adds at
# dynamic per-symlink-target mounts bin/dejima adds at
# launch (see below) only protect targets that resolve *outside*
# ~/.claude - they never cover this directory itself, and a plain
# file dropped here wouldn't be a symlink for that logic to even
@@ -124,7 +134,7 @@ services:
# hooks/plugins above: a skill can ship an executable script, and a
# command/agent is prompt text loaded into a later, unsandboxed host
# session. Unlike those, they may not exist on a given host at all -
# bin/claude-sandbox pre-creates them (empty is fine; existence is what
# bin/dejima pre-creates them (empty is fine; existence is what
# lets Docker overlay them) so a compromised session can't create-then-
# populate one under the read-write parent mount.
- ${HOME}/.claude/skills:${HOME}/.claude/skills:ro
@@ -134,14 +144,14 @@ services:
# system prompt, same class as commands/agents above.
- ${HOME}/.claude/output-styles:${HOME}/.claude/output-styles:ro
# A throwaway per-session copy, not the real file - see
# bin/claude-sandbox and README "Throwaway ~/.claude.json". A plain
# bin/dejima and README "Throwaway ~/.claude.json". A plain
# read-only mount hangs Claude Code on startup (it writes to this
# file before login completes), so this can't just be layered
# read-only the way settings.json/CLAUDE.md/plugins/hooks are above;
# a generated, discarded copy is what actually stops a compromised
# session's changes (e.g. a malicious mcpServers entry) from ever
# reaching the real file.
- ${SANDBOX_CLAUDE_JSON_FILE:?set by bin/claude-sandbox}:${HOME}/.claude.json
- ${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
@@ -156,13 +166,13 @@ services:
# rename pattern, which fails on a single-file mount ("Device or
# resource busy" - Linux won't let that mount point's backing inode be
# replaced). GIT_CONFIG_GLOBAL above points git at the file inside
# this directory instead. See bin/claude-sandbox - nothing written
# this directory instead. See bin/dejima - nothing written
# here ever reaches the user's real ~/.gitconfig.
- ${SANDBOX_GITCONFIG_DIR:?set by bin/claude-sandbox}:${HOME}/.gitconfig-sandbox
- ${SANDBOX_GITCONFIG_DIR:?set by bin/dejima}:${HOME}/.gitconfig-sandbox
# ~/.claude/hooks contains symlinks whose targets live outside
# ~/.claude itself (this repo's hooks/, and potentially others, e.g.
# another config repo's hooks/) - those targets are bind-mounted at
# their own real host paths dynamically by bin/claude-sandbox at
# their own real host paths dynamically by bin/dejima at
# launch (it inspects the symlinks and appends the exact mounts
# needed), not listed statically here, since this repo can't assume
# what else might be installed on a given host.
@@ -170,7 +180,7 @@ services:
- nvim-data:${HOME}/.local/share/nvim
- nvim-state:${HOME}/.local/state/nvim
- nvim-cache:${HOME}/.cache/nvim
# Required only if SANDBOX_SSH_KEY isn't set - bin/claude-sandbox
# Required only if SANDBOX_SSH_KEY isn't set - bin/dejima
# itself enforces that at least one of the two is present.
- ${SSH_AUTH_SOCK:-/dev/null}:/ssh-agent
- ${SSH_KNOWN_HOSTS:-/dev/null}:${HOME}/.ssh/known_hosts:ro
+4 -4
View File
@@ -79,7 +79,7 @@ if [ -n "${GH_TOKEN:-}" ]; then
gosu "$SANDBOX_USER" gh auth setup-git
fi
# Dedicated headless SSH identity (optional - see bin/claude-sandbox's
# Dedicated headless SSH identity (optional - see bin/dejima's
# SANDBOX_SSH_KEY and README "Headless git push"): a purpose-built key
# mounted read-only, independent of whatever forwarded agent socket the
# SSH_AUTH_SOCK mount above provides. Loaded into a fresh agent owned by
@@ -99,8 +99,8 @@ if [ -s "$SANDBOX_SSH_KEY_SRC" ]; then
export SSH_AUTH_SOCK
fi
# Loopback bridge for the host-side credential proxy (see bin/claude-sandbox
# and bin/claude-sandbox-anthropic-proxy): Claude Code talks HTTP to
# Loopback bridge for the host-side credential proxy (see bin/dejima
# and bin/dejima-anthropic-proxy): Claude Code talks HTTP to
# 127.0.0.1:18300, the forwarder pipes bytes into the bind-mounted unix
# socket. Only started when this session actually has a proxy socket.
if [ -n "${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}" ]; then
@@ -142,6 +142,6 @@ fi
# (the wrapped binary on PATH, see claude-wrapper.sh) so a fresh clone of
# this repo works with no editor setup at all. Override it (e.g. to `nvim`)
# for a custom editor-driven workflow; pair with SANDBOX_NVIM_CONFIG (see
# bin/claude-sandbox) to bring in a real config for it instead of running
# bin/dejima) to bring in a real config for it instead of running
# unconfigured.
exec gosu "$SANDBOX_USER" ${SANDBOX_CMD:-claude} "$@"
+67 -40
View File
@@ -1,17 +1,25 @@
#!/bin/bash
# Based on the official Anthropic reference dev container:
# https://github.com/anthropics/claude-code/tree/main/.devcontainer
set -euo pipefail # Exit on error, undefined vars, and pipeline failures
IFS=$'\n\t' # Stricter word splitting
# Default-deny egress firewall for the sandbox, allowlisting only GitHub's
# published ranges, the fixed/extra domains below, and the Docker host
# itself. The overall shape (ipset + iptables, GitHub ranges via api.github.com/meta,
# allowlist domains resolved via dig) follows the same idea sketched in
# Anthropic's public reference dev container
# (https://github.com/anthropics/claude-code/tree/main/.devcontainer), but
# everything here - IPv6 blocking, ulogd2 verdict logging, the GitHub
# push/pull split, GH_TOKEN auth, and the extra-domains allowlist - is this
# project's own.
set -euo pipefail
IFS=$'\n\t'
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=fixed-domains.sh
source "$SCRIPT_DIR/fixed-domains.sh"
# 1. Extract Docker DNS info BEFORE any flushing
DOCKER_DNS_RULES=$(iptables-save -t nat | grep "127\.0\.0\.11" || true)
# Docker's embedded-DNS NAT rules live in the same table we're about to
# flush - snapshot them first so they can be reinstated afterward.
docker_dns_nat_rules=$(iptables-save -t nat | grep "127\.0\.0\.11" || true)
# Flush existing rules and delete existing ipsets
# Reset to a known-empty state before laying down the real ruleset.
iptables -F
iptables -X
iptables -t nat -F
@@ -38,14 +46,15 @@ else
echo "ip6tables unusable - assuming IPv6 is unavailable at the kernel level"
fi
# 2. Selectively restore ONLY internal Docker DNS resolution
if [ -n "$DOCKER_DNS_RULES" ]; then
echo "Restoring Docker DNS rules..."
# Reinsert just the rules captured above - nothing else Docker had set up in
# nat comes back, so the reset above stays meaningful.
if [ -n "$docker_dns_nat_rules" ]; then
echo "Reinstating captured Docker DNS rules"
iptables -t nat -N DOCKER_OUTPUT 2>/dev/null || true
iptables -t nat -N DOCKER_POSTROUTING 2>/dev/null || true
echo "$DOCKER_DNS_RULES" | xargs -L 1 iptables -t nat
echo "$docker_dns_nat_rules" | xargs -L 1 iptables -t nat
else
echo "No Docker DNS rules to restore"
echo "No Docker DNS rules were present to restore"
fi
# Allow localhost. DNS resolution goes entirely through Docker's embedded
@@ -65,26 +74,44 @@ iptables -A OUTPUT -o lo -j ACCEPT
ipset create allowed-domains hash:net
ipset create github-domains hash:net
# Fetch GitHub meta information and aggregate + add their IP ranges
echo "Fetching GitHub IP ranges..."
gh_ranges=$(curl -s https://api.github.com/meta)
# Pull GitHub's published IP ranges so GitHub traffic can be allowed without
# opening up the entire internet.
echo "Looking up GitHub's IP ranges..."
# Authenticated whenever GH_TOKEN is set: unauthenticated /meta is capped at
# 60 requests per hour per source IP, shared across every container start and
# everything else on the host, so a session that starts sandboxes in a loop
# exhausts it and every subsequent launch fails here. Same read-only token
# `gh` already gets (see docker-compose.yml), sent to the API it belongs to.
gh_auth=()
if [ -n "${GH_TOKEN:-}" ]; then
gh_auth=(-H "Authorization: Bearer $GH_TOKEN")
fi
gh_ranges=$(curl -s ${gh_auth[@]+"${gh_auth[@]}"} https://api.github.com/meta)
if [ -z "$gh_ranges" ]; then
echo "ERROR: Failed to fetch GitHub IP ranges"
echo "ERROR: Empty response fetching GitHub's IP ranges"
exit 1
fi
if ! echo "$gh_ranges" | jq -e '.web and .api and .git' >/dev/null; then
echo "ERROR: GitHub API response missing required fields"
# An API error (rate limiting, a bad token) returns a JSON body with a
# `message` and no ranges - non-empty, so it reaches here rather than the
# emptiness check above. Surface it instead of reporting the response as
# malformed, which points at the wrong thing entirely.
if gh_error=$(echo "$gh_ranges" | jq -re '.message' 2>/dev/null); then
echo "ERROR: GitHub API request failed: $gh_error"
else
echo "ERROR: GitHub API response missing required fields"
fi
exit 1
fi
echo "Processing GitHub IPs..."
echo "Loading GitHub's ranges into the ipset..."
while read -r cidr; do
if [[ ! "$cidr" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/[0-9]{1,2}$ ]]; then
echo "ERROR: Invalid CIDR range from GitHub meta: $cidr"
echo "ERROR: GitHub meta returned a malformed CIDR range: $cidr"
exit 1
fi
echo "Adding GitHub range $cidr"
echo " + $cidr"
# -exist: aggregate -q collapses overlapping ranges within each of
# web/api/git, but not necessarily across all three combined - the same
# crash this already caused once on the allowed-domains ipset (a
@@ -96,19 +123,19 @@ done < <(echo "$gh_ranges" | jq -r '(.web + .api + .git)[]' | aggregate -q)
# Resolve and add other allowed domains (shared with dns-proxy.sh's name
# allowlist via fixed-domains.sh, so the two can't drift apart)
for domain in "${FIXED_DOMAINS[@]}"; do
echo "Resolving $domain..."
echo "Looking up $domain..."
ips=$(dig +noall +answer A "$domain" | awk '$4 == "A" {print $5}')
if [ -z "$ips" ]; then
echo "ERROR: Failed to resolve $domain"
echo "ERROR: DNS lookup for $domain returned nothing"
exit 1
fi
while read -r ip; do
if [[ ! "$ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
echo "ERROR: Invalid IP from DNS for $domain: $ip"
echo "ERROR: $domain resolved to something that isn't an IPv4 address: $ip"
exit 1
fi
echo "Adding $ip for $domain"
echo " + $ip ($domain)"
# -exist: two domains can resolve to the same IP (e.g. pypi.org and
# files.pythonhosted.org share Fastly edge IPs), and ipset otherwise
# errors on a duplicate add, which -e/set -e turns into the whole
@@ -154,14 +181,15 @@ if [ -n "${EXTRA_ALLOWED_DOMAINS:-}" ]; then
done
fi
# Get host IP from default route
HOST_IP=$(ip route | grep default | cut -d" " -f3)
# The Docker host's IP, taken from the container's default route - needed
# below so the sandbox can still reach services running on the host itself.
HOST_IP=$(ip -4 route show default | awk '{print $3; exit}')
if [ -z "$HOST_IP" ]; then
echo "ERROR: Failed to detect host IP"
echo "ERROR: Couldn't determine the Docker host's IP from the default route"
exit 1
fi
echo "Host detected as: $HOST_IP"
echo "Docker host IP: $HOST_IP"
# Only the host itself, not its whole subnet - other machines on the same
# LAN/VPC have no reason to be reachable from this container.
@@ -195,7 +223,7 @@ iptables -A OUTPUT -m set --match-set allowed-domains dst -j ACCEPT
# GitHub gets port 443 always - read access (clone/fetch/gh over HTTPS).
# Port 22 (needed for SSH push) stays closed unless the caller explicitly
# opted in via ALLOW_GITHUB_PUSH=1 (see bin/claude-sandbox and
# opted in via ALLOW_GITHUB_PUSH=1 (see bin/dejima and
# git-push-guard.sh, which independently also has to allow the host before a
# push actually gets through) - default is push there stays physically
# unreachable, not just discouraged.
@@ -212,21 +240,20 @@ fi
iptables -A OUTPUT -j NFLOG --nflog-group 2 --nflog-prefix "reject"
iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited
echo "Firewall configuration complete"
echo "Verifying firewall rules..."
echo "Firewall rules applied. Self-checking..."
if curl --connect-timeout 5 https://example.com >/dev/null 2>&1; then
echo "ERROR: Firewall verification failed - was able to reach https://example.com"
echo "ERROR: self-check failed - a non-allowlisted host (example.com) was reachable"
exit 1
else
echo "Firewall verification passed - unable to reach https://example.com as expected"
echo "self-check ok - example.com is unreachable, as it should be"
fi
# Verify GitHub API access
# Confirm the allowlist itself actually works, not just that the default-deny does.
if ! curl --connect-timeout 5 https://api.github.com/zen >/dev/null 2>&1; then
echo "ERROR: Firewall verification failed - unable to reach https://api.github.com"
echo "ERROR: self-check failed - api.github.com should be reachable but wasn't"
exit 1
else
echo "Firewall verification passed - able to reach https://api.github.com as expected"
echo "self-check ok - api.github.com is reachable, as expected"
fi
# Verify GitHub SSH (port 22) matches whichever mode was requested - this is
@@ -236,16 +263,16 @@ fi
# rather than silently running in the wrong mode.
if [ "${ALLOW_GITHUB_PUSH:-0}" = "1" ]; then
if timeout 5 bash -c "cat < /dev/null > /dev/tcp/github.com/22" 2>/dev/null; then
echo "Firewall verification passed - able to reach github.com:22 as expected (ALLOW_GITHUB_PUSH=1)"
echo "self-check ok - github.com:22 is reachable, as expected with ALLOW_GITHUB_PUSH=1"
else
echo "ERROR: Firewall verification failed - unable to reach github.com on port 22 despite ALLOW_GITHUB_PUSH=1"
echo "ERROR: self-check failed - github.com:22 should be reachable under ALLOW_GITHUB_PUSH=1 but wasn't"
exit 1
fi
else
if timeout 5 bash -c "cat < /dev/null > /dev/tcp/github.com/22" 2>/dev/null; then
echo "ERROR: Firewall verification failed - was able to reach github.com on port 22"
echo "ERROR: self-check failed - github.com:22 was reachable without ALLOW_GITHUB_PUSH=1"
exit 1
else
echo "Firewall verification passed - unable to reach github.com:22 as expected"
echo "self-check ok - github.com:22 is unreachable, as it should be"
fi
fi
+4 -4
View File
@@ -40,7 +40,7 @@ ship_stream() {
| map(split("\t") | [.[0], (.[1:] | join("\t"))])
')
payload=$(jq -n --arg job "$job" --argjson values "$values" \
'{streams: [{stream: {job: $job, source: "claude-sandbox"}, values: $values}]}')
'{streams: [{stream: {job: $job, source: "dejima"}, values: $values}]}')
curl -s -o /dev/null --max-time 5 -H "Content-Type: application/json" \
-XPOST "$LOKI_URL/loki/api/v1/push" -d "$payload" || true
batch=()
@@ -137,15 +137,15 @@ if [ "${LOG_SHIPPER_LIB_ONLY:-0}" = "1" ]; then
return 0 2>/dev/null || exit 0
fi
ship_stream "claude-sandbox-dns" /var/log/dnsmasq.log &
ship_stream "claude-sandbox-net" /var/log/ulogd.json &
ship_stream "dejima-dns" /var/log/dnsmasq.log &
ship_stream "dejima-net" /var/log/ulogd.json &
# Conversation shipping is opt-in on top of LOKI_URL: prompts/responses/
# tool commands are session *content*, a different sensitivity class than
# DNS names and firewall verdicts.
if [ "${SANDBOX_LOG_CONVERSATION:-0}" = "1" ]; then
watch_transcripts &
ship_stream "claude-sandbox-conversation" /var/log/claude-conversation.log &
ship_stream "dejima-conversation" /var/log/claude-conversation.log &
fi
wait
+1 -1
View File
@@ -12,7 +12,7 @@
-- plain write; paste is deliberately a no-op (OSC 52 *reads* are
-- inconsistently supported and can hang waiting for a reply - paste into
-- the sandbox via the terminal's own native paste instead).
if vim.env.CLAUDE_SANDBOX == nil or vim.g.clipboard ~= nil then
if vim.env.DEJIMA == nil or vim.g.clipboard ~= nil then
return
end
+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;
}
}
+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 README "Remote
# access" 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"
+3 -3
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# PreToolUse hook: inside claude-sandbox, only allow `git push` to the Gitea
# PreToolUse hook: inside dejima, only allow `git push` to the Gitea
# host (GITEA_SSH_HOST / the GITEA_API host), and never directly to
# main/master there - the sandbox workflow is feature branch + `gitea-pr
# create`, never a direct push. GitHub push is also blocked here by default
@@ -9,10 +9,10 @@
# too. This is a fast-fail guard against an overeager agent, not a hard
# security boundary - a sandbox session has full shell access and could in
# principle edit .git/config to route around it. No-op outside the sandbox
# (CLAUDE_SANDBOX unset), since this restriction doesn't apply to normal host
# (DEJIMA unset), since this restriction doesn't apply to normal host
# usage.
if [ -z "${CLAUDE_SANDBOX:-}" ]; then
if [ -z "${DEJIMA:-}" ]; then
exit 0
fi
+171 -20
View File
@@ -1,27 +1,166 @@
#!/usr/bin/env bash
# SessionStart hook: when running inside the claude-sandbox Docker container
# SessionStart hook: when running inside the dejima Docker container
# (see https://github.com/anthropics/claude-code/tree/main/.devcontainer for
# the reference this is based on, and this repo's own README for the actual
# setup), inject a reminder of the sandbox's constraints and how to escape
# it. No-op outside the container.
if [ -z "${CLAUDE_SANDBOX:-}" ]; then
if [ -z "${DEJIMA:-}" ]; then
exit 0
fi
session_id=$(jq -r '.session_id // empty')
# Read stdin once - it's consumed by the first reader, and this hook now
# needs both session_id and cwd out of the same payload.
payload=$(cat)
session_id=$(printf '%s' "$payload" | jq -r '.session_id // empty')
resume_id="${session_id:-<unknown-session-id>}"
project_dir=$(printf '%s' "$payload" | jq -r '.cwd // empty')
project_dir="${project_dir:-$PWD}"
# /etc/hostname in a docker container is the container's own ID, which
# docker exec accepts directly - baking it into the suggested command below
# means the agent states its real container instead of guessing (a wrong
# guess that matches *another* running sandbox makes claude-sandbox-allow
# guess that matches *another* running sandbox makes dejima-allow
# "succeed" against the wrong container).
container_id=$(cat /etc/hostname 2>/dev/null)
container_id="${container_id:-<container-id>}"
# What the project says it needs, checked against what this container
# actually has. .dejima/ is on the read-write workspace mount, so it
# grants nothing - it's a declaration the user approves by hand on the host
# (see bin/dejima-project). Reporting the gap here is what turns
# "this fails mysteriously" into "tell the user to run one command".
requirements=""
decl_dir="$project_dir/.dejima"
# Everything below reads attacker-controllable text on its way into an
# agent's context window, so all three costs it can impose are bounded:
#
# MAX_DECLARED entries considered - the same cap read_declared() applies
# host-side, so this reports on the same window
# `dejima-project approve` will act on.
# MAX_ENTRY_LEN characters per entry.
#
# Within that window the report is a strict subset of what `approve` accepts,
# never a superset: it never names something approve would reject, but it can
# stay silent about something approve would take - a dotless domain (the
# regex below requires a label separator, deliberately, so a bare
# IGNORE-PREVIOUS-INSTRUCTIONS can't be echoed into the context) and a valid
# domain longer than MAX_ENTRY_LEN are both approvable host-side and both
# silently skipped here. Fail-closed in the direction that matters: the worst
# case is a missing line of advice, not an unapproved entry presented as fine.
# MAX_LISTED entries actually named in the report; the rest are only
# counted. Without it 200 entries of 200 chars would be 40 KB
# of attacker-chosen tokens per section, and a big enough
# $requirements used to push the jq call at the end past
# Linux's 128 KiB MAX_ARG_STRLEN and emit nothing at all -
# silently costing every later session in the project its
# entire briefing.
MAX_DECLARED=200
MAX_ENTRY_LEN=200
MAX_LISTED=20
# Yields at most MAX_DECLARED entries and stops reading there, so the size of
# the file itself costs nothing either. `cat file; echo` supplies the newline
# a final line may lack.
read_declared_capped() {
local line count=0
while IFS= read -r line; do
line="${line%%#*}"; line="${line// /}"
[ -n "$line" ] || continue
count=$((count + 1))
[ "$count" -le "$MAX_DECLARED" ] || break
[ "${#line}" -le "$MAX_ENTRY_LEN" ] || continue
printf '%s\n' "$line"
done < <(cat "$1"; echo)
}
# One listing per ecosystem, cached, instead of one probe per declared entry:
# an `npm ls -g` or `pip show` fork costs about a second, so probing 200
# declared entries would run past Claude Code's hook timeout and drop this
# context just as thoroughly as an oversized argv would.
installed_names() {
case "$1" in
apt) dpkg-query -W -f='${Package} ${Status}\n' 2>/dev/null | sed -n 's/ install ok installed$//p' ;;
npm) npm ls -g --depth=0 --parseable 2>/dev/null | sed -n 's#.*/node_modules/##p' ;;
# PEP 503: pip treats runs of [-_.] and case as equivalent, so
# compare normalized names rather than miss `pip:typing-extensions`
# against an installed `typing_extensions`.
pip) pip list --format=freeze 2>/dev/null | sed -n 's/^\([^=@ ][^=@ ]*\).*/\1/p' | tr 'A-Z._' 'a-z--' ;;
cargo) cargo install --list 2>/dev/null | sed -n 's/^\([^ ][^ ]*\) v[^ ]*:$/\1/p' ;;
esac
}
if [ -f "$decl_dir/dependencies" ]; then
missing_deps=""
missing_deps_n=0
declare -A installed_cache=()
while IFS= read -r spec; do
# Same shape as valid_dep_spec() in dejima-project-lib.sh -
# a leading dash (e.g. "--allow-unauthenticated") must not validate
# as a name, or this hook would echo into the agent's context an
# entry the project's own approve/status commands treat as rejected.
[[ "$spec" =~ ^(apt|npm|pip|cargo):[A-Za-z0-9][A-Za-z0-9._+-]*$ ]] || continue
kind="${spec%%:*}"
name="${spec#*:}"
[ -n "${installed_cache[$kind]+set}" ] || installed_cache[$kind]="$(installed_names "$kind")"
[ "$kind" != pip ] || name="$(printf '%s' "$name" | tr 'A-Z._' 'a-z--')"
# -F -x: a package name can contain "." and "+", both of which would
# otherwise be regex metacharacters matching the wrong entry.
grep -qxF -- "$name" <<< "${installed_cache[$kind]}" && continue
missing_deps_n=$((missing_deps_n + 1))
[ "$missing_deps_n" -le "$MAX_LISTED" ] && missing_deps="$missing_deps $spec"
done < <(read_declared_capped "$decl_dir/dependencies")
if [ "$missing_deps_n" -gt "$MAX_LISTED" ]; then
missing_deps="$missing_deps (+$((missing_deps_n - MAX_LISTED)) more)"
fi
if [ -n "$missing_deps" ]; then
requirements="$requirements
- Declared dependencies missing from this image:$missing_deps
These need an image rebuild, so they cannot be added to this session. Tell
the user to run, outside the sandbox:
dejima-project approve \"$project_dir\"
then restart the sandbox. Do not try to install them yourself."
fi
fi
if [ -f "$decl_dir/allowed-domains" ]; then
missing_domains=""
missing_domains_n=0
while IFS= read -r dom; do
# One dot minimum, unlike valid_domain()'s optional label group: a
# bare word is not a domain this hook could ever be reporting on, and
# accepting one puts arbitrary attacker text ("IGNORE-PREVIOUS-...")
# verbatim into the agent's context under a heading that reads as the
# project speaking.
[[ "$dom" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,62}\.)+[a-zA-Z0-9-]{1,63}$ ]] || continue
# Every valid domain has a dot, a BRE metachar that would match any
# char - escape it so "a.b" can't false-positive against "aXb".
grep -q "^server=/${dom//./\\.}/" /etc/dnsmasq-sandbox-servers.conf 2>/dev/null && continue
missing_domains_n=$((missing_domains_n + 1))
[ "$missing_domains_n" -le "$MAX_LISTED" ] && missing_domains="$missing_domains $dom"
done < <(read_declared_capped "$decl_dir/allowed-domains")
if [ "$missing_domains_n" -gt "$MAX_LISTED" ]; then
missing_domains="$missing_domains (+$((missing_domains_n - MAX_LISTED)) more)"
fi
if [ -n "$missing_domains" ]; then
requirements="$requirements
- Declared domains not allowed in this session:$missing_domains
Tell the user to run, outside the sandbox:
dejima-project approve \"$project_dir\"
(or, for one domain right now:
bin/dejima-allow <domain> --container $container_id)"
fi
fi
if [ -n "$requirements" ]; then
requirements="
This project declares requirements this session does not satisfy:$requirements"
fi
context=$(cat <<EOF
You are running inside the claude-sandbox Docker container, not directly on
You are running inside the dejima Docker container, not directly on
the user's machine, with
--dangerously-skip-permissions active (no tool-use confirmations).
@@ -30,15 +169,24 @@ 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 set
via ALLOWED_DOMAINS). Requests to anything else will simply fail to
connect. 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/claude-sandbox-allow <domain> --container $container_id\` (add
registry, GitHub read-only, the configured Gitea host, plus anything
approved via dejima-allow). Requests to anything else will simply
fail to connect. That restriction is not only a limit on your reach: it
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
sandbox, then continue. Quote that --container value exactly as written
here - it is this container's real ID, not something to infer.
@@ -55,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.
@@ -78,13 +225,17 @@ the host:
claude --resume $resume_id
(session history is shared via the bind-mounted ~/.claude, so it will pick
up right where this left off).
up right where this left off).$requirements
EOF
)
jq -n --arg context "$context" '{
# Fed on stdin rather than as --arg: a single argv element is capped at 128
# KiB (MAX_ARG_STRLEN), and a context that overruns it makes jq fail outright,
# emitting no context at all. The bounds above keep $requirements far below
# that, but this removes the failure mode rather than staying just inside it.
printf '%s' "$context" | jq -R -s '{
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": $context
"additionalContext": .
}
}'
+62 -11
View File
@@ -1,15 +1,19 @@
#!/bin/bash
# Idempotent installer for claude-sandbox. Symlinks this repo's hooks into
# Idempotent installer for dejima. Symlinks this repo's hooks into
# ~/.claude/hooks, registers them in ~/.claude/settings.json, and puts
# bin/claude-sandbox + bin/claude-sandbox-allow 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)"
BACKUP_DIR="$HOME/.claude-sandbox-backup"
BACKUP_DIR="$HOME/.dejima-backup"
echo "claude-sandbox install.sh"
echo "dejima install.sh"
echo "Repo: $SCRIPT_DIR"
echo
@@ -50,6 +54,13 @@ link_hook() {
link_hook sandbox-context.sh
link_hook git-push-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
# nothing else already provides it, so this repo works fully standalone
@@ -95,7 +106,7 @@ echo
BIN_DIR="$HOME/.local/bin"
if [[ ":$PATH:" != *":$BIN_DIR:"* ]]; then
echo "Warning: $BIN_DIR is not on PATH - add it to your shell profile to run 'claude-sandbox' by name instead of the full path." >&2
echo "Warning: $BIN_DIR is not on PATH - add it to your shell profile to run 'dejima' by name instead of the full path." >&2
fi
mkdir -p "$BIN_DIR"
@@ -114,9 +125,49 @@ link_bin() {
ln -s "$src" "$dest"
}
link_bin claude-sandbox
link_bin claude-sandbox-allow
# Symlinks from before the claude-sandbox -> dejima rename point at bin/
# paths that no longer exist. Only remove ones that are actually dangling -
# anything still resolving belongs to some other install, not to us.
for old in claude-sandbox claude-sandbox-allow claude-sandbox-dep claude-sandbox-project; do
old_dest="$BIN_DIR/$old"
if [ -L "$old_dest" ] && [ ! -e "$old_dest" ]; then
echo "Removing: dangling pre-rename symlink $old_dest"
rm -f "$old_dest"
fi
done
link_bin dejima
link_bin dejima-allow
link_bin dejima-dep
link_bin dejima-project
# Remote access (opt-in, see README). All three are inert without
# ~/.config/dejima/remote.conf - they refuse to run at all - so linking them
# 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/claude-sandbox ~/some/project"
echo "(or just 'claude-sandbox ~/some/project' once $BIN_DIR is on PATH)"
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 README \"Remote access\"."
else
echo "Remote access is available but not configured - nothing is listening."
echo "See README \"Remote access\" 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
+14 -14
View File
@@ -5,15 +5,15 @@
setup() {
REPO_ROOT="$BATS_TEST_DIRNAME/.."
AUDIT="$REPO_ROOT/bin/claude-sandbox-audit"
AUDIT="$REPO_ROOT/bin/dejima-audit"
WS="$(mktemp -d)"; git -C "$WS" init -q
git -C "$WS" config user.email t@t; git -C "$WS" config user.name t
STATE="$(mktemp)"
PROJDIR="$(mktemp -d)"
export CLAUDE_SANDBOX_AUDIT_PROJECTS_DIR="$PROJDIR"
export DEJIMA_AUDIT_PROJECTS_DIR="$PROJDIR"
CURRENT_SLUG="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
STATUSLINE_DIR="$(mktemp -d)"
export CLAUDE_SANDBOX_AUDIT_STATUSLINE="$STATUSLINE_DIR/statusline.sh"
export DEJIMA_AUDIT_STATUSLINE="$STATUSLINE_DIR/statusline.sh"
}
teardown() { rm -rf "$WS"; rm -f "$STATE"; rm -rf "$PROJDIR"; rm -rf "$STATUSLINE_DIR"; }
@@ -167,7 +167,7 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
mkdir -p "$WS/bin"
echo x > "$WS/bin/x"
echo x > "$WS/install.sh"
export CLAUDE_SANDBOX_AUDIT_REPO_ROOT="$(readlink -f "$WS")"
export DEJIMA_AUDIT_REPO_ROOT="$(readlink -f "$WS")"
snap
echo changed > "$WS/bin/x"
run cmp_
@@ -177,7 +177,7 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
@test "TCB: does not flag changes when the workspace is not the audit's own repo root" {
mkdir -p "$WS/bin"
echo x > "$WS/bin/x"
# CLAUDE_SANDBOX_AUDIT_REPO_ROOT is left unset here, so it defaults to
# DEJIMA_AUDIT_REPO_ROOT is left unset here, so it defaults to
# this repo's real root - $WS (a scratch dir) is not that, so the TCB
# section stays empty even though a same-named file changes.
snap
@@ -194,16 +194,16 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
}
@test "flags a plain regular-file statusline.sh replaced with different content" {
printf '#!/bin/sh\necho original\n' > "$CLAUDE_SANDBOX_AUDIT_STATUSLINE"
printf '#!/bin/sh\necho original\n' > "$DEJIMA_AUDIT_STATUSLINE"
snap
printf '#!/bin/sh\necho pwned\n' > "$CLAUDE_SANDBOX_AUDIT_STATUSLINE"
printf '#!/bin/sh\necho pwned\n' > "$DEJIMA_AUDIT_STATUSLINE"
run cmp_
[[ "$output" == *"HOST-CODE-EXECUTION"* ]]
[[ "$output" == *"statusline.sh"* ]]
}
@test "flags the confirmed exploit: a symlink statusline.sh swapped for a regular file" {
# This is the CONFIRMED Finding-1 gap: bin/claude-sandbox's read-only
# This is the CONFIRMED Finding-1 gap: bin/dejima's read-only
# mount onto ~/.claude/statusline.sh does not reliably survive when
# statusline.sh is a symlink (the common case, e.g. into a dotfiles
# checkout) - runc resolves the bind-mount destination through the
@@ -212,10 +212,10 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
# test/runtime.bats). Detection here is the compensating control.
local target_dir; target_dir="$(mktemp -d)"
printf '#!/bin/sh\necho original\n' > "$target_dir/statusline.sh"
ln -s "$target_dir/statusline.sh" "$CLAUDE_SANDBOX_AUDIT_STATUSLINE"
ln -s "$target_dir/statusline.sh" "$DEJIMA_AUDIT_STATUSLINE"
snap
rm -f "$CLAUDE_SANDBOX_AUDIT_STATUSLINE"
printf '#!/bin/sh\necho pwned\n' > "$CLAUDE_SANDBOX_AUDIT_STATUSLINE"
rm -f "$DEJIMA_AUDIT_STATUSLINE"
printf '#!/bin/sh\necho pwned\n' > "$DEJIMA_AUDIT_STATUSLINE"
run cmp_
[[ "$output" == *"HOST-CODE-EXECUTION"* ]]
[[ "$output" == *"statusline.sh"* ]]
@@ -226,16 +226,16 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
local target_a target_b; target_a="$(mktemp -d)"; target_b="$(mktemp -d)"
printf '#!/bin/sh\necho same\n' > "$target_a/statusline.sh"
printf '#!/bin/sh\necho same\n' > "$target_b/statusline.sh"
ln -s "$target_a/statusline.sh" "$CLAUDE_SANDBOX_AUDIT_STATUSLINE"
ln -s "$target_a/statusline.sh" "$DEJIMA_AUDIT_STATUSLINE"
snap
ln -sf "$target_b/statusline.sh" "$CLAUDE_SANDBOX_AUDIT_STATUSLINE"
ln -sf "$target_b/statusline.sh" "$DEJIMA_AUDIT_STATUSLINE"
run cmp_
[[ "$output" == *"HOST-CODE-EXECUTION"* ]]
rm -rf "$target_a" "$target_b"
}
@test "does not flag statusline.sh when nothing about it changed" {
printf '#!/bin/sh\necho original\n' > "$CLAUDE_SANDBOX_AUDIT_STATUSLINE"
printf '#!/bin/sh\necho original\n' > "$DEJIMA_AUDIT_STATUSLINE"
snap
run cmp_
[ "$status" -eq 0 ]
-50
View File
@@ -1,50 +0,0 @@
#!/usr/bin/env bats
# Host-side (no Docker): the config/env parser must extract ALLOWED_DOMAINS
# without ever executing the file, so a compromised self-mounted session
# can't plant host shell there for the next launch to run.
setup() {
REPO_ROOT="$BATS_TEST_DIRNAME/.."
source "$REPO_ROOT/bin/claude-sandbox-env.sh"
ENVFILE="$(mktemp)"
}
teardown() { rm -f "$ENVFILE"; }
@test "accepts the export ALLOWED_DOMAINS=\"...\" format written by claude-sandbox-allow" {
printf 'export ALLOWED_DOMAINS="crates.io index.crates.io"\n' > "$ENVFILE"
run parse_allowed_domains_env "$ENVFILE"
[ "$status" -eq 0 ]
[ "$output" = "crates.io index.crates.io" ]
}
@test "accepts an unquoted bare assignment" {
printf 'ALLOWED_DOMAINS=example.com\n' > "$ENVFILE"
run parse_allowed_domains_env "$ENVFILE"
[ "$output" = "example.com" ]
}
@test "does NOT execute command substitution in the value" {
printf 'export ALLOWED_DOMAINS="$(touch %s/pwned)"\n' "$BATS_TEST_TMPDIR" > "$ENVFILE"
run parse_allowed_domains_env "$ENVFILE"
[ ! -e "$BATS_TEST_TMPDIR/pwned" ]
[ "$output" = "" ]
}
@test "does NOT execute a trailing command line" {
printf 'export ALLOWED_DOMAINS="ok.com"\ntouch %s/pwned\n' "$BATS_TEST_TMPDIR" > "$ENVFILE"
run parse_allowed_domains_env "$ENVFILE"
[ ! -e "$BATS_TEST_TMPDIR/pwned" ]
[ "$output" = "ok.com" ]
}
@test "rejects a value containing shell metacharacters, yielding empty" {
printf 'export ALLOWED_DOMAINS="a.com; rm -rf /"\n' > "$ENVFILE"
run parse_allowed_domains_env "$ENVFILE"
[ "$output" = "" ]
}
@test "ignores comments and blank lines, missing file yields empty" {
run parse_allowed_domains_env /nonexistent/config/env
[ "$status" -eq 0 ]
[ "$output" = "" ]
}
+3 -2
View File
@@ -30,7 +30,8 @@ class Handler(BaseHTTPRequestHandler):
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
if self.command != "HEAD":
self.wfile.write(body)
def _echo(self):
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
@@ -79,7 +80,7 @@ class Handler(BaseHTTPRequestHandler):
else:
self._echo()
do_GET = do_POST = do_PUT = do_DELETE = do_PATCH = _handle
do_GET = do_POST = do_PUT = do_DELETE = do_PATCH = do_HEAD = do_OPTIONS = _handle
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
+795
View File
@@ -0,0 +1,795 @@
#!/usr/bin/env bats
# Host-side (no Docker): per-project declaration parsing, the approval store,
# and the three CLIs that manage it. Declarations are untrusted input - these
# tests are what keep them from ever becoming code or an unapproved grant.
setup() {
REPO_ROOT="$BATS_TEST_DIRNAME/.."
LIB="$REPO_ROOT/bin/dejima-project-lib.sh"
CONFIG_DIR="$(mktemp -d)"
export DEJIMA_CONFIG_DIR="$CONFIG_DIR"
PROJ="$(mktemp -d)"
mkdir -p "$PROJ/.dejima"
}
teardown() { rm -rf "$CONFIG_DIR" "$PROJ" ${STUB_DIR:+"$STUB_DIR"} ${LAUNCH_DIR:+"$LAUNCH_DIR"}; }
store_of() {
printf '%s/projects/%s' "$CONFIG_DIR" \
"$(bash -c 'source "$1"; project_slug "$2"' _ "$LIB" "$1")"
}
# A fake `docker` on PATH, so the container-facing half of
# dejima-allow can be exercised without a daemon. $1 is how many
# running sandboxes to report, $2 the exit status the in-container curl
# reachability check should return.
stub_docker() {
STUB_DIR="$(mktemp -d)"
cat > "$STUB_DIR/docker" <<EOF
#!/bin/bash
if [ "\$1" = ps ]; then
[ "$1" -eq 0 ] || echo dejima-test-container
exit 0
fi
case "\$*" in
*dig*) echo 10.0.0.1; exit 0 ;;
*curl*) exit $2 ;;
*) exit 0 ;;
esac
EOF
chmod +x "$STUB_DIR/docker"
PATH="$STUB_DIR:$PATH"
}
# bin/dejima is where an approval finally becomes a grant (a firewall
# entry, an installed package), and nothing else in this suite exercises that
# translation - the checks on it are greps over its source, which a deleted
# line can satisfy just as well as a working one. These two helpers run the
# real launcher against a stub `docker` and a throwaway $HOME, and capture the
# environment it would have handed the container. Split in two because bats'
# `run` executes in a subshell: everything the assertions need to see has to
# be set up out here first.
launch_setup() {
LAUNCH_DIR="$(mktemp -d)"
LAUNCH_ENV="$LAUNCH_DIR/run-env"
mkdir -p "$LAUNCH_DIR/home/.claude"
echo '{}' > "$LAUNCH_DIR/home/.claude/settings.json"
cat > "$LAUNCH_DIR/docker" <<'EOF'
#!/bin/bash
echo "$*" >> "${SANDBOX_TEST_DOCKER_LOG:-/dev/null}"
# For a real `docker build` (never `docker compose ... build`, whose $1 is
# "compose"), also log the generated Dockerfile's own content, tagged by -t -
# argv alone can't tell whether the Dockerfile's FROM line actually points at
# the previous layer.
if [ "$1" = build ] && [ -n "${SANDBOX_TEST_DOCKERFILE_LOG:-}" ]; then
tag="" dockerfile=""
args=("$@")
for i in "${!args[@]}"; do
case "${args[$i]}" in
-t) tag="${args[$((i + 1))]}" ;;
-f) dockerfile="${args[$((i + 1))]}" ;;
esac
done
{ echo "=== $tag ==="; cat "$dockerfile"; } >> "$SANDBOX_TEST_DOCKERFILE_LOG"
fi
for arg in "$@"; do
[ "$arg" = run ] || continue
env > "$SANDBOX_TEST_RUN_ENV"
break
done
exit 0
EOF
chmod +x "$LAUNCH_DIR/docker"
: > "$LAUNCH_DIR/sandbox-key"
chmod 600 "$LAUNCH_DIR/sandbox-key"
}
launch() {
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
SANDBOX_TEST_RUN_ENV="$LAUNCH_ENV" \
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
SANDBOX_TEST_DOCKERFILE_LOG="$LAUNCH_DIR/dockerfile-log" \
SANDBOX_CREDENTIAL_PROXY=0 \
SANDBOX_SSH_KEY="$LAUNCH_DIR/sandbox-key" \
SSH_AUTH_SOCK= ALLOWED_DOMAINS= \
"$REPO_ROOT/bin/dejima" "$@"
}
launched_env() { sed -n "s/^$1=//p" "$LAUNCH_ENV"; }
docker_log() { cat "$LAUNCH_DIR/docker-log" 2>/dev/null; }
# Prints just the lines generate_derived_dockerfile wrote for the build
# tagged $1, so callers can assert its FROM line without the other layer's
# Dockerfile (also in the same log) satisfying a plain grep.
dockerfile_for() {
awk -v want="=== $1 ===" '$0 == want { on=1; next } /^=== / { on=0 } on' \
"$LAUNCH_DIR/dockerfile-log" 2>/dev/null
}
@test "valid_domain accepts real domains and rejects shell-active input" {
run bash -c 'source "$1"; valid_domain "crates.io"' _ "$LIB"
[ "$status" -eq 0 ]
run bash -c 'source "$1"; valid_domain "static.crates.io"' _ "$LIB"
[ "$status" -eq 0 ]
run bash -c 'source "$1"; valid_domain "evil.com; curl x"' _ "$LIB"
[ "$status" -ne 0 ]
run bash -c 'source "$1"; valid_domain "\$(id)"' _ "$LIB"
[ "$status" -ne 0 ]
run bash -c 'source "$1"; valid_domain "a/b"' _ "$LIB"
[ "$status" -ne 0 ]
}
@test "valid_dep_spec accepts the four ecosystems and rejects anything else" {
for spec in apt:libpq-dev npm:typescript pip:ruff cargo:cargo-nextest apt:g++ pip:ruamel.yaml; do
run bash -c 'source "$1"; valid_dep_spec "$2"' _ "$LIB" "$spec"
[ "$status" -eq 0 ]
done
for spec in 'libpq-dev' 'gem:rails' 'apt:' 'apt:a;b' 'apt:$(id)' 'npm:a b' \
'apt:--allow-unauthenticated' 'npm:--force' 'pip:-r' 'cargo:--offline' 'apt:-'; do
run bash -c 'source "$1"; valid_dep_spec "$2"' _ "$LIB" "$spec"
[ "$status" -ne 0 ]
done
}
@test "read_declared skips comments/blanks and marks invalid entries rejected" {
cat > "$PROJ/.dejima/dependencies" <<'EOF'
# project deps
apt:libpq-dev
cargo:cargo-nextest
gem:rails
EOF
run bash -c 'source "$1"; read_declared "$2" valid_dep_spec' _ "$LIB" "$PROJ/.dejima/dependencies"
[ "$status" -eq 0 ]
[[ "$output" == *"ok apt:libpq-dev"* ]]
[[ "$output" == *"ok cargo:cargo-nextest"* ]]
[[ "$output" == *"rejected gem:rails"* ]]
[[ "$output" != *"ok gem:rails"* ]]
[[ "$output" != *"# project deps"* ]]
}
@test "read_declared prints nothing for a missing file" {
run bash -c 'source "$1"; read_declared "$2" valid_dep_spec' _ "$LIB" "$PROJ/.dejima/nope"
[ "$status" -eq 0 ]
[ "$output" = "" ]
}
@test "read_declared caps at 200 entries and says so" {
for i in $(seq 1 250); do echo "apt:pkg$i"; done > "$PROJ/.dejima/dependencies"
run bash -c 'source "$1"; read_declared "$2" valid_dep_spec' _ "$LIB" "$PROJ/.dejima/dependencies"
[ "$status" -eq 0 ]
[ "$(grep -c '^ok ' <<< "$output")" -eq 200 ]
[[ "$output" == *"truncated 250"* ]]
[[ "$output" != *"apt:pkg201"* ]]
}
@test "project_slug matches the log-shipper project-key rule" {
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" {
run bash -c 'source "$1"; ensure_project_store "$2"' _ "$LIB" "$PROJ"
[ "$status" -eq 0 ]
[ -d "$output" ]
[ "$(cat "$output/path")" = "$PROJ" ]
}
@test "ensure_global_store creates config_root and prints it" {
# setup()'s mktemp -d would make the [ -d ] below true whether or not
# ensure_global_store created anything, so start from a config_root that
# does not exist - which is the state of every host before its first
# global approval.
rm -rf "$CONFIG_DIR"
run bash -c 'source "$1"; ensure_global_store' _ "$LIB"
[ "$status" -eq 0 ]
[ "$output" = "$CONFIG_DIR" ]
[ -d "$CONFIG_DIR" ]
}
@test "the first global approval on a host with no config_root succeeds and creates it" {
# The project scope creates config_root as a side effect of mkdir -p on
# projects/<slug>; only the global scope depends on ensure_global_store
# for it. Without that mkdir this is the one command that fails, and it
# fails on the first --global anyone ever runs.
export DEJIMA_CONFIG_DIR="$CONFIG_DIR/fresh/dejima"
[ ! -d "$DEJIMA_CONFIG_DIR" ]
run "$REPO_ROOT/bin/dejima-dep" add cargo:cargo-nextest --global
[ "$status" -eq 0 ]
[ -d "$DEJIMA_CONFIG_DIR" ]
grep -qx 'cargo:cargo-nextest' "$DEJIMA_CONFIG_DIR/dependencies"
}
@test "ensure_global_store is idempotent and does not disturb an existing project store" {
bash -c 'source "$1"; ensure_project_store "$2"' _ "$LIB" "$PROJ" >/dev/null
run bash -c 'source "$1"; ensure_global_store' _ "$LIB"
[ "$status" -eq 0 ]
run bash -c 'source "$1"; ensure_global_store' _ "$LIB"
[ "$status" -eq 0 ]
[ "$output" = "$CONFIG_DIR" ]
[ "$(cat "$(store_of "$PROJ")/path")" = "$PROJ" ]
}
@test "approve_entry on the global store round-trips through approved_entries on config_root" {
bash -c 'source "$1"; approve_entry "$(ensure_global_store)" allowed-domains example.com' _ "$LIB"
run bash -c 'source "$1"; approved_entries "$(config_root)" allowed-domains' _ "$LIB"
[ "$status" -eq 0 ]
[ "$output" = "example.com" ]
}
@test "the global store and a project store hold independent entries" {
store=$(bash -c 'source "$1"; ensure_project_store "$2"' _ "$LIB" "$PROJ")
bash -c 'source "$1"; approve_entry "$(ensure_global_store)" allowed-domains global.example' _ "$LIB"
bash -c 'source "$1"; approve_entry "$2" allowed-domains project.example' _ "$LIB" "$store"
run bash -c 'source "$1"; approved_entries "$(config_root)" allowed-domains' _ "$LIB"
[ "$output" = "global.example" ]
run bash -c 'source "$1"; approved_entries "$2" allowed-domains' _ "$LIB" "$store"
[ "$output" = "project.example" ]
}
@test "resolve_project_store finds nothing before anything is approved" {
run bash -c 'source "$1"; resolve_project_store "$2"' _ "$LIB" "$PROJ"
[ "$status" -eq 0 ]
[ "$output" = "" ]
}
@test "resolve_project_store walks up so a worktree inherits its parent's approvals" {
bash -c 'source "$1"; ensure_project_store "$2"' _ "$LIB" "$PROJ" >/dev/null
mkdir -p "$PROJ/.worktrees/feature-x"
run bash -c 'source "$1"; resolve_project_store "$2"' _ "$LIB" "$PROJ/.worktrees/feature-x"
[ "$status" -eq 0 ]
[ "$(cat "$output/path")" = "$PROJ" ]
}
@test "approving from inside a worktree writes to the project's store, not a shadowing one" {
# Readers walk ancestors, writers used to create a store at the exact
# path - so the first approval made from inside a worktree created one
# that shadowed the project's for every later read, silently un-applying
# everything the worktree had been inheriting. Both writers, since they
# share ensure_project_store.
local parent wt
parent="$CONFIG_DIR/projects/$(bash -c 'source "$1"; project_slug "$2"' _ "$LIB" "$PROJ")"
wt="$PROJ/.worktrees/feat"
"$REPO_ROOT/bin/dejima-dep" add apt:parentpkg --project "$PROJ" >/dev/null
mkdir -p "$wt"
run "$REPO_ROOT/bin/dejima-dep" add apt:branchpkg --project "$wt"
[ "$status" -eq 0 ]
run env DEJIMA_ALLOW_SKIP_SESSION=1 \
"$REPO_ROOT/bin/dejima-allow" branch.example --project "$wt"
[ "$status" -eq 0 ]
[ ! -d "$CONFIG_DIR/projects/$(bash -c 'source "$1"; project_slug "$2"' _ "$LIB" "$wt")" ]
grep -qx 'apt:branchpkg' "$parent/dependencies"
grep -qx 'branch.example' "$parent/allowed-domains"
# and the inherited approval is still in force from the worktree
run "$REPO_ROOT/bin/dejima-dep" list --project "$wt"
[[ "$output" == *"apt:parentpkg"* ]]
}
@test "ensure_project_store heals a store that has no slug-collision guard" {
# An interrupted write or a hand-restored directory leaves projects/<slug>/
# with no path file, and that file is the only thing resolve_project_store
# checks to catch two projects sanitizing to the same slug. Attaching to
# such a store without healing it leaves it unguarded permanently.
mkdir -p "$(store_of "$PROJ")"
run bash -c 'source "$1"; ensure_project_store "$2"' _ "$LIB" "$PROJ"
[ "$status" -eq 0 ]
grep -qx "$PROJ" "$(store_of "$PROJ")/path"
}
@test "healing an unguarded store records the owner, not the path being approved" {
# The store found for a worktree belongs to the project above it. Writing
# the worktree's own path into it would make every later read from the
# real owner look like a slug collision and fail.
local wt="$PROJ/.worktrees/feat"
mkdir -p "$(store_of "$PROJ")" "$wt"
run bash -c 'source "$1"; ensure_project_store "$2"' _ "$LIB" "$wt"
[ "$status" -eq 0 ]
grep -qx "$PROJ" "$(store_of "$PROJ")/path"
run bash -c 'source "$1"; resolve_project_store "$2"' _ "$LIB" "$PROJ"
[ "$status" -eq 0 ]
[ "$output" = "$(store_of "$PROJ")" ]
}
@test "resolve_project_store fails loudly on a slug collision" {
store=$(bash -c 'source "$1"; ensure_project_store "$2"' _ "$LIB" "$PROJ")
echo "/some/other/project" > "$store/path"
run bash -c 'source "$1"; resolve_project_store "$2"' _ "$LIB" "$PROJ"
[ "$status" -ne 0 ]
[[ "$output" == *"collision"* ]]
}
@test "approve_entry is idempotent and unapprove_entry removes" {
store=$(bash -c 'source "$1"; ensure_project_store "$2"' _ "$LIB" "$PROJ")
bash -c 'source "$1"; approve_entry "$2" allowed-domains crates.io' _ "$LIB" "$store"
bash -c 'source "$1"; approve_entry "$2" allowed-domains crates.io' _ "$LIB" "$store"
run bash -c 'source "$1"; approved_entries "$2" allowed-domains' _ "$LIB" "$store"
[ "$(grep -c 'crates.io' <<< "$output")" -eq 1 ]
bash -c 'source "$1"; unapprove_entry "$2" allowed-domains crates.io' _ "$LIB" "$store"
run bash -c 'source "$1"; approved_entries "$2" allowed-domains' _ "$LIB" "$store"
[ "$output" = "" ]
}
@test "approved_entries rejects a corrupt approval file rather than using it" {
store=$(bash -c 'source "$1"; ensure_project_store "$2"' _ "$LIB" "$PROJ")
echo 'evil.com; curl x' > "$store/allowed-domains"
run bash -c 'source "$1"; approved_entries "$2" allowed-domains' _ "$LIB" "$store"
[ "$status" -ne 0 ]
[[ "$output" == *"corrupt"* ]]
}
@test "resolve_project_store rejects relative paths without hanging" {
run timeout 2 bash -c 'source "$1"; resolve_project_store "foo/bar"' _ "$LIB"
[ "$status" -ne 0 ]
[[ "$output" == *"absolute"* ]]
}
@test "dejima-dep add approves a spec and list shows it" {
run "$REPO_ROOT/bin/dejima-dep" add apt:libpq-dev --project "$PROJ"
[ "$status" -eq 0 ]
run "$REPO_ROOT/bin/dejima-dep" list --project "$PROJ"
[ "$status" -eq 0 ]
[[ "$output" == *"apt:libpq-dev"* ]]
}
@test "dejima-dep add says the change needs a relaunch" {
run "$REPO_ROOT/bin/dejima-dep" add apt:libpq-dev --project "$PROJ"
[ "$status" -eq 0 ]
[[ "$output" == *"next launch"* ]]
}
@test "dejima-dep rejects a malformed spec without writing anything" {
run "$REPO_ROOT/bin/dejima-dep" add 'apt:a;b' --project "$PROJ"
[ "$status" -ne 0 ]
run "$REPO_ROOT/bin/dejima-dep" list --project "$PROJ"
[[ "$output" != *"a;b"* ]]
}
@test "dejima-dep remove takes a spec back out" {
"$REPO_ROOT/bin/dejima-dep" add npm:typescript --project "$PROJ" >/dev/null
run "$REPO_ROOT/bin/dejima-dep" remove npm:typescript --project "$PROJ"
[ "$status" -eq 0 ]
run "$REPO_ROOT/bin/dejima-dep" list --project "$PROJ"
[[ "$output" != *"npm:typescript"* ]]
}
@test "dejima-dep add --global writes the global store, not a project entry" {
run "$REPO_ROOT/bin/dejima-dep" add cargo:cargo-nextest --global
[ "$status" -eq 0 ]
grep -qx 'cargo:cargo-nextest' "$CONFIG_DIR/dependencies"
[ ! -d "$CONFIG_DIR/projects" ]
}
@test "dejima-dep list --global shows global deps, plain list in an unrelated project does not" {
"$REPO_ROOT/bin/dejima-dep" add cargo:cargo-nextest --global >/dev/null
run "$REPO_ROOT/bin/dejima-dep" list --global
[[ "$output" == *"cargo:cargo-nextest"* ]]
run "$REPO_ROOT/bin/dejima-dep" list --project "$PROJ"
[[ "$output" != *"cargo:cargo-nextest"* ]]
}
@test "dejima-dep remove --global removes a globally approved spec" {
"$REPO_ROOT/bin/dejima-dep" add cargo:cargo-nextest --global >/dev/null
run "$REPO_ROOT/bin/dejima-dep" remove cargo:cargo-nextest --global
[ "$status" -eq 0 ]
run "$REPO_ROOT/bin/dejima-dep" list --global
[[ "$output" != *"cargo:cargo-nextest"* ]]
}
@test "dejima-dep remove fails the same way in both scopes when no store exists" {
export DEJIMA_CONFIG_DIR="$CONFIG_DIR/fresh/dejima"
run "$REPO_ROOT/bin/dejima-dep" remove cargo:cargo-nextest --global
[ "$status" -eq 1 ]
[[ "$output" != *"Removed"* ]]
run "$REPO_ROOT/bin/dejima-dep" remove cargo:cargo-nextest --project "$PROJ"
[ "$status" -eq 1 ]
[[ "$output" != *"Removed"* ]]
}
@test "dejima-dep rejects --global combined with --project and writes nothing" {
run "$REPO_ROOT/bin/dejima-dep" add apt:foo --global --project /tmp/x
[ "$status" -ne 0 ]
[[ "$output" == *"mutually exclusive"* ]]
[ ! -f "$CONFIG_DIR/dependencies" ]
[ ! -d "$CONFIG_DIR/projects" ]
}
@test "dejima-dep add --global rejects an invalid spec before writing anything" {
run "$REPO_ROOT/bin/dejima-dep" add 'apt:a;b' --global
[ "$status" -ne 0 ]
[ ! -f "$CONFIG_DIR/dependencies" ]
}
@test "dejima-allow --project persists without needing a container" {
run env DEJIMA_ALLOW_SKIP_SESSION=1 \
"$REPO_ROOT/bin/dejima-allow" crates.io --project "$PROJ"
[ "$status" -eq 0 ]
store="$CONFIG_DIR/projects/$(bash -c 'source "$1"; project_slug "$2"' _ "$LIB" "$PROJ")"
grep -qx 'crates.io' "$store/allowed-domains"
}
@test "dejima-allow --project records the approval with no sandbox running" {
# The documented moment to approve is right after leaving the sandbox -
# dependency approvals need a relaunch - so requiring a live container
# broke the main path: the domain was never persisted at all.
stub_docker 0 0
run "$REPO_ROOT/bin/dejima-allow" crates.io --project "$PROJ"
[ "$status" -eq 0 ]
grep -qx 'crates.io' "$(store_of "$PROJ")/allowed-domains"
}
@test "dejima-allow --project records a domain the reachability probe rejects" {
# A declared host may legitimately not answer on 443 - an internal
# service, an SSH-only host, an apex that only serves a subdomain. The
# probe's verdict is worth reporting, but it is not the user's approval
# decision, and it used to discard it.
stub_docker 1 7
run "$REPO_ROOT/bin/dejima-allow" crates.io --project "$PROJ"
[ "$status" -eq 2 ]
[[ "$output" == *"still NOT reachable"* ]]
grep -qx 'crates.io' "$(store_of "$PROJ")/allowed-domains"
}
@test "dejima-allow --global persists without needing a container" {
run env DEJIMA_ALLOW_SKIP_SESSION=1 \
"$REPO_ROOT/bin/dejima-allow" example.com --global
[ "$status" -eq 0 ]
grep -qx 'example.com' "$CONFIG_DIR/allowed-domains"
[ ! -d "$CONFIG_DIR/projects" ]
}
@test "dejima-allow --global records the approval with no sandbox running" {
# --global used to require a live container to exit 0 at all. It no
# longer does, deliberately: the natural moment to approve a domain for
# every future session is when no session is running to patch, and the
# store write above is already done by the time this is reached.
stub_docker 0 0
run "$REPO_ROOT/bin/dejima-allow" example.com --global
[ "$status" -eq 0 ]
[[ "$output" == *"applies from the next launch"* ]]
grep -qx 'example.com' "$CONFIG_DIR/allowed-domains"
}
@test "dejima-allow --global and --project together persist to both stores" {
run env DEJIMA_ALLOW_SKIP_SESSION=1 \
"$REPO_ROOT/bin/dejima-allow" example.com --global --project "$PROJ"
[ "$status" -eq 0 ]
grep -qx 'example.com' "$CONFIG_DIR/allowed-domains"
grep -qx 'example.com' "$(store_of "$PROJ")/allowed-domains"
}
@test "dejima-allow rejects an invalid domain before persisting to either store" {
run env DEJIMA_ALLOW_SKIP_SESSION=1 \
"$REPO_ROOT/bin/dejima-allow" 'evil.com; curl x' --global --project "$PROJ"
[ "$status" -ne 0 ]
[ ! -f "$CONFIG_DIR/allowed-domains" ]
[ ! -d "$CONFIG_DIR/projects" ]
}
@test "dejima-allow --global records a domain the reachability probe rejects" {
# --global used to be gated on this probe, refusing any global approval
# for a host that legitimately doesn't answer on 443 (an SSH-only host,
# an internal service). It now persists exactly like --project: the
# probe's verdict is worth reporting, but it is not the approval decision.
stub_docker 1 7
run "$REPO_ROOT/bin/dejima-allow" crates.io --global
[ "$status" -eq 2 ]
[[ "$output" == *"still NOT reachable"* ]]
grep -qx 'crates.io' "$CONFIG_DIR/allowed-domains"
}
@test "dejima-project approve persists every domain when one fails to apply" {
# Under set -e the first dejima-allow to exit nonzero used to
# abort the loop, silently skipping every domain after it - and, before
# persistence moved ahead of the container work, persisting none at all.
printf 'apt:libpq-dev\n' > "$PROJ/.dejima/dependencies"
printf 'crates.io\nstatic.crates.io\n' > "$PROJ/.dejima/allowed-domains"
stub_docker 1 7
run bash -c 'echo y | "$1" approve "$2"' _ "$REPO_ROOT/bin/dejima-project" "$PROJ"
[ "$status" -eq 0 ]
grep -qx 'crates.io' "$(store_of "$PROJ")/allowed-domains"
grep -qx 'static.crates.io' "$(store_of "$PROJ")/allowed-domains"
grep -qx 'apt:libpq-dev' "$(store_of "$PROJ")/dependencies"
[[ "$output" == *"not applied to a running session"* ]]
}
@test "dejima-allow --project still refuses an invalid domain" {
run env DEJIMA_ALLOW_SKIP_SESSION=1 \
"$REPO_ROOT/bin/dejima-allow" 'evil.com; curl x' --project "$PROJ"
[ "$status" -ne 0 ]
}
@test "dejima-project status shows declared, approved and rejected" {
printf 'apt:libpq-dev\ngem:rails\n' > "$PROJ/.dejima/dependencies"
printf 'static.crates.io\n' > "$PROJ/.dejima/allowed-domains"
"$REPO_ROOT/bin/dejima-dep" add apt:libpq-dev --project "$PROJ" >/dev/null
run "$REPO_ROOT/bin/dejima-project" status "$PROJ"
[ "$status" -eq 0 ]
[[ "$output" == *"approved"*"apt:libpq-dev"* ]]
[[ "$output" == *"rejected"*"gem:rails"* ]]
[[ "$output" == *"static.crates.io"* ]]
}
@test "dejima-project status shows a dep approved only globally as approved" {
printf 'apt:libpq-dev\n' > "$PROJ/.dejima/dependencies"
"$REPO_ROOT/bin/dejima-dep" add apt:libpq-dev --global >/dev/null
run "$REPO_ROOT/bin/dejima-project" status "$PROJ"
[ "$status" -eq 0 ]
[[ "$output" == *"approved"*"apt:libpq-dev"* ]]
}
@test "dejima-project status shows a dep approved in neither store as pending" {
printf 'apt:libpq-dev\n' > "$PROJ/.dejima/dependencies"
run "$REPO_ROOT/bin/dejima-project" status "$PROJ"
[ "$status" -eq 0 ]
[[ "$output" == *"pending"*"apt:libpq-dev"* ]]
}
@test "dejima-project status prints the global store path" {
run "$REPO_ROOT/bin/dejima-project" status "$PROJ"
[ "$status" -eq 0 ]
[[ "$output" == *"Global approvals: $CONFIG_DIR"* ]]
}
@test "dejima-project status and approve refuse a corrupt global store rather than treating it as empty" {
printf 'apt:libpq-dev\n' > "$PROJ/.dejima/dependencies"
mkdir -p "$CONFIG_DIR"
echo 'apt:evil; curl x' > "$CONFIG_DIR/dependencies"
run "$REPO_ROOT/bin/dejima-project" status "$PROJ"
[ "$status" -ne 0 ]
[[ "$output" != *"approved apt:libpq-dev"* ]]
[[ "$output" != *"pending apt:libpq-dev"* ]]
run "$REPO_ROOT/bin/dejima-project" approve "$PROJ" < /dev/null
[ "$status" -ne 0 ]
[[ "$output" != *"Declared but not approved"* ]]
[[ "$output" != *"Nothing pending approval"* ]]
}
@test "dejima-project approve reports nothing pending when declared entries are approved globally" {
printf 'apt:libpq-dev\n' > "$PROJ/.dejima/dependencies"
printf 'static.crates.io\n' > "$PROJ/.dejima/allowed-domains"
"$REPO_ROOT/bin/dejima-dep" add apt:libpq-dev --global >/dev/null
run env DEJIMA_ALLOW_SKIP_SESSION=1 \
"$REPO_ROOT/bin/dejima-allow" static.crates.io --global
[ "$status" -eq 0 ]
run "$REPO_ROOT/bin/dejima-project" approve "$PROJ" < /dev/null
[ "$status" -eq 0 ]
[[ "$output" == *"Nothing pending approval"* ]]
}
@test "dejima-project approve declines without confirmation" {
printf 'apt:libpq-dev\n' > "$PROJ/.dejima/dependencies"
run bash -c 'echo n | "$1" approve "$2"' _ "$REPO_ROOT/bin/dejima-project" "$PROJ"
[ "$status" -eq 0 ]
run "$REPO_ROOT/bin/dejima-dep" list --project "$PROJ"
[[ "$output" != *"apt:libpq-dev"* ]]
}
@test "dejima-project approve approves the declared set on confirmation" {
printf 'apt:libpq-dev\n' > "$PROJ/.dejima/dependencies"
printf 'static.crates.io\n' > "$PROJ/.dejima/allowed-domains"
run bash -c 'echo y | DEJIMA_ALLOW_SKIP_SESSION=1 "$1" approve "$2"' \
_ "$REPO_ROOT/bin/dejima-project" "$PROJ"
[ "$status" -eq 0 ]
run "$REPO_ROOT/bin/dejima-dep" list --project "$PROJ"
[[ "$output" == *"apt:libpq-dev"* ]]
store="$CONFIG_DIR/projects/$(bash -c 'source "$1"; project_slug "$2"' _ "$LIB" "$PROJ")"
grep -qx 'static.crates.io' "$store/allowed-domains"
}
@test "dejima-project approve never approves a rejected entry" {
printf 'gem:rails\n' > "$PROJ/.dejima/dependencies"
run bash -c 'echo y | "$1" approve "$2"' _ "$REPO_ROOT/bin/dejima-project" "$PROJ"
run "$REPO_ROOT/bin/dejima-dep" list --project "$PROJ"
[[ "$output" != *"gem:rails"* ]]
}
@test "dejima-project approve reports a rejected entry's literal text, unsplit and unglobbed" {
printf 'npm:has space\n' > "$PROJ/.dejima/dependencies"
printf 'a*evil\n' > "$PROJ/.dejima/allowed-domains"
run "$REPO_ROOT/bin/dejima-project" approve "$PROJ" < /dev/null
[ "$status" -eq 0 ]
[[ "$output" == *"npm:has space"* ]]
[[ "$output" == *"a*evil"* ]]
}
@test "dejima-project approve warns about entries beyond the 200-entry cap" {
for i in $(seq 1 250); do echo "apt:pkg$i"; done > "$PROJ/.dejima/dependencies"
run bash -c 'echo n | "$1" approve "$2"' _ "$REPO_ROOT/bin/dejima-project" "$PROJ"
[ "$status" -eq 0 ]
[[ "$output" == *"250"* ]]
}
@test "dejima-project approve exits 0 and approves nothing when stdin is already at EOF" {
printf 'apt:libpq-dev\n' > "$PROJ/.dejima/dependencies"
run "$REPO_ROOT/bin/dejima-project" approve "$PROJ" < /dev/null
[ "$status" -eq 0 ]
[[ "$output" == *"Nothing approved."* ]]
run "$REPO_ROOT/bin/dejima-dep" list --project "$PROJ"
[[ "$output" != *"apt:libpq-dev"* ]]
}
@test "generate_derived_dockerfile emits one layer per ecosystem and re-asserts privilege" {
store=$(bash -c 'source "$1"; ensure_project_store "$2"' _ "$LIB" "$PROJ")
printf 'apt:libpq-dev\nnpm:typescript\npip:ruff\n' > "$store/dependencies"
run bash -c 'source "$1"; generate_derived_dockerfile base:tag "$2"' _ "$LIB" "$store"
[ "$status" -eq 0 ]
[[ "$output" == *"FROM base:tag"* ]]
[[ "$output" == *"apt-get install -y --no-install-recommends libpq-dev"* ]]
[[ "$output" == *"npm install -g typescript"* ]]
[[ "$output" == *"pip install --break-system-packages ruff"* ]]
# the last privilege-relevant lines must restore the base posture
[[ "$(grep -E '^(USER|ENTRYPOINT)' <<< "$output" | tail -2)" == "USER root
ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
}
@test "generate_derived_dockerfile installs rustup only when a cargo spec is present" {
store=$(bash -c 'source "$1"; ensure_project_store "$2"' _ "$LIB" "$PROJ")
printf 'apt:libpq-dev\n' > "$store/dependencies"
run bash -c 'source "$1"; generate_derived_dockerfile base:tag "$2"' _ "$LIB" "$store"
[[ "$output" != *"rustup"* ]]
printf 'cargo:cargo-nextest\n' > "$store/dependencies"
run bash -c 'source "$1"; generate_derived_dockerfile base:tag "$2"' _ "$LIB" "$store"
[[ "$output" == *"rustup"* ]]
[[ "$output" == *"cargo install cargo-nextest"* ]]
[[ "$output" == *".cargo/bin"* ]]
}
@test "generate_derived_dockerfile refuses a corrupt approved deps file without emitting a partial Dockerfile" {
store=$(bash -c 'source "$1"; ensure_project_store "$2"' _ "$LIB" "$PROJ")
# A valid line FOLLOWED by a corrupt one - the security property is "never
# emit a partial Dockerfile," which a single-line corrupt file can't tell
# apart from "refused before emitting anything at all."
printf 'apt:libpq-dev\napt:evil; curl x\n' > "$store/dependencies"
run bash -c 'source "$1"; generate_derived_dockerfile base:tag "$2" 2>/dev/null' _ "$LIB" "$store"
[ "$status" -ne 0 ]
[ -z "$output" ]
}
@test "the launcher passes approved project domains to the container, and declared ones never" {
printf 'declared.example.com\n' > "$PROJ/.dejima/allowed-domains"
"$REPO_ROOT/bin/dejima-dep" add apt:libpq-dev --project "$PROJ" >/dev/null
printf 'approved.example.com\n' > "$(store_of "$PROJ")/allowed-domains"
launch_setup
run launch "$PROJ"
[ "$status" -eq 0 ]
[ -f "$LAUNCH_ENV" ]
[[ " $(launched_env ALLOWED_DOMAINS) " == *" approved.example.com "* ]]
[[ " $(launched_env ALLOWED_DOMAINS) " != *" declared.example.com "* ]]
}
@test "the launcher passes an approved global domain to the container" {
printf 'global.example.com\n' > "$CONFIG_DIR/allowed-domains"
launch_setup
run launch "$PROJ"
[ "$status" -eq 0 ]
[ -f "$LAUNCH_ENV" ]
[[ " $(launched_env ALLOWED_DOMAINS) " == *" global.example.com "* ]]
}
@test "the launcher merges global and project approved domains, neither replacing the other" {
printf 'global.example.com\n' > "$CONFIG_DIR/allowed-domains"
store=$(bash -c 'source "$1"; ensure_project_store "$2"' _ "$LIB" "$PROJ")
printf 'project.example.com\n' > "$store/allowed-domains"
launch_setup
run launch "$PROJ"
[ "$status" -eq 0 ]
[ -f "$LAUNCH_ENV" ]
[[ " $(launched_env ALLOWED_DOMAINS) " == *" global.example.com "* ]]
[[ " $(launched_env ALLOWED_DOMAINS) " == *" project.example.com "* ]]
}
@test "an ALLOWED_DOMAINS exported before invocation is ignored, not merged in" {
# The approved domain is what keeps this test honest: without it, the
# absence of evil.example would also be satisfied by the launcher setting
# no domains at all, or by the env prefix below never reaching the child.
printf 'good.example.com\n' > "$CONFIG_DIR/allowed-domains"
launch_setup
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
SANDBOX_TEST_RUN_ENV="$LAUNCH_ENV" \
SANDBOX_CREDENTIAL_PROXY=0 \
SANDBOX_SSH_KEY="$LAUNCH_DIR/sandbox-key" \
SSH_AUTH_SOCK= ALLOWED_DOMAINS=evil.example \
run "$REPO_ROOT/bin/dejima" "$PROJ"
[ "$status" -eq 0 ]
[ -f "$LAUNCH_ENV" ]
[[ " $(launched_env ALLOWED_DOMAINS) " == *" good.example.com "* ]]
[[ " $(launched_env ALLOWED_DOMAINS) " != *"evil.example"* ]]
}
@test "the launcher refuses to start when either store's approved domains are corrupt" {
# A store entry that no longer validates means a bug or tampering, not
# user error - launching anyway would either drop it silently or write it
# into a dnsmasq server= line. Both stores are read on the same path, so
# both are checked here.
launch_setup
printf 'evil.example; curl x\n' > "$CONFIG_DIR/allowed-domains"
run launch "$PROJ"
[ "$status" -ne 0 ]
[[ "$output" == *"corrupt approval entry"* ]]
[ ! -f "$LAUNCH_ENV" ]
rm -f "$CONFIG_DIR/allowed-domains"
store=$(bash -c 'source "$1"; ensure_project_store "$2"' _ "$LIB" "$PROJ")
printf 'evil.example; curl x\n' > "$store/allowed-domains"
run launch "$PROJ"
[ "$status" -ne 0 ]
[[ "$output" == *"corrupt approval entry"* ]]
[ ! -f "$LAUNCH_ENV" ]
}
@test "the launcher runs the derived image only when the project has approved dependencies" {
launch_setup
run launch "$PROJ"
[ "$status" -eq 0 ]
[ "$(launched_env SANDBOX_IMAGE)" = "dejima:$(id -un)" ]
"$REPO_ROOT/bin/dejima-dep" add apt:libpq-dev --project "$PROJ" >/dev/null
run launch "$PROJ"
[ "$status" -eq 0 ]
slug="$(bash -c 'source "$1"; project_slug "$2"' _ "$LIB" "$PROJ")"
[ "$(launched_env SANDBOX_IMAGE)" = "dejima:$(id -un)-$slug" ]
}
@test "the launcher builds and runs the global layer when only global deps are approved" {
"$REPO_ROOT/bin/dejima-dep" add apt:libpq-dev --global >/dev/null
launch_setup
run launch "$PROJ"
[ "$status" -eq 0 ]
grep -q -- "-t dejima:$(id -un)-global -f" <(docker_log)
[ "$(launched_env SANDBOX_IMAGE)" = "dejima:$(id -un)-global" ]
}
@test "the launcher builds the global layer then the project layer on top of it" {
"$REPO_ROOT/bin/dejima-dep" add apt:libpq-dev --global >/dev/null
"$REPO_ROOT/bin/dejima-dep" add apt:libpq-dev --project "$PROJ" >/dev/null
launch_setup
run launch "$PROJ"
[ "$status" -eq 0 ]
slug="$(bash -c 'source "$1"; project_slug "$2"' _ "$LIB" "$PROJ")"
global_line=$(grep -n -- "-t dejima:$(id -un)-global -f" <(docker_log) | cut -d: -f1)
project_line=$(grep -n -- "-t dejima:$(id -un)-$slug -f" <(docker_log) | cut -d: -f1)
[ -n "$global_line" ]
[ -n "$project_line" ]
[ "$global_line" -lt "$project_line" ]
[ "$(launched_env SANDBOX_IMAGE)" = "dejima:$(id -un)-$slug" ]
}
@test "the project layer's Dockerfile is generated FROM the global layer, not the base image" {
# The ordering test above only shows the two builds happen in order - it
# can't tell whether the second one's Dockerfile actually names the first
# one as its base, versus both independently FROM-ing the base image
# (which would silently drop the global packages from every project that
# also has its own deps).
"$REPO_ROOT/bin/dejima-dep" add apt:libpq-dev --global >/dev/null
"$REPO_ROOT/bin/dejima-dep" add apt:libpq-dev --project "$PROJ" >/dev/null
launch_setup
run launch "$PROJ"
[ "$status" -eq 0 ]
slug="$(bash -c 'source "$1"; project_slug "$2"' _ "$LIB" "$PROJ")"
dockerfile_for "dejima:$(id -un)-global" | grep -qxF "FROM dejima:$(id -un)"
dockerfile_for "dejima:$(id -un)-$slug" | grep -qxF "FROM dejima:$(id -un)-global"
}
@test "the launcher builds no dependency layer when neither global nor project deps are approved" {
launch_setup
run launch "$PROJ"
[ "$status" -eq 0 ]
run grep -- "-t dejima:" <(docker_log)
[ "$status" -ne 0 ]
[ "$(launched_env SANDBOX_IMAGE)" = "dejima:$(id -un)" ]
}
@test "the launcher warns when the approval store sits inside the mounted workspace" {
launch_setup
run launch "$PROJ"
[ "$status" -eq 0 ]
[[ "$output" != *"no longer out of reach"* ]]
# Same project, but with the store somewhere the session can rewrite it.
export DEJIMA_CONFIG_DIR="$PROJ/.config/dejima"
"$REPO_ROOT/bin/dejima-dep" add apt:libpq-dev --project "$PROJ" >/dev/null
run launch "$PROJ"
[ "$status" -eq 0 ]
[[ "$output" == *"no longer out of reach"* ]]
}
+48 -2
View File
@@ -7,7 +7,7 @@ PLACEHOLDER="sandbox-credential-proxy-placeholder"
setup() {
REPO_ROOT="$BATS_TEST_DIRNAME/.."
PROXY="$REPO_ROOT/bin/claude-sandbox-anthropic-proxy"
PROXY="$REPO_ROOT/bin/dejima-anthropic-proxy"
TD="$(mktemp -d)"
SOCK="$TD/proxy.sock"
CREDS="$TD/credentials.json"
@@ -30,7 +30,7 @@ EOF
start_proxy() {
"$PROXY" --socket "$SOCK" --credentials "$CREDS" \
--upstream "http://127.0.0.1:$PORT" \
--oauth-endpoint "http://127.0.0.1:$PORT/v1/oauth/token" "$@" &
--oauth-endpoint "http://127.0.0.1:$PORT/v1/oauth/token" "$@" 2>"$TD/stderr" &
PROXY_PID=$!
for _ in $(seq 1 50); do [ -S "$SOCK" ] && break; sleep 0.1; done
[ -S "$SOCK" ]
@@ -196,3 +196,49 @@ teardown() {
kill "$FWD_PID" 2>/dev/null || true
[ "$(jq -r '.headers.authorization' <<<"$output")" = "Bearer real-access-token" ]
}
@test "a client that disappears mid-stream is not reported as a crash" {
# Interrupting a streaming reply, or exiting with requests in flight,
# closes the container side mid-response; the proxy then takes an EPIPE
# writing the rest. socketserver's default handler dumps a full traceback
# for that, onto the same terminal the session is using. Expected
# condition, nothing to deliver, nothing to fix - so it must stay quiet.
start_proxy
timeout 0.7 curl -s --unix-socket "$SOCK" http://proxy/sse >/dev/null 2>&1 || true
sleep 3
run cat "$TD/stderr"
[[ "$output" != *"Traceback"* ]]
[[ "$output" != *"BrokenPipeError"* ]]
[ -z "$output" ]
}
@test "a genuine handler failure is still reported with its traceback" {
# The suppression has to be specific to the client going away. A real
# fault - here a chunk length that isn't hex, which blows up _read_body -
# must still surface in full, or this trades noise for blindness.
start_proxy
python3 - "$SOCK" <<'PYEOF'
import socket, sys
s = socket.socket(socket.AF_UNIX)
s.connect(sys.argv[1])
s.sendall(b"POST /v1/messages HTTP/1.1\r\nHost: proxy\r\n"
b"Transfer-Encoding: chunked\r\n\r\nNOTHEX\r\n")
s.close()
PYEOF
sleep 1
run cat "$TD/stderr"
[[ "$output" == *"Traceback"* ]]
[[ "$output" == *"ValueError"* ]]
}
@test "HEAD and OPTIONS are proxied upstream, not rejected with a 501" {
# The proxy stands in for api.anthropic.com, so the method set it accepts
# has to be the API's, not whichever do_* handlers happen to be defined.
start_proxy
run bash -c 'curl -s -o /dev/null -w "%{http_code}" --unix-socket "$1" -X OPTIONS http://proxy/v1/messages' _ "$SOCK"
[ "$output" = "200" ]
run bash -c 'curl -s -o /dev/null -w "%{http_code}" --unix-socket "$1" -I http://proxy/v1/messages' _ "$SOCK"
[ "$output" = "200" ]
run cat "$TD/stderr"
[[ "$output" != *"501"* ]]
}
+2534
View File
File diff suppressed because it is too large Load Diff
+201 -41
View File
@@ -8,7 +8,7 @@ bats_require_minimum_version 1.5.0
# file (setup_file/teardown_file, not setup/teardown) since spinning up a
# fresh one per test would make this suite too slow to actually get run.
#
# Needs Docker. Set SKIP_BUILD=1 to reuse whatever claude-sandbox:latest is
# Needs Docker. Set SKIP_BUILD=1 to reuse whatever dejima:latest is
# already built, for fast iteration - the default is to always rebuild, so a
# stale image can't produce a false pass.
#
@@ -24,10 +24,10 @@ bats_require_minimum_version 1.5.0
# BATS_FILE_TMPDIR) is a *different* value in every test, setup_file, and
# teardown_file, silently pointing them at containers that don't match.
IMAGE=claude-sandbox:latest
IMAGE=dejima:latest
container_name() { echo "claude-sandbox-test-$(basename "$BATS_FILE_TMPDIR")"; }
network_name() { echo "claude-sandbox-test-net-$(basename "$BATS_FILE_TMPDIR")"; }
container_name() { echo "dejima-test-$(basename "$BATS_FILE_TMPDIR")"; }
network_name() { echo "dejima-test-net-$(basename "$BATS_FILE_TMPDIR")"; }
# Every container this file starts must go on network_name(), never the
# default bridge: dns-proxy.sh forwards allowlisted names to Docker's
@@ -93,17 +93,33 @@ teardown_file() {
}
teardown() {
docker rm -f "claude-sandbox-test-override-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rmi "claude-sandbox-test-override:$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "claude-sandbox-test-sshkey-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "claude-sandbox-test-ghpush-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "claude-sandbox-test-claudejson-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "claude-sandbox-test-rebind-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "claude-sandbox-test-overlays-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "claude-sandbox-test-proxy-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "claude-sandbox-test-proxy-mask-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "claude-sandbox-test-statusline-regular-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "claude-sandbox-test-statusline-symlink-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
# 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
docker rm -f "dejima-test-ghpush-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-claudejson-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-rebind-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-overlays-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-proxy-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-proxy-mask-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-statusline-regular-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-statusline-symlink-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-derived-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rmi "dejima-test-derived:$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-ctxreq-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
}
dexec() {
@@ -115,8 +131,8 @@ dexec_node() {
}
@test "the container's user actually matches overridden SANDBOX_USER/UID/GID/HOME build args, not just the defaults" {
local test_image="claude-sandbox-test-override:$(basename "$BATS_FILE_TMPDIR")"
local test_container="claude-sandbox-test-override-$(basename "$BATS_FILE_TMPDIR")"
local test_image="dejima-test-override:$(basename "$BATS_FILE_TMPDIR")"
local test_container="dejima-test-override-$(basename "$BATS_FILE_TMPDIR")"
local sandbox_dir="$BATS_TEST_DIRNAME/../docker"
local repo_root="$BATS_TEST_DIRNAME/.."
@@ -153,7 +169,7 @@ dexec_node() {
key_dir="$BATS_TEST_TMPDIR"
ssh-keygen -t ed25519 -N '' -f "$key_dir/testkey" >/dev/null
test_container="claude-sandbox-test-sshkey-$(basename "$BATS_FILE_TMPDIR")"
test_container="dejima-test-sshkey-$(basename "$BATS_FILE_TMPDIR")"
docker run -d --name "$test_container" \
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
@@ -191,16 +207,16 @@ dexec_node() {
}
@test "~/.claude.json mount is a discarded per-session copy - a write inside the container never reaches the host source it was seeded from" {
# In the real bin/claude-sandbox flow, the file mounted at
# In the real bin/dejima flow, the file mounted at
# ~/.claude.json is always a *generated* per-session copy (see
# SANDBOX_CLAUDE_JSON_FILE in bin/claude-sandbox), never the real
# SANDBOX_CLAUDE_JSON_FILE in bin/dejima), never the real
# ~/.claude.json - so nothing written to it in the container, including
# Claude Code's own atomic write-temp-then-rename on startup, can ever
# reach the real file. host_copy here stands in for that real file: it's
# seeded with the same content as the mounted file but never mounted
# itself, so it can only change if something actually writes back to the
# real source - which must not happen.
local test_container="claude-sandbox-test-claudejson-$(basename "$BATS_FILE_TMPDIR")"
local test_container="dejima-test-claudejson-$(basename "$BATS_FILE_TMPDIR")"
local mounted_copy="$BATS_TEST_TMPDIR/claude.json"
local host_copy="$BATS_TEST_TMPDIR/claude-json-original.json"
@@ -265,7 +281,7 @@ dexec_node() {
}
@test "ALLOW_GITHUB_PUSH=1 opens port 22 to GitHub" {
local test_container="claude-sandbox-test-ghpush-$(basename "$BATS_FILE_TMPDIR")"
local test_container="dejima-test-ghpush-$(basename "$BATS_FILE_TMPDIR")"
docker run -d --name "$test_container" \
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
@@ -283,7 +299,7 @@ dexec_node() {
}
@test "GITEA_SSH_HOST is exempted from stop-dns-rebind, other allowed domains are not" {
local test_container="claude-sandbox-test-rebind-$(basename "$BATS_FILE_TMPDIR")"
local test_container="dejima-test-rebind-$(basename "$BATS_FILE_TMPDIR")"
docker run -d --name "$test_container" \
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
@@ -308,7 +324,7 @@ dexec_node() {
}
@test "the code-carrying ~/.claude overlay dirs are read-only inside the container" {
local test_container="claude-sandbox-test-overlays-$(basename "$BATS_FILE_TMPDIR")"
local test_container="dejima-test-overlays-$(basename "$BATS_FILE_TMPDIR")"
local src; src="$(mktemp -d)"
mkdir -p "$src/skills" "$src/commands" "$src/agents"
docker run -d --name "$test_container" \
@@ -331,17 +347,17 @@ dexec_node() {
}
@test "statusline.sh read-only mount blocks replacement when it's a plain regular file" {
# Companion to the overlay-dirs test above, but for bin/claude-sandbox's
# Companion to the overlay-dirs test above, but for bin/dejima's
# narrower statusline.sh-onto-itself mount (see its comment there) rather
# than a whole subdirectory. This test does NOT use bin/claude-sandbox or
# than a whole subdirectory. This test does NOT use bin/dejima or
# docker-compose.yml directly - it builds the same mount shape by hand: a
# whole ~/.claude directory bind-mounted read-write (docker-compose.yml's
# own ~/.claude mount), with statusline.sh itself bind-mounted read-only
# back onto its own path on top, source and destination the same literal
# path exactly as bin/claude-sandbox constructs it (container HOME
# path exactly as bin/dejima constructs it (container HOME
# matches host HOME - see Dockerfile). This shared container isn't reused
# for this (it has no ~/.claude mount at all - see setup_file).
local test_container="claude-sandbox-test-statusline-regular-$(basename "$BATS_FILE_TMPDIR")"
local test_container="dejima-test-statusline-regular-$(basename "$BATS_FILE_TMPDIR")"
local dotclaude; dotclaude="$(mktemp -d)"
printf '#!/bin/sh\necho original\n' > "$dotclaude/statusline.sh"
chmod +x "$dotclaude/statusline.sh"
@@ -378,7 +394,7 @@ dexec_node() {
rm -rf "$dotclaude"
}
@test "statusline.sh read-only mount does NOT protect it when it's a symlink (Finding 1 - confirmed gap, see bin/claude-sandbox-audit)" {
@test "statusline.sh read-only mount does NOT protect it when it's a symlink (Finding 1 - confirmed gap, see bin/dejima-audit)" {
# Same mount shape as the plain-regular-file test above, except
# statusline.sh on the host is a symlink into a separately-mounted
# directory - the common real setup
@@ -389,15 +405,15 @@ dexec_node() {
# it - and bind-mount destination resolution (runc's SecureJoin) follows
# symlinks within the rootfs. This CONFIRMS the mount resolves through
# the symlink to the target directory (already read-only via the
# separate target_dir mount below, mirroring bin/claude-sandbox's own
# separate target_dir mount below, mirroring bin/dejima's own
# symlink-target scan) instead of binding to the symlink dentry itself -
# so the symlink dentry stays a perfectly ordinary, writable entry on
# the read-write ~/.claude mount, with no mountpoint-busy protection
# either. This test is a characterization test of that CONFIRMED,
# known, deliberately-documented limitation (see bin/claude-sandbox and
# known, deliberately-documented limitation (see bin/dejima and
# README "Security notes"), not an aspiration: it asserts the
# replacement SUCCEEDS. The compensating control is detection, not
# prevention - bin/claude-sandbox-audit hashes the symlink's target
# prevention - bin/dejima-audit hashes the symlink's target
# string and reports a changed/repointed statusline.sh as a
# host-code-execution finding after the session exits (see
# test/audit.bats). If this test ever starts FAILING, that means
@@ -405,7 +421,7 @@ dexec_node() {
# have closed - that would be good news worth investigating and acting
# on (tightening the comments/audit accordingly), not a regression to
# "fix" back to the old behavior.
local test_container="claude-sandbox-test-statusline-symlink-$(basename "$BATS_FILE_TMPDIR")"
local test_container="dejima-test-statusline-symlink-$(basename "$BATS_FILE_TMPDIR")"
local target_dir dotclaude
target_dir="$(mktemp -d)"
printf '#!/bin/sh\necho original\n' > "$target_dir/statusline.sh"
@@ -548,13 +564,13 @@ EOF'
cat > "$TD/credentials.json" <<EOF
{"claudeAiOauth":{"accessToken":"real-access-token","refreshToken":"r","expiresAt":$((($(date +%s) + 7200) * 1000)),"refreshTokenExpiresAt":$((($(date +%s) + 86400) * 1000)),"scopes":[],"subscriptionType":"max","rateLimitTier":"default"}}
EOF
"$BATS_TEST_DIRNAME/../bin/claude-sandbox-anthropic-proxy" \
"$BATS_TEST_DIRNAME/../bin/dejima-anthropic-proxy" \
--socket "$TD/proxy.sock" --credentials "$TD/credentials.json" \
--upstream "http://127.0.0.1:$(cat "$TD/port")" &
PROXY_PID=$!
for _ in $(seq 1 50); do [ -S "$TD/proxy.sock" ] && break; sleep 0.1; done
container="claude-sandbox-test-proxy-$(basename "$BATS_FILE_TMPDIR")"
container="dejima-test-proxy-$(basename "$BATS_FILE_TMPDIR")"
docker run -d --name "$container" \
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
@@ -592,10 +608,10 @@ EOF
[ "$env_hits" = "0" ]
}
@test "credential mask: ~/.claude/.credentials.json in-container is empty, never the real token (the actual bin/claude-sandbox mask mechanism)" {
@test "credential mask: ~/.claude/.credentials.json in-container is empty, never the real token (the actual bin/dejima mask mechanism)" {
# Unlike the proxy-chain test above (which hand-builds a docker run to
# exercise the proxy's HTTP path), this test reuses the exact masking
# mechanism bin/claude-sandbox itself uses at launch (see
# mechanism bin/dejima itself uses at launch (see
# SANDBOX_CREDENTIALS_MASK_FILE there): a real ~/.claude directory
# bind-mounted read-write, with an empty file layered read-write on top
# at .credentials.json specifically - the same "more specific mount wins"
@@ -613,7 +629,7 @@ EOF
: > "$mask_file"
chmod 666 "$mask_file"
container="claude-sandbox-test-proxy-mask-$(basename "$BATS_FILE_TMPDIR")"
container="dejima-test-proxy-mask-$(basename "$BATS_FILE_TMPDIR")"
docker run -d --name "$container" \
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
@@ -636,7 +652,7 @@ EOF
}
@test "mid-session allowed domains get ongoing ipset tracking, not a one-time snapshot" {
# Simulate the container-side half of bin/claude-sandbox-allow for a
# Simulate the container-side half of bin/dejima-allow for a
# domain that wasn't allowed at startup (SIGHUP also clears dnsmasq's
# cache, so subsequent digs are really forwarded, not cached).
# SIGHUP-to-reload is asynchronous - give dnsmasq a moment to actually
@@ -651,7 +667,7 @@ EOF
dexec ipset test allowed-domains "$ip"
# Simulate a CDN edge rotating away and back: drop the IP, clear the
# dnsmasq cache, resolve again - the wildcard must re-add it with no
# claude-sandbox-allow involvement. This is exactly the case that used
# dejima-allow involvement. This is exactly the case that used
# to break ("allowed but blocked again minutes later").
dexec ipset del allowed-domains "$ip"
dexec bash -c 'kill -HUP "$(cat /var/run/dnsmasq-sandbox.pid)"'
@@ -694,10 +710,84 @@ EOF
[[ "$output" != *"sandbox-osc52"* ]]
}
@test "session context reports declared-but-missing project requirements" {
local ws; ws="$(mktemp -d)"
mkdir -p "$ws/.dejima"
printf 'apt:definitely-not-installed\n' > "$ws/.dejima/dependencies"
printf 'declared-not-allowed.example\n' > "$ws/.dejima/allowed-domains"
chmod -R 755 "$ws"
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 \
-v "$ws:$ws" \
-v "$BATS_TEST_DIRNAME/../hooks/sandbox-context.sh:/usr/local/bin/sandbox-context.sh:ro" \
--entrypoint /usr/local/bin/entrypoint.sh \
"$IMAGE" >/dev/null
wait_ready "$test_container"
# -w is deliberately an unrelated directory, not "$ws": if cwd extraction
# from the payload broke and silently fell back to $PWD, running with
# -w "$ws" would still find the declarations and pass regardless.
run docker exec -u node -w /tmp "$test_container" \
bash -c 'printf "{\"session_id\":\"s1\",\"cwd\":\"'"$ws"'\"}" | DEJIMA=1 /usr/local/bin/sandbox-context.sh'
[ "$status" -eq 0 ]
[[ "$output" == *"definitely-not-installed"* ]]
[[ "$output" == *"declared-not-allowed.example"* ]]
[[ "$output" == *"dejima-project approve"* ]]
docker rm -f "$test_container" >/dev/null 2>&1 || true
rm -rf "$ws"
}
@test "a derived project image gets the package and keeps the base privilege posture" {
local derived="dejima-test-derived:$(basename "$BATS_FILE_TMPDIR")"
local test_container="dejima-test-derived-$(basename "$BATS_FILE_TMPDIR")"
local ctx; ctx="$(mktemp -d)"
local store; store="$(mktemp -d)"
local df; df="$(mktemp)"
printf 'apt:cowsay\n' > "$store/dependencies"
bash -c 'source "$1"; generate_derived_dockerfile "$2" "$3"' \
_ "$BATS_TEST_DIRNAME/../bin/dejima-project-lib.sh" "$IMAGE" "$store" > "$df"
docker build -q -t "$derived" -f "$df" "$ctx" >&2
# The image's own config, not the running container: `docker run` below
# passes --entrypoint explicitly (this file's convention), which would
# override the image's ENTRYPOINT and mask its absence - so that run
# succeeding doesn't prove generate_derived_dockerfile's own ENTRYPOINT
# line survived. Checked here, straight off the build.
run docker inspect -f '{{json .Config.Entrypoint}}' "$derived"
[[ "$output" == *"/usr/local/bin/entrypoint.sh"* ]]
docker run -d --name "$test_container" \
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
-e SANDBOX_TEST_MODE=1 \
--entrypoint /usr/local/bin/entrypoint.sh \
"$derived" >/dev/null
wait_ready "$test_container"
run docker exec "$test_container" dpkg-query -W -f='${Status}' cowsay
[[ "$output" == *"install ok installed"* ]]
# the hardening assertions that matter must hold on a derived image too.
# `run !` for sudo, matching "node has no working sudo" above: it exits
# 127 rather than a permission error, since sudo isn't installed at all.
run ! docker exec -u node "$test_container" sudo -n true
run docker exec -u node "$test_container" iptables -L
[ "$status" -ne 0 ]
docker rm -f "$test_container" >/dev/null 2>&1 || true
docker rmi "$derived" >/dev/null 2>&1 || true
rm -rf "$ctx" "$store" "$df"
}
@test "live end-to-end: claude -p through the real proxy (RUN_LIVE_API=1 only)" {
[ "${RUN_LIVE_API:-0}" = "1" ] || skip "set RUN_LIVE_API=1 to hit the real API"
TD="$BATS_TEST_TMPDIR"
"$BATS_TEST_DIRNAME/../bin/claude-sandbox-anthropic-proxy" \
"$BATS_TEST_DIRNAME/../bin/dejima-anthropic-proxy" \
--socket "$TD/proxy.sock" --credentials "$HOME/.claude/.credentials.json" &
PROXY_PID=$!
for _ in $(seq 1 50); do [ -S "$TD/proxy.sock" ] && break; sleep 0.1; done
@@ -712,3 +802,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 ]
}
+397 -104
View File
@@ -66,6 +66,13 @@ setup() {
done < <(grep "ipset add" "$SANDBOX_DIR/init-firewall.sh")
}
@test "docker-compose.yml runs an init so orphaned processes get reaped" {
# Without this the session's own command is PID 1 and never reaps, so
# leaked children accumulate as zombies until the pids cgroup is full
# and the container cannot fork - see the comment on the init: line.
grep -qE '^\s*init:\s*true\s*$' "$SANDBOX_DIR/docker-compose.yml"
}
@test "docker-compose.yml never mounts the Docker socket" {
run grep -i "docker.sock" "$SANDBOX_DIR/docker-compose.yml"
[ "$status" -ne 0 ]
@@ -96,7 +103,7 @@ setup() {
# in - see docker-compose.yml's comment on this mount line. Without
# this, a compromised session can unlink a hook symlink and drop a
# plain malicious file in its place, which the dynamic-mount logic in
# bin/claude-sandbox never sees (it only protects symlink targets that
# bin/dejima never sees (it only protects symlink targets that
# resolve outside ~/.claude).
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/hooks:\$\{HOME\}/\.claude/hooks:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
}
@@ -105,10 +112,10 @@ setup() {
# A plain read-only mount hangs Claude Code on startup (it does an
# atomic write-temp-then-rename against this file even before login) -
# the fix is a generated, discarded copy, not read-only. See
# bin/claude-sandbox and README "Throwaway ~/.claude.json".
# bin/dejima and README "Throwaway ~/.claude.json".
grep -qE '^\s*-\s*\$\{SANDBOX_CLAUDE_JSON_FILE.*\}:\$\{HOME\}/\.claude\.json\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -q 'mktemp.*claude-sandbox-claude-json' "$REPO_ROOT/bin/claude-sandbox"
grep -q 'rm -f "\$SANDBOX_SETTINGS_FILE" "\$SANDBOX_CLAUDE_JSON_FILE"' "$REPO_ROOT/bin/claude-sandbox"
grep -q 'mktemp.*dejima-claude-json' "$REPO_ROOT/bin/dejima"
grep -q 'rm -f "\$SANDBOX_SETTINGS_FILE" "\$SANDBOX_CLAUDE_JSON_FILE"' "$REPO_ROOT/bin/dejima"
}
@test "gitconfig is a throwaway copy in its own directory, not a single-file mount" {
@@ -121,7 +128,7 @@ setup() {
# GIT_CONFIG_GLOBAL pointing git at the file inside it.
grep -qE '^\s*-\s*\$\{SANDBOX_GITCONFIG_DIR.*\}:\$\{HOME\}/\.gitconfig-sandbox\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*GIT_CONFIG_GLOBAL:\s*\$\{HOME\}/\.gitconfig-sandbox/\.gitconfig\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -q 'mktemp -d.*gitconfig' "$REPO_ROOT/bin/claude-sandbox"
grep -q 'mktemp -d.*gitconfig' "$REPO_ROOT/bin/dejima"
}
@test "docker-compose.yml never hardcodes /home/node - the container home is host-parametrized" {
@@ -133,56 +140,56 @@ setup() {
}
@test "docker-compose.yml builds with a per-user image tag and passes the host identity as build args" {
grep -qE '^\s*image:\s*claude-sandbox:\$\{SANDBOX_USER:-node\}\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*image:\s*\$\{SANDBOX_IMAGE:-dejima:\$\{SANDBOX_USER:-node\}\}\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*SANDBOX_USER:\s*\$\{SANDBOX_USER:-node\}\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*SANDBOX_UID:\s*\$\{SANDBOX_UID:-1000\}\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*SANDBOX_GID:\s*\$\{SANDBOX_GID:-1000\}\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*SANDBOX_HOME:\s*\$\{HOME:-/home/node\}\s*$' "$SANDBOX_DIR/docker-compose.yml"
}
@test "bin/claude-sandbox exports the host user identity for the build" {
grep -q 'SANDBOX_USER="$(id -un)"' "$REPO_ROOT/bin/claude-sandbox"
grep -q 'SANDBOX_UID="$(id -u)"' "$REPO_ROOT/bin/claude-sandbox"
grep -q 'SANDBOX_GID="$(id -g)"' "$REPO_ROOT/bin/claude-sandbox"
@test "bin/dejima exports the host user identity for the build" {
grep -q 'SANDBOX_USER="$(id -un)"' "$REPO_ROOT/bin/dejima"
grep -q 'SANDBOX_UID="$(id -u)"' "$REPO_ROOT/bin/dejima"
grep -q 'SANDBOX_GID="$(id -g)"' "$REPO_ROOT/bin/dejima"
}
@test "docker-compose.yml never hardcodes a dotfiles (or any other specific repo) mount" {
# Hook-symlink targets are discovered dynamically at launch by
# bin/claude-sandbox instead - see the next test. A hardcoded path here
# bin/dejima instead - see the next test. A hardcoded path here
# would silently break for anyone whose hooks live somewhere else.
run grep -i 'dotfiles' "$SANDBOX_DIR/docker-compose.yml"
[ "$status" -ne 0 ]
}
@test "bin/claude-sandbox dynamically discovers and mounts hook-symlink targets" {
grep -q 'readlink -f "\$entry"' "$REPO_ROOT/bin/claude-sandbox"
grep -q 'extra_mounts+=(-v "\$real_dir:\$real_dir:ro")' "$REPO_ROOT/bin/claude-sandbox"
grep -q '"\${extra_mounts\[@\]}"' "$REPO_ROOT/bin/claude-sandbox"
@test "bin/dejima dynamically discovers and mounts hook-symlink targets" {
grep -q 'readlink -f "\$entry"' "$REPO_ROOT/bin/dejima"
grep -q 'extra_mounts+=(-v "\$real_dir:\$real_dir:ro")' "$REPO_ROOT/bin/dejima"
grep -q '"\${extra_mounts\[@\]}"' "$REPO_ROOT/bin/dejima"
}
@test "bin/claude-sandbox layers a writable lazy-lock.json over the read-only nvim config mount" {
@test "bin/dejima layers a writable lazy-lock.json over the read-only nvim config mount" {
# SANDBOX_NVIM_CONFIG is mounted :ro, but lazy.nvim writes its lockfile
# back into that same directory on every plugin sync - without this
# overlay, that write fails with a read-only-filesystem error and the
# sync aborts (a real bug: plugins never finish installing, so a
# config's colorscheme fallback kicks in instead of the real one).
grep -q 'mktemp.*claude-sandbox-nvim-lock' "$REPO_ROOT/bin/claude-sandbox"
grep -q 'extra_mounts+=(-v "\$SANDBOX_NVIM_LOCKFILE:\$HOME/\.config/nvim/lazy-lock\.json")' "$REPO_ROOT/bin/claude-sandbox"
grep -q 'rm -f .*"\${SANDBOX_NVIM_LOCKFILE:-}"' "$REPO_ROOT/bin/claude-sandbox"
grep -q 'mktemp.*dejima-nvim-lock' "$REPO_ROOT/bin/dejima"
grep -q 'extra_mounts+=(-v "\$SANDBOX_NVIM_LOCKFILE:\$HOME/\.config/nvim/lazy-lock\.json")' "$REPO_ROOT/bin/dejima"
grep -q 'rm -f .*"\${SANDBOX_NVIM_LOCKFILE:-}"' "$REPO_ROOT/bin/dejima"
}
@test "bin/claude-sandbox mounts SANDBOX_EXTRA_MOUNTS entries read-only at their own path" {
grep -q 'IFS=.:. read -ra extra_paths <<< "\$SANDBOX_EXTRA_MOUNTS"' "$REPO_ROOT/bin/claude-sandbox"
grep -q 'extra_mounts+=(-v "\$path:\$path:ro")' "$REPO_ROOT/bin/claude-sandbox"
@test "bin/dejima mounts SANDBOX_EXTRA_MOUNTS entries read-only at their own path" {
grep -q 'IFS=.:. read -ra extra_paths <<< "\$SANDBOX_EXTRA_MOUNTS"' "$REPO_ROOT/bin/dejima"
grep -q 'extra_mounts+=(-v "\$path:\$path:ro")' "$REPO_ROOT/bin/dejima"
}
@test "bin/claude-sandbox forces sandbox.enabled to false in the generated settings.json" {
@test "bin/dejima forces sandbox.enabled to false in the generated settings.json" {
# Claude Code's own internal per-command sandboxing is redundant on top
# of this container and can't actually function inside it (bwrap can't
# create a namespace here) - settings.json is read-only precisely so it
# can't be patched from inside, so this has to happen in the generation
# step on the host, before the container starts.
grep -qE '\.sandbox\.enabled\s*=\s*false' "$REPO_ROOT/bin/claude-sandbox"
grep -qE '\.sandbox\.enabled\s*=\s*false' "$REPO_ROOT/bin/dejima"
}
@test "Dockerfile never installs bubblewrap or socat" {
@@ -211,34 +218,34 @@ setup() {
! grep -qE '^\s*USER\s+node\s*$' <(tail -n 8 "$SANDBOX_DIR/Dockerfile")
}
@test "bin/claude-sandbox and bin/claude-sandbox-allow resolve their own location through symlinks" {
@test "bin/dejima and bin/dejima-allow resolve their own location through symlinks" {
# install.sh symlinks both into ~/.local/bin so they can be run by name
# via PATH. A bare `dirname "${BASH_SOURCE[0]}"` (no readlink -f first)
# resolves against the symlink's own directory in that case, not this
# repo's real bin/ - a real bug hit on first use: claude-sandbox tried
# repo's real bin/ - a real bug hit on first use: dejima tried
# to open ~/.local/docker/docker-compose.yml instead of this repo's.
grep -q 'dirname "\$(readlink -f "\${BASH_SOURCE\[0\]}")"' "$REPO_ROOT/bin/claude-sandbox"
grep -q 'dirname "\$(readlink -f "\${BASH_SOURCE\[0\]}")"' "$REPO_ROOT/bin/claude-sandbox-allow"
grep -q 'dirname "\$(readlink -f "\${BASH_SOURCE\[0\]}")"' "$REPO_ROOT/bin/dejima"
grep -q 'dirname "\$(readlink -f "\${BASH_SOURCE\[0\]}")"' "$REPO_ROOT/bin/dejima-allow"
}
@test "bin/claude-sandbox-allow rejects a domain with shell metacharacters" {
run "$REPO_ROOT/bin/claude-sandbox-allow" 'evil.com;touch /tmp/pwned'
@test "bin/dejima-allow rejects a domain with shell metacharacters" {
run "$REPO_ROOT/bin/dejima-allow" 'evil.com;touch /tmp/pwned'
[ "$status" -ne 0 ]
[[ "$output" == *"Not a valid domain"* ]]
}
@test "bin/claude-sandbox-allow rejects a domain with a slash (dnsmasq server= delimiter)" {
run "$REPO_ROOT/bin/claude-sandbox-allow" 'evil.com/../etc'
@test "bin/dejima-allow rejects a domain with a slash (dnsmasq server= delimiter)" {
run "$REPO_ROOT/bin/dejima-allow" 'evil.com/../etc'
[ "$status" -ne 0 ]
[[ "$output" == *"Not a valid domain"* ]]
}
@test "bin/claude-sandbox-allow accepts a well-formed domain (passes validation)" {
@test "bin/dejima-allow accepts a well-formed domain (passes validation)" {
# --container points at a name that can't exist, so this can *never*
# reach a real running claude-sandbox session (and mutate its live
# reach a real running dejima session (and mutate its live
# firewall) even if the machine running this test happens to have one -
# it must fail at "container not found", not at validation.
run "$REPO_ROOT/bin/claude-sandbox-allow" 'crates.io' --container claude-sandbox-test-suite-nonexistent-target
run "$REPO_ROOT/bin/dejima-allow" 'crates.io' --container dejima-test-suite-nonexistent-target
[ "$status" -ne 0 ]
[[ "$output" != *"Not a valid domain"* ]]
}
@@ -268,17 +275,24 @@ setup() {
[ "$rm_line" -gt "$ssh_add_line" ]
}
@test "bin/claude-sandbox allows a dedicated SANDBOX_SSH_KEY in place of a forwarded agent" {
grep -q 'SANDBOX_SSH_KEY' "$REPO_ROOT/bin/claude-sandbox"
grep -q 'export SANDBOX_SSH_KEY_FILE' "$REPO_ROOT/bin/claude-sandbox"
@test "bin/dejima allows a dedicated SANDBOX_SSH_KEY in place of a forwarded agent" {
grep -q 'SANDBOX_SSH_KEY' "$REPO_ROOT/bin/dejima"
grep -q 'export SANDBOX_SSH_KEY_FILE' "$REPO_ROOT/bin/dejima"
}
@test "bin/claude-sandbox warns when SANDBOX_SSH_KEY looks like a default personal key" {
grep -q 'looks like a default personal SSH key' "$REPO_ROOT/bin/claude-sandbox"
run grep -E '^\s*id_rsa\|id_ecdsa\|id_ed25519\|id_dsa\|id_ecdsa_sk\|id_ed25519_sk\)\s*$' "$REPO_ROOT/bin/claude-sandbox"
@test "bin/dejima warns when SANDBOX_SSH_KEY looks like a default personal key" {
grep -q 'looks like a default personal SSH key' "$REPO_ROOT/bin/dejima"
run grep -E '^\s*id_rsa\|id_ecdsa\|id_ed25519\|id_dsa\|id_ecdsa_sk\|id_ed25519_sk\)\s*$' "$REPO_ROOT/bin/dejima"
[ "$status" -eq 0 ]
}
@test "init-firewall.sh sends GH_TOKEN on its GitHub API fetch when one is set" {
# Unauthenticated /meta is capped at 60 requests/hour per source IP,
# shared across every container start; exhausting it fails the launch.
grep -q 'gh_auth=(-H "Authorization: Bearer \$GH_TOKEN")' "$SANDBOX_DIR/init-firewall.sh"
grep -q 'curl -s \${gh_auth\[@\]+"\${gh_auth\[@\]}"} https://api.github.com/meta' "$SANDBOX_DIR/init-firewall.sh"
}
@test "ALLOW_GITHUB_PUSH defaults to 0 everywhere it's read" {
grep -qE '^\s*ALLOW_GITHUB_PUSH:\s*\$\{ALLOW_GITHUB_PUSH:-0\}\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -q '\${ALLOW_GITHUB_PUSH:-0}' "$SANDBOX_DIR/init-firewall.sh"
@@ -343,30 +357,31 @@ setup() {
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/output-styles:\$\{HOME\}/\.claude/output-styles:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
}
@test "bin/claude-sandbox pre-creates the code-carrying ~/.claude overlay dirs" {
grep -q 'mkdir -p "$HOME/.claude/skills" "$HOME/.claude/commands" "$HOME/.claude/agents" "$HOME/.claude/output-styles"' "$REPO_ROOT/bin/claude-sandbox"
@test "bin/dejima pre-creates the code-carrying ~/.claude overlay dirs" {
grep -q 'mkdir -p "$HOME/.claude/skills" "$HOME/.claude/commands" "$HOME/.claude/agents" "$HOME/.claude/output-styles"' "$REPO_ROOT/bin/dejima"
}
@test "bin/claude-sandbox overlays the workspace .git/hooks read-only when .git is a real dir" {
grep -q 'if \[ -d "$WORKSPACE_DIR/.git/hooks" \]' "$REPO_ROOT/bin/claude-sandbox"
grep -q 'extra_mounts+=(-v "$WORKSPACE_DIR/.git/hooks:$WORKSPACE_DIR/.git/hooks:ro")' "$REPO_ROOT/bin/claude-sandbox"
@test "bin/dejima overlays the workspace .git/hooks read-only when .git is a real dir" {
grep -q 'if \[ -d "$WORKSPACE_DIR/.git/hooks" \]' "$REPO_ROOT/bin/dejima"
grep -q 'extra_mounts+=(-v "$WORKSPACE_DIR/.git/hooks:$WORKSPACE_DIR/.git/hooks:ro")' "$REPO_ROOT/bin/dejima"
}
@test "bin/claude-sandbox parses config/env instead of sourcing it" {
run grep -E 'source "\$REPO_ROOT/config/env"' "$REPO_ROOT/bin/claude-sandbox"
@test "bin/dejima reads allowed domains only from the approval store" {
[ ! -e "$REPO_ROOT/config/env" ]
[ ! -e "$REPO_ROOT/bin/dejima-env.sh" ]
run grep -n 'config/env\|dejima-env' "$REPO_ROOT/bin/dejima"
[ "$status" -ne 0 ]
grep -q 'parse_allowed_domains_env "$REPO_ROOT/config/env"' "$REPO_ROOT/bin/claude-sandbox"
grep -q 'source "$SCRIPT_DIR/claude-sandbox-env.sh"' "$REPO_ROOT/bin/claude-sandbox"
grep -q 'ALLOWED_DOMAINS="$(approved_entries "$(config_root)" allowed-domains' "$REPO_ROOT/bin/dejima"
}
@test "bin/claude-sandbox snapshots before the run and compares after" {
grep -q '"$SCRIPT_DIR/claude-sandbox-audit" snapshot "$WORKSPACE_DIR"' "$REPO_ROOT/bin/claude-sandbox"
grep -q '"$SCRIPT_DIR/claude-sandbox-audit" compare "$WORKSPACE_DIR"' "$REPO_ROOT/bin/claude-sandbox"
@test "bin/dejima snapshots before the run and compares after" {
grep -q '"$SCRIPT_DIR/dejima-audit" snapshot "$WORKSPACE_DIR"' "$REPO_ROOT/bin/dejima"
grep -q '"$SCRIPT_DIR/dejima-audit" compare "$WORKSPACE_DIR"' "$REPO_ROOT/bin/dejima"
}
@test "bin/claude-sandbox warns when the workspace is this repo itself (TCB self-mount)" {
grep -q 'sandbox.s own repo' "$REPO_ROOT/bin/claude-sandbox" || \
grep -q 'TCB' "$REPO_ROOT/bin/claude-sandbox"
@test "bin/dejima warns when the workspace is this repo itself (TCB self-mount)" {
grep -q 'sandbox.s own repo' "$REPO_ROOT/bin/dejima" || \
grep -q 'TCB' "$REPO_ROOT/bin/dejima"
}
@test "compose carries the credential-proxy env plumbing, empty by default" {
@@ -386,98 +401,107 @@ setup() {
grep -q 'unset ANTHROPIC_AUTH_TOKEN' "$SANDBOX_DIR/entrypoint.sh"
}
@test "bin/claude-sandbox's exit-time audit still runs when the container run fails or is interrupted" {
@test "bin/dejima's exit-time audit still runs when the container run fails or is interrupted" {
local run_line
run_line=$(grep -n 'docker compose .* run --rm --build' "$REPO_ROOT/bin/claude-sandbox" | cut -d: -f1)
run_line=$(grep -n 'docker compose .* run --rm' "$REPO_ROOT/bin/dejima" | cut -d: -f1)
[ -n "$run_line" ]
# The compose invocation must not be a bare command under set -e - a
# nonzero/interrupted exit there would otherwise abort the script before
# the audit compare and exit below ever run.
grep -q '|| compose_status=\$?' <(sed -n "${run_line}p" "$REPO_ROOT/bin/claude-sandbox")
sed -n "1,${run_line}p" "$REPO_ROOT/bin/claude-sandbox" | grep -q 'compose_status=0'
tail -n "+$run_line" "$REPO_ROOT/bin/claude-sandbox" | grep -q '"\$SCRIPT_DIR/claude-sandbox-audit" compare "\$WORKSPACE_DIR"'
tail -n "+$run_line" "$REPO_ROOT/bin/claude-sandbox" | grep -q 'exit "\$compose_status"'
grep -q '|| compose_status=\$?' <(sed -n "${run_line}p" "$REPO_ROOT/bin/dejima")
sed -n "1,${run_line}p" "$REPO_ROOT/bin/dejima" | grep -q 'compose_status=0'
tail -n "+$run_line" "$REPO_ROOT/bin/dejima" | grep -q '"\$SCRIPT_DIR/dejima-audit" compare "\$WORKSPACE_DIR"'
tail -n "+$run_line" "$REPO_ROOT/bin/dejima" | grep -q 'exit "\$compose_status"'
}
@test "credential proxy is on by default, gated only by SANDBOX_CREDENTIAL_PROXY=0" {
grep -q '"\${SANDBOX_CREDENTIAL_PROXY:-1}" != "0"' "$REPO_ROOT/bin/claude-sandbox"
grep -q '"\${SANDBOX_CREDENTIAL_PROXY:-1}" != "0"' "$REPO_ROOT/bin/dejima"
}
@test "bin/claude-sandbox masks the credentials file and mounts the proxy socket dir" {
@test "bin/dejima masks the credentials file and mounts the proxy socket dir" {
# The mask is what keeps the real token out of the (otherwise read-write)
# ~/.claude mount; the socket dir is how API traffic gets back out to the
# host-side proxy that holds the real credential.
grep -q 'mktemp.*claude-sandbox-credentials-mask' "$REPO_ROOT/bin/claude-sandbox"
grep -q 'extra_mounts+=(-v "\$SANDBOX_CREDENTIALS_MASK_FILE:\$HOME/\.claude/\.credentials\.json")' "$REPO_ROOT/bin/claude-sandbox"
grep -q 'extra_mounts+=(-v "\$SANDBOX_ANTHROPIC_PROXY_DIR:\$SANDBOX_ANTHROPIC_PROXY_DIR:ro")' "$REPO_ROOT/bin/claude-sandbox"
grep -q 'mktemp.*dejima-credentials-mask' "$REPO_ROOT/bin/dejima"
grep -q 'extra_mounts+=(-v "\$SANDBOX_CREDENTIALS_MASK_FILE:\$HOME/\.claude/\.credentials\.json")' "$REPO_ROOT/bin/dejima"
grep -q 'extra_mounts+=(-v "\$SANDBOX_ANTHROPIC_PROXY_DIR:\$SANDBOX_ANTHROPIC_PROXY_DIR:ro")' "$REPO_ROOT/bin/dejima"
}
@test "bin/claude-sandbox also masks the proxy's write-back staging subdirectory" {
# _write() in bin/claude-sandbox-anthropic-proxy stages the rotated
@test "bin/dejima also masks the proxy's write-back staging subdirectory" {
# _write() in bin/dejima-anthropic-proxy stages the rotated
# credential in ~/.claude/.credentials-proxy-tmp before the atomic
# rename - that subdirectory is inside the otherwise read-write ~/.claude
# mount, so without its own mask a sibling temp file would be
# container-readable for the whole write window (see proxy.bats).
grep -q 'mktemp -d.*claude-sandbox-credentials-tmpdir-mask' "$REPO_ROOT/bin/claude-sandbox"
grep -q 'extra_mounts+=(-v "\$SANDBOX_CREDENTIALS_TMPDIR_MASK:\$HOME/\.claude/\.credentials-proxy-tmp:ro")' "$REPO_ROOT/bin/claude-sandbox"
grep -q '"\${SANDBOX_CREDENTIALS_TMPDIR_MASK:-}"' "$REPO_ROOT/bin/claude-sandbox"
grep -q 'mktemp -d.*dejima-credentials-tmpdir-mask' "$REPO_ROOT/bin/dejima"
grep -q 'extra_mounts+=(-v "\$SANDBOX_CREDENTIALS_TMPDIR_MASK:\$HOME/\.claude/\.credentials-proxy-tmp:ro")' "$REPO_ROOT/bin/dejima"
grep -q '"\${SANDBOX_CREDENTIALS_TMPDIR_MASK:-}"' "$REPO_ROOT/bin/dejima"
}
@test "bin/claude-sandbox refuses to launch when the proxy fails to start" {
@test "bin/dejima refuses to launch when the proxy fails to start" {
# Falling back to mounting the real credential would be a silent
# security downgrade - the failure must be loud and the launch aborted.
grep -q 'Credential proxy failed to start' "$REPO_ROOT/bin/claude-sandbox"
grep -q 'SANDBOX_CREDENTIAL_PROXY=0' "$REPO_ROOT/bin/claude-sandbox"
grep -q 'Credential proxy failed to start' "$REPO_ROOT/bin/dejima"
grep -q 'SANDBOX_CREDENTIAL_PROXY=0' "$REPO_ROOT/bin/dejima"
}
@test "cleanup reaps the proxy and its socket dir and mask file" {
grep -q 'kill "\$SANDBOX_ANTHROPIC_PROXY_PID"' "$REPO_ROOT/bin/claude-sandbox"
grep -q '"\${SANDBOX_CREDENTIALS_MASK_FILE:-}"' "$REPO_ROOT/bin/claude-sandbox"
grep -q '"\${SANDBOX_ANTHROPIC_PROXY_DIR:-}"' "$REPO_ROOT/bin/claude-sandbox"
grep -q 'kill "\$SANDBOX_ANTHROPIC_PROXY_PID"' "$REPO_ROOT/bin/dejima"
grep -q '"\${SANDBOX_CREDENTIALS_MASK_FILE:-}"' "$REPO_ROOT/bin/dejima"
grep -q '"\${SANDBOX_ANTHROPIC_PROXY_DIR:-}"' "$REPO_ROOT/bin/dejima"
}
@test "bin/claude-sandbox warns if the proxy died before the session ended" {
grep -q 'credential proxy exited before' "$REPO_ROOT/bin/claude-sandbox"
@test "bin/dejima warns if the proxy died before the session ended" {
grep -q 'credential proxy exited before' "$REPO_ROOT/bin/dejima"
}
@test "docker-compose.yml pins the compose project name so claude-sandbox-allow's label filter matches" {
# bin/claude-sandbox-allow discovers running sandboxes via the
# com.docker.compose.project=claude-sandbox label; without an explicit
@test "docker-compose.yml pins the compose project name so dejima-allow's label filter matches" {
# bin/dejima-allow discovers running sandboxes via the
# com.docker.compose.project=dejima label; without an explicit
# top-level name, compose derives the project from the compose file's
# directory ("docker"), which varies by checkout and breaks discovery.
grep -qE '^name: claude-sandbox\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^name: dejima\s*$' "$SANDBOX_DIR/docker-compose.yml"
}
@test "sandbox-context.sh gives the agent its own container ID for claude-sandbox-allow" {
@test "sandbox-context.sh gives the agent its own container ID for dejima-allow" {
# Agents used to guess their container ID and could hand back another
# running sandbox's ID, making claude-sandbox-allow "succeed" against
# running sandbox's ID, making dejima-allow "succeed" against
# the wrong container. /etc/hostname inside a container is its real ID.
grep -q '/etc/hostname' "$REPO_ROOT/hooks/sandbox-context.sh"
grep -q -- '--container' "$REPO_ROOT/hooks/sandbox-context.sh"
}
@test "bin/claude-sandbox-allow ends with a real reachability check, not just config changes" {
@test "bin/dejima-allow ends with a real reachability check, not just config changes" {
# "It said allowed but the sandbox still can't reach it" was a real
# failure mode (wrong container targeted / stale ipset) - the script
# must verify from inside the target container and say so definitively.
grep -q 'connect-timeout' "$REPO_ROOT/bin/claude-sandbox-allow"
grep -q 'Verified:' "$REPO_ROOT/bin/claude-sandbox-allow"
grep -q 'NOT reachable' "$REPO_ROOT/bin/claude-sandbox-allow"
grep -q 'connect-timeout' "$REPO_ROOT/bin/dejima-allow"
grep -q 'Verified:' "$REPO_ROOT/bin/dejima-allow"
grep -q 'NOT reachable' "$REPO_ROOT/bin/dejima-allow"
}
@test "bin/claude-sandbox-allow's multi-container error points at the sandbox's own ID" {
grep -q '/etc/hostname' "$REPO_ROOT/bin/claude-sandbox-allow"
@test "bin/dejima-allow's multi-container error points at the sandbox's own ID" {
grep -q '/etc/hostname' "$REPO_ROOT/bin/dejima-allow"
}
@test "bin/claude-sandbox mounts targets of top-level ~/.claude symlinks (statusline.sh)" {
@test "bin/dejima-allow --global no longer rewrites config/env" {
# --global persists to the host-side approval store now (Task 3); the old
# sed -i rewrite of config/env's ALLOWED_DOMAINS is gone for good.
run grep -F 'config/env' "$REPO_ROOT/bin/dejima-allow"
[ "$status" -ne 0 ]
run grep -F 'sed -i' "$REPO_ROOT/bin/dejima-allow"
[ "$status" -ne 0 ]
}
@test "bin/dejima mounts targets of top-level ~/.claude symlinks (statusline.sh)" {
# settings.json's statusLine points at ~/.claude/statusline.sh, which on
# this setup is a symlink into dotfiles - previously only the five
# overlay subdirectories were scanned for symlinks, so the target dir
# never got mounted and the statusline silently died in the sandbox.
grep -q 'for overlay_dir in \. hooks skills commands agents output-styles' "$REPO_ROOT/bin/claude-sandbox"
grep -q 'for overlay_dir in \. hooks skills commands agents output-styles' "$REPO_ROOT/bin/dejima"
}
@test "bin/claude-sandbox mounts ~/.claude/statusline.sh itself read-only, when present" {
@test "bin/dejima mounts ~/.claude/statusline.sh itself read-only, when present" {
# statusline.sh is a top-level file directly on the read-write ~/.claude
# mount (unlike settings.json/CLAUDE.md/plugins/hooks/skills/commands/
# agents/output-styles, none of which have this gap) - settings.json's
@@ -489,10 +513,10 @@ setup() {
# (not every host has a statusline, and mounting a nonexistent source
# would make Docker create a root-owned directory at the target
# instead).
grep -q 'extra_mounts+=(-v "\$HOME/\.claude/statusline\.sh:\$HOME/\.claude/statusline\.sh:ro")' "$REPO_ROOT/bin/claude-sandbox"
grep -q 'extra_mounts+=(-v "\$HOME/\.claude/statusline\.sh:\$HOME/\.claude/statusline\.sh:ro")' "$REPO_ROOT/bin/dejima"
}
@test "bin/claude-sandbox refuses to launch rather than silently skip a dangling statusline.sh symlink" {
@test "bin/dejima refuses to launch rather than silently skip a dangling statusline.sh symlink" {
# Plain -e follows symlinks and is false for a *dangling* one (target
# doesn't exist, e.g. a dotfiles checkout that isn't present) - silently
# skipping the mount in that case would reopen the exact gap the test
@@ -502,20 +526,20 @@ setup() {
# the user. So this must be a hard, explicit error - same fail-loud
# precedent as the credential proxy refusing to launch rather than
# silently mounting the real OAuth credential.
grep -q 'if \[ -L "\$HOME/\.claude/statusline\.sh" \] && \[ ! -e "\$HOME/\.claude/statusline\.sh" \]' "$REPO_ROOT/bin/claude-sandbox"
grep -q "Docker can't bind-mount a dangling symlink" "$REPO_ROOT/bin/claude-sandbox"
grep -q 'if \[ -L "\$HOME/\.claude/statusline\.sh" \] && \[ ! -e "\$HOME/\.claude/statusline\.sh" \]' "$REPO_ROOT/bin/dejima"
grep -q "Docker can't bind-mount a dangling symlink" "$REPO_ROOT/bin/dejima"
}
@test "bin/claude-sandbox-audit monitors the home-level ~/.claude/statusline.sh (Finding 1 compensating control)" {
@test "bin/dejima-audit monitors the home-level ~/.claude/statusline.sh (Finding 1 compensating control)" {
# The read-only mount above does not reliably protect statusline.sh when
# it's a symlink (confirmed live-container gap - see test/runtime.bats
# and bin/claude-sandbox's comment on the mount) - detection is the
# and bin/dejima's comment on the mount) - detection is the
# compensating control. Full behavioral coverage (snapshot/compare
# against real fixtures, including the symlink-swap exploit) lives in
# test/audit.bats; this just confirms the wiring exists.
grep -q 'STATUSLINE_PATH=' "$REPO_ROOT/bin/claude-sandbox-audit"
grep -q 'statusline_snapshot' "$REPO_ROOT/bin/claude-sandbox-audit"
grep -q 'HOST-CODE-EXECUTION' "$REPO_ROOT/bin/claude-sandbox-audit"
grep -q 'STATUSLINE_PATH=' "$REPO_ROOT/bin/dejima-audit"
grep -q 'statusline_snapshot' "$REPO_ROOT/bin/dejima-audit"
grep -q 'HOST-CODE-EXECUTION' "$REPO_ROOT/bin/dejima-audit"
}
@test "Dockerfile installs the sandbox clipboard fallback into nvim's VIMRUNTIME plugin dir" {
@@ -571,3 +595,272 @@ this line is not json'
run grep -nE 'docker exec.*pgrep -x sleep' "$REPO_ROOT/test/runtime.bats"
[ "$status" -ne 0 ]
}
@test "dejima-allow's session-skip escape hatch is test-only, never a silent no-op path" {
# DEJIMA_ALLOW_SKIP_SESSION exists so test/project.bats can
# exercise --project persistence without a live container. It must never
# be reachable from the documented interface.
run grep -c 'DEJIMA_ALLOW_SKIP_SESSION' "$REPO_ROOT/bin/dejima-allow"
[ "$output" -eq 1 ]
run grep 'DEJIMA_ALLOW_SKIP_SESSION' "$REPO_ROOT/README.md"
[ "$status" -ne 0 ]
}
@test "bin/dejima merges approved project domains into ALLOWED_DOMAINS" {
grep -q 'dejima-project-lib.sh' "$REPO_ROOT/bin/dejima"
grep -q 'resolve_project_store "$WORKSPACE_DIR"' "$REPO_ROOT/bin/dejima"
}
@test "bin/dejima never reads a declaration file as a grant" {
# .dejima/ in the workspace is session-writable. It may only be
# read by the reporting/approval path, never by the launcher deciding
# what to allow.
run grep -n '\.dejima/' "$REPO_ROOT/bin/dejima"
[ "$status" -ne 0 ]
}
@test "docker-compose.yml image honors SANDBOX_IMAGE with today's default" {
grep -qE '^\s*image:\s*\$\{SANDBOX_IMAGE:-dejima:\$\{SANDBOX_USER:-node\}\}\s*$' \
"$SANDBOX_DIR/docker-compose.yml"
}
@test "bin/dejima explicitly builds the base image, pinned to the base tag on that exact line" {
# docker-compose.yml's image: now honors $SANDBOX_IMAGE (see the test
# above), so an unpinned `compose build claude` would inherit whatever
# $SANDBOX_IMAGE happens to be in the caller's environment - e.g. a
# derived tag left over from a previous project session - and rebuild
# the base Dockerfile under that name instead of the base tag. Anchored
# to the line immediately before the build invocation itself: the
# fallback `SANDBOX_IMAGE="dejima:${SANDBOX_USER}"` assignment a
# couple of lines later would satisfy a looser "does the pin appear
# anywhere in the file" check just as well, without actually applying to
# this build.
local build_line
build_line=$(grep -n 'docker compose -f "\$COMPOSE_FILE" build claude' "$REPO_ROOT/bin/dejima" | cut -d: -f1)
[ -n "$build_line" ]
run sed -n "$((build_line - 1))p" "$REPO_ROOT/bin/dejima"
[[ "$output" == 'SANDBOX_IMAGE="dejima:${SANDBOX_USER}" \' ]]
}
@test "the derived image build never uses the workspace as its build context" {
# A root-context build with the untrusted project as context would let a
# session's own files influence the image it then runs in.
run grep -n 'docker build.*"\$WORKSPACE_DIR"' "$REPO_ROOT/bin/dejima"
[ "$status" -ne 0 ]
grep -q 'SANDBOX_DERIVED_CONTEXT' "$REPO_ROOT/bin/dejima"
}
@test "the global dependency layer is built under the -global tag, guarded by the global store" {
# Anchored to the line immediately before the call: a file-wide grep for
# the guard or the tag alone would pass even if they'd drifted apart.
local call_line
call_line=$(grep -n 'build_dep_layer "dejima:\${SANDBOX_USER}-global"' "$REPO_ROOT/bin/dejima" | cut -d: -f1)
[ -n "$call_line" ]
run sed -n "$((call_line - 1))p" "$REPO_ROOT/bin/dejima"
[[ "$output" == 'if [ -s "$(config_root)/dependencies" ]; then' ]]
}
@test "build_dep_layer advances SANDBOX_IMAGE to the tag it just built" {
# Without this, the second call in a global+project build would build the
# project layer FROM the base image instead of FROM the global layer.
grep -q 'SANDBOX_IMAGE="\$tag"' "$REPO_ROOT/bin/dejima"
}
@test "the project dependency layer is still guarded by a non-empty PROJECT_STORE" {
local call_line
call_line=$(grep -n 'build_dep_layer "dejima:\${SANDBOX_USER}-\$(project_slug' "$REPO_ROOT/bin/dejima" | cut -d: -f1)
[ -n "$call_line" ]
run sed -n "$((call_line - 1))p" "$REPO_ROOT/bin/dejima"
[[ "$output" == 'if [ -n "$PROJECT_STORE" ] && [ -s "$PROJECT_STORE/dependencies" ]; then' ]]
}
@test "the approval store is never mounted into the container" {
# The store is the only thing that actually grants anything, and the
# container runs an agent with no tool-use confirmations - mounting it
# anywhere, at any mode, would let a session widen its own next launch
# (a read-only mount still leaks every other project's approvals).
run grep -nE 'config/dejima|DEJIMA_CONFIG_DIR|PROJECT_STORE' \
"$SANDBOX_DIR/docker-compose.yml"
[ "$status" -ne 0 ]
run grep -nE '^\s*extra_mounts\+=.*(PROJECT_STORE|config_root)' "$REPO_ROOT/bin/dejima"
[ "$status" -ne 0 ]
}
@test "sandbox-context escapes a declared domain's dots before matching the servers file" {
# $dom comes straight off a session-writable declaration and lands in a
# grep BRE, where an unescaped "." matches any character: a project could
# declare "aexample.com" and have the hook match the server= line for an
# already-approved "a.example.com", reporting an unapproved domain as
# allowed. Anchored to the matching line itself - the servers file is an
# absolute path only the container has, so this can't be driven from a
# host-side test, and a file-wide grep would be satisfied by the same
# expression appearing anywhere else.
run grep -n 'dnsmasq-sandbox-servers.conf' "$REPO_ROOT/hooks/sandbox-context.sh"
[ "$status" -eq 0 ]
[ "$(printf '%s\n' "$output" | wc -l)" -eq 1 ]
[[ "$output" == *'grep -q "^server=/${dom//./\\.}/"'* ]]
}
@test "sandbox-context reads its stdin payload once" {
# jq is called more than once now (session_id and cwd); reading stdin
# twice would silently give the second call nothing.
grep -q 'payload=$(cat)' "$REPO_ROOT/hooks/sandbox-context.sh"
run grep -c "jq -r '\.session_id" "$REPO_ROOT/hooks/sandbox-context.sh"
[ "$output" -eq 1 ]
}
@test "sandbox-context probes declared dependencies and never treats them as granted" {
grep -q 'dpkg-query' "$REPO_ROOT/hooks/sandbox-context.sh"
grep -q 'npm ls -g' "$REPO_ROOT/hooks/sandbox-context.sh"
grep -q 'pip list' "$REPO_ROOT/hooks/sandbox-context.sh"
grep -q 'cargo install --list' "$REPO_ROOT/hooks/sandbox-context.sh"
# domains are checked against what this container actually resolves,
# not against the host-side approval store it cannot see
grep -q 'dnsmasq-sandbox-servers.conf' "$REPO_ROOT/hooks/sandbox-context.sh"
grep -q 'dejima-project approve' "$REPO_ROOT/hooks/sandbox-context.sh"
}
@test "sandbox-context reports nothing when no project declarations exist" {
local ws; ws="$(mktemp -d)"
local out
out=$(printf '{"session_id":"s1","cwd":"%s"}' "$ws" | DEJIMA=1 bash "$REPO_ROOT/hooks/sandbox-context.sh")
rm -rf "$ws"
[[ "$out" != *"declares requirements"* ]]
}
@test "sandbox-context still reports a declared dependency with no trailing newline" {
# A while/read loop fed straight from the file drops a final line that
# has no trailing newline - not a validation rejection, just never read.
local ws; ws="$(mktemp -d)"
mkdir -p "$ws/.dejima"
printf 'apt:definitely-not-installed-noeol' > "$ws/.dejima/dependencies"
local out
out=$(printf '{"session_id":"s1","cwd":"%s"}' "$ws" | DEJIMA=1 bash "$REPO_ROOT/hooks/sandbox-context.sh")
rm -rf "$ws"
[[ "$out" == *"definitely-not-installed-noeol"* ]]
}
@test "sandbox-context bounds a huge declaration file instead of emitting nothing" {
# .dejima/ is on the read-write workspace mount, so a compromised
# session can append to it at will. Unbounded, that used to push the
# assembled context past Linux's 128 KiB MAX_ARG_STRLEN and make jq fail
# outright - costing every later session in the project its entire
# briefing (sandbox notice, worktree rule, never-push-to-main rule), and
# destroying the very report that would have named the junk.
local ws lab i; ws="$(mktemp -d)"
mkdir -p "$ws/.dejima"
lab="$(printf 'a%.0s' $(seq 1 62))"
for i in $(seq 1 700); do printf 'x%03d.%s.%s.%s\n' "$i" "$lab" "$lab" "$lab"; done \
> "$ws/.dejima/allowed-domains"
local out
out=$(printf '{"session_id":"s1","cwd":"%s"}' "$ws" | DEJIMA=1 bash "$REPO_ROOT/hooks/sandbox-context.sh")
rm -rf "$ws"
printf '%s' "$out" | jq -e '.hookSpecificOutput.additionalContext' >/dev/null
[[ "$out" == *"dejima Docker container"* ]]
# 700 declared, 200 considered (the cap read_declared applies host-side),
# 20 of those named and the remaining 180 only counted. Both caps have to
# hold for this exact number to appear.
[[ "$out" == *"(+180 more)"* ]]
[ "$(printf '%s' "$out" | wc -c)" -lt 16000 ]
# The context must not travel as a single argv element even so - staying
# just inside a limit is not the same as not having one.
run grep -n 'jq -n --arg context' "$REPO_ROOT/hooks/sandbox-context.sh"
[ "$status" -ne 0 ]
}
@test "sandbox-context probes each ecosystem once, not once per declared entry" {
# An `npm ls -g` fork costs about a second, so one per declared entry
# would run 200 of them at the cap - past Claude Code's hook timeout,
# which drops the injected context just as completely as a jq failure.
local ws stub i; ws="$(mktemp -d)"; stub="$(mktemp -d)"
mkdir -p "$ws/.dejima"
for i in $(seq 1 50); do echo "npm:nosuchpkg$i"; done > "$ws/.dejima/dependencies"
cat > "$stub/npm" <<EOF
#!/bin/sh
echo call >> "$stub/calls"
exit 1
EOF
chmod +x "$stub/npm"
local out
out=$(printf '{"session_id":"s1","cwd":"%s"}' "$ws" \
| PATH="$stub:$PATH" DEJIMA=1 bash "$REPO_ROOT/hooks/sandbox-context.sh")
local calls; calls=$(wc -l < "$stub/calls")
rm -rf "$ws" "$stub"
[ "$calls" -eq 1 ]
[[ "$out" == *"nosuchpkg1"* ]]
}
@test "sandbox-context ignores a dotless declared domain instead of echoing it verbatim" {
# A bare word is not a domain this hook could ever be reporting on, so
# accepting one only lets a session put arbitrary text into the next
# session's context under a heading that reads as the project speaking.
local ws; ws="$(mktemp -d)"
mkdir -p "$ws/.dejima"
printf 'IGNORE-PREVIOUS-INSTRUCTIONS\nreal.example.com\n' > "$ws/.dejima/allowed-domains"
local out
out=$(printf '{"session_id":"s1","cwd":"%s"}' "$ws" | DEJIMA=1 bash "$REPO_ROOT/hooks/sandbox-context.sh")
rm -rf "$ws"
[[ "$out" != *"IGNORE-PREVIOUS-INSTRUCTIONS"* ]]
[[ "$out" == *"real.example.com"* ]]
}
@test "install.sh links the per-project CLIs alongside the existing ones" {
grep -qx 'link_bin dejima-dep' "$REPO_ROOT/install.sh"
grep -qx 'link_bin dejima-project' "$REPO_ROOT/install.sh"
}
@test "README documents the declaration/approval split" {
grep -q '\.dejima/' "$REPO_ROOT/README.md"
grep -q 'dejima-project approve' "$REPO_ROOT/README.md"
grep -q 'dejima-dep' "$REPO_ROOT/README.md"
}
@test "README no longer documents the removed config/env flow" {
run grep -n 'config/env\|dejima-env' "$REPO_ROOT/README.md"
[ "$status" -ne 0 ]
}
@test "README documents dejima-dep --global" {
grep -q 'dejima-dep add cargo:cargo-nextest --global' "$REPO_ROOT/README.md"
}
@test "sandbox-context.sh no longer mentions ALLOWED_DOMAINS" {
run grep -n 'ALLOWED_DOMAINS' "$REPO_ROOT/hooks/sandbox-context.sh"
[ "$status" -ne 0 ]
}
# The repo is public; the operator's own network is not. Both shapes below
# leaked into it once (a .localdomain Gitea host in bin/gitea-pr, a real LAN
# address as the Loki example in docker-compose.yml), so they are pinned
# here. Documentation addresses belong in RFC 5737's ranges (192.0.2.0/24,
# 198.51.100.0/24, 203.0.113.0/24) and documentation names under RFC 2606's
# reserved TLDs.
#
# test/ is deliberately out of scope: fixtures stub real resolver answers
# (project.bats feeds dig a 10.0.0.1) and those are inputs to a test, not
# addresses an operator is being told to use.
#
# This catches address- and hostname-shaped leaks only. A personal domain
# that looks like any other public one is not mechanically distinguishable
# and still rests on review.
SCANNED_PATHS=(README.md docs docker bin hooks install.sh systemd)
@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
}
}