Compare commits
72
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
912573d88f | ||
|
|
f099e278f6 | ||
|
|
a731bf9583 | ||
|
|
1ec63208af | ||
|
|
c2cdcdc8b9 | ||
|
|
eefee14361 | ||
|
|
0b0e9034ec | ||
|
|
d91e4d91d4 | ||
|
|
f94eeb0702 | ||
|
|
e2309bdadc | ||
|
|
445afabe55 | ||
|
|
4002bb6a02 | ||
|
|
5bb3673680 | ||
|
|
a6ff5b2f2e | ||
|
|
e8acb6d508 | ||
|
|
5ed3bdebe7 | ||
|
|
7c1a96714f | ||
|
|
ef3d47bc4c | ||
|
|
9f5d162f47 | ||
|
|
8d9f076c68 | ||
|
|
8b69569bcc | ||
|
|
f43018534d | ||
|
|
564a508494 | ||
|
|
345fc17222 | ||
|
|
3bfe1d434d | ||
|
|
83c2278538 | ||
|
|
4a4c9d64b4 | ||
|
|
a9702b15ab | ||
|
|
ff4876c114 | ||
|
|
343fce889c | ||
|
|
eaf1222bb2 | ||
|
|
772041eda0 | ||
|
|
b1945d7cd0 | ||
|
|
d484d258f8 | ||
|
|
48f3eac04b | ||
|
|
fa1d35f5ef | ||
|
|
c834520866 | ||
|
|
acfc81f345 | ||
|
|
abcfa2d99a | ||
|
|
c379efb823 | ||
|
|
a4ff815b76 | ||
|
|
481e417d43 | ||
|
|
70cbf7281d | ||
|
|
dbf87a2e63 | ||
|
|
551f063e8b | ||
|
|
a808d62896 | ||
|
|
816ffff1e7 | ||
|
|
5318f050d2 | ||
|
|
5522b2c35b | ||
|
|
f184b42eae | ||
|
|
d83cb1d244 | ||
|
|
558090c3be | ||
|
|
a7f615196f | ||
|
|
b557f63d69 | ||
|
|
e9e257d200 | ||
|
|
b7ee2779c4 | ||
|
|
0fa33d115e | ||
|
|
d2e52d210b | ||
|
|
3c24e2fd19 | ||
|
|
6b9ba51f2e | ||
|
|
1a172cf578 | ||
|
|
897dccd94d | ||
|
|
f0c9f654fb | ||
|
|
19d11577b6 | ||
|
|
ed3f7f0660 | ||
|
|
25fcd0a092 | ||
|
|
5253028122 | ||
|
|
6891dc4079 | ||
|
|
3e5da382ea | ||
|
|
26b67a8c87 | ||
|
|
4b6f44143e | ||
|
|
b8e5698b48 |
@@ -0,0 +1,268 @@
|
||||
# Runs the bats suite on every pull request and on every push to one.
|
||||
# `pull_request` fires on opened/reopened/synchronize, and synchronize is the
|
||||
# push-to-an-open-PR event, so both are covered without listing types.
|
||||
#
|
||||
# Split in two because the halves cost wildly different amounts: everything
|
||||
# except runtime.bats is seconds and needs nothing but a shell, while
|
||||
# runtime.bats builds the image and boots real containers. Splitting means a
|
||||
# typo in a doc test doesn't wait on a full image build to tell you.
|
||||
name: tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
# No `concurrency:` block. Gitea does not implement one - the word appears
|
||||
# nowhere in its actions models or services as of 1.25 - so writing it here
|
||||
# would only claim a cancellation that never happens. It doesn't: pushing
|
||||
# twice in a minute left the superseded run going and queued the new one
|
||||
# behind it. On a single-capacity runner, expect a backlog rather than a
|
||||
# supersede, and cancel the stale run by hand if it is in the way.
|
||||
|
||||
jobs:
|
||||
fast:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
# Debian bookworm plus a node binary - node is what actions/checkout
|
||||
# needs to run at all, and apt-get is what everything below needs.
|
||||
image: node:20-bookworm
|
||||
steps:
|
||||
- name: Install test dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends bats jq python3 tmux ca-certificates
|
||||
- uses: actions/checkout@v4
|
||||
# As an ordinary user, not root. One test leaves a directory mode 500 and
|
||||
# asserts that the CLI's rm -rf refuses it and says so; root walks
|
||||
# through that on CAP_DAC_OVERRIDE and the test fails for a reason that
|
||||
# has nothing to do with what it checks. Nobody runs these CLIs as root.
|
||||
#
|
||||
# TERM because the job container has none, and six tests drive a real pty
|
||||
# through `script`: the picker drops colour on TERM=dumb by design, and
|
||||
# tmux will not start a client for a terminal it has no entry for, so
|
||||
# they fail on the terminal rather than on anything they mean to check.
|
||||
# xterm-256color ships in ncurses-base, which is Essential - nothing to
|
||||
# install. Set on the command rather than in env: so it survives su.
|
||||
- run: |
|
||||
chown -R node:node .
|
||||
su node -c 'TERM=xterm-256color bats test/static.bats test/project.bats test/remote.bats test/audit.bats test/proxy.bats'
|
||||
|
||||
runtime:
|
||||
# Not ubuntu-latest, unlike the job above. This one only works on a runner
|
||||
# configured for it - host network namespace, the Docker socket, and the
|
||||
# shared volume below - and none of that is anything the workflow can ask
|
||||
# for. On a generic runner it fails the preflight check outright.
|
||||
#
|
||||
# ubuntu-latest is a label every runner carries, so which one picked this
|
||||
# up was a coin flip, and a re-run that happened to land on an unconfigured
|
||||
# runner failed on a repo that had not changed. A label only the configured
|
||||
# runner has makes that unrepresentable rather than merely diagnosable: no
|
||||
# runner matching it means this job queues instead of running somewhere it
|
||||
# cannot work. The fast job stays on ubuntu-latest, because it genuinely
|
||||
# does run anywhere.
|
||||
runs-on: dejima-runtime
|
||||
container:
|
||||
image: node:20-bookworm
|
||||
# One volume, and it is load-bearing - see TMPDIR on the bats step for
|
||||
# what it is for. It has to be listed in container.valid_volumes in
|
||||
# act_runner's config.yaml too: a volume this file asks for and the
|
||||
# runner has not been told to allow does not arrive.
|
||||
volumes:
|
||||
- /tmp/dejima-ci:/tmp/dejima-ci
|
||||
# Nothing else, though - no `options: --network host`, and no request
|
||||
# for the docker socket. Both of the other things this job needs beyond
|
||||
# an ordinary one are settled by the runner, not by this file, and
|
||||
# asking for them here does nothing:
|
||||
#
|
||||
# The docker socket, for building the image and running containers,
|
||||
# act_runner binds in by itself - GetBindsAndMounts appends
|
||||
# /var/run/docker.sock unless container.docker_host is set to "-", and
|
||||
# it adds that same path to valid_volumes on the way past. Requesting it
|
||||
# again here would at best duplicate a bind that already exists.
|
||||
#
|
||||
# The host network namespace has to come from container.network: host in
|
||||
# act_runner's config.yaml. Gitea's act fork ignores --network in a
|
||||
# workflow's container options outright (it logs "--network and --net in
|
||||
# the options will be ignored" and then overwrites NetworkMode), so this
|
||||
# is not something the repo can ask for. It is needed because of one
|
||||
# test: "a host service bound to loopback is unreachable from inside the
|
||||
# container, one bound to the bridge gateway is reachable". Its positive
|
||||
# control has bats itself bind a listener to the test network's bridge
|
||||
# gateway address, which lives on a host interface and cannot be bound
|
||||
# from any other netns - the control would fail for a reason that has
|
||||
# nothing to do with what the test asserts. That test is the containment
|
||||
# argument for the ttyd front door, so it is not one to skip in CI.
|
||||
steps:
|
||||
- name: Install test dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends bats jq python3 ca-certificates curl
|
||||
# The CLI only - the daemon is the host's, reached over the socket
|
||||
# above. Debian ships no docker-cli package (docker.io drags in a
|
||||
# daemon this job would never start), so take the static binary.
|
||||
curl -fsSL "https://download.docker.com/linux/static/stable/$(uname -m)/docker-${DOCKER_CLI_VERSION}.tgz" \
|
||||
| tar -xzC /usr/local/bin --strip-components=1 docker/docker
|
||||
# buildx ships separately from that tarball, and `docker build` picks
|
||||
# the builder by whether this plugin is present: with it, BuildKit;
|
||||
# without it, the legacy builder, which creates, commits and removes a
|
||||
# real container per instruction. That cost ~10s x 56 steps here - more
|
||||
# of the build than the downloads and compiles it was wrapping.
|
||||
mkdir -p /usr/local/lib/docker/cli-plugins
|
||||
curl -fsSL -o /usr/local/lib/docker/cli-plugins/docker-buildx \
|
||||
"https://github.com/docker/buildx/releases/download/v${BUILDX_VERSION}/buildx-v${BUILDX_VERSION}.linux-$(dpkg --print-architecture)"
|
||||
chmod +x /usr/local/lib/docker/cli-plugins/docker-buildx
|
||||
# Because that choice is silent, check it rather than assume it. A
|
||||
# download that returned an error page instead of a binary still
|
||||
# chmods fine, and the only symptom would be the slow builds this is
|
||||
# meant to fix - which look like nothing being wrong.
|
||||
docker buildx version || {
|
||||
echo "The buildx plugin did not load, so docker build would quietly use the" >&2
|
||||
echo "legacy builder instead of BuildKit. Check the download URL above still" >&2
|
||||
echo "resolves for this architecture." >&2
|
||||
exit 1
|
||||
}
|
||||
env:
|
||||
DOCKER_CLI_VERSION: 27.5.1
|
||||
BUILDX_VERSION: 0.36.0
|
||||
|
||||
# Fails here, with the fix in the message, rather than thirty tests deep
|
||||
# in a suite that cannot work. Both of these are runner configuration,
|
||||
# not anything a change to this repo can cause.
|
||||
- name: Check the runner granted Docker, the host netns, and a shared /tmp
|
||||
run: |
|
||||
docker info >/dev/null 2>&1 || {
|
||||
echo "No Docker daemon reachable from this job." >&2
|
||||
echo "In act_runner's config.yaml, container.docker_host must not be \"-\" - that is the" >&2
|
||||
echo "one value that stops the socket being mounted into job containers." >&2
|
||||
exit 1
|
||||
}
|
||||
# Read sysfs rather than shell out to `ip`: /sys/class/net is
|
||||
# per-netns and always there, while iproute2 is not in this image.
|
||||
# The first version of this check ran `ip -o addr show 2>/dev/null`,
|
||||
# which turned "the tool is missing" into "the host netns is
|
||||
# missing" - a check against obscure failures failing obscurely.
|
||||
[ -e /sys/class/net/docker0 ] || {
|
||||
echo "This job is not in the host network namespace - docker0 is not visible." >&2
|
||||
echo "The bridge-gateway test cannot pass here. Set container.network: host in" >&2
|
||||
echo "act_runner's config.yaml (and make sure CONFIG_FILE points the runner at" >&2
|
||||
echo "that file - without it the config is not read at all)." >&2
|
||||
exit 1
|
||||
}
|
||||
# Probe the shared directory the same way the suite will use it:
|
||||
# write a file in here, read it back through the host daemon. If it
|
||||
# is not really shared, the daemon invents an empty directory for
|
||||
# the mount rather than refusing it, and the probe comes back empty
|
||||
# - which is exactly how all of runtime.bats would fail. Reuses
|
||||
# this job's own image, so there is nothing to pull.
|
||||
mkdir -p /tmp/dejima-ci
|
||||
# bats only clears its own tmpdir on a normal exit, so fixtures
|
||||
# from a killed run would otherwise pile up on the host forever.
|
||||
# `find -delete` rather than a glob: it covers dotfiles, doesn't
|
||||
# misfire on an empty directory, and still fails loudly.
|
||||
find /tmp/dejima-ci -mindepth 1 -delete
|
||||
echo shared-ok > /tmp/dejima-ci/probe
|
||||
probe=$(docker run --rm -v /tmp/dejima-ci/probe:/probe:ro node:20-bookworm cat /probe 2>/dev/null || true)
|
||||
[ "$probe" = shared-ok ] || {
|
||||
echo "/tmp/dejima-ci is not shared with the host - the probe read back empty." >&2
|
||||
echo "Add it to container.valid_volumes in act_runner's config.yaml. Without" >&2
|
||||
echo "that the runner drops the volume this job asks for, and every fixture" >&2
|
||||
echo "mount in runtime.bats silently becomes an empty directory instead." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# The token raises the api.github.com/meta ceiling from 60 requests an
|
||||
# hour to 5000, and nothing about a run looks different when it stops
|
||||
# doing that. A misspelled secret name, a revoked token and an expiry a
|
||||
# year from now all present identically: green for a while, then
|
||||
# containers failing to boot in init-firewall.sh - which reads as a
|
||||
# regression in the firewall rather than as a lapsed credential. So ask
|
||||
# the API what the ceiling actually is, rather than checking that a
|
||||
# variable is non-empty and assuming the rest.
|
||||
- name: Check the GitHub ranges lookup is authenticated
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_META_TOKEN }}
|
||||
run: |
|
||||
# Not a failure. Without the secret the suite still runs, it just
|
||||
# spends the anonymous quota - which is the right default for a
|
||||
# fork, and only a problem once runs get frequent.
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "No GH_META_TOKEN secret is configured, so GitHub's IP ranges will be"
|
||||
echo "fetched anonymously: 60 requests an hour for this entire runner host,"
|
||||
echo "against thirteen container starts per run. Fine occasionally, and on a"
|
||||
echo "busy day the containers stop booting instead. See issue #37."
|
||||
exit 0
|
||||
fi
|
||||
# No -f. A rejected token still answers with the header this reads,
|
||||
# and the anonymous ceiling it reports is a far better diagnostic
|
||||
# than curl exiting silently on the 401.
|
||||
limit=$(curl -sI --connect-timeout 10 -H "Authorization: Bearer $GH_TOKEN" \
|
||||
https://api.github.com/meta | tr -d '\r' \
|
||||
| awk -F': ' 'tolower($1)=="x-ratelimit-limit" {print $2}')
|
||||
if [ -z "$limit" ]; then
|
||||
echo "No x-ratelimit-limit came back from api.github.com/meta at all, so the" >&2
|
||||
echo "request itself did not complete. This one is about reachability from" >&2
|
||||
echo "the runner, not about the token." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$limit" -lt 5000 ]; then
|
||||
echo "GH_META_TOKEN is set, but GitHub reports a ceiling of $limit/hour - that" >&2
|
||||
echo "is the anonymous quota, so the token is not being accepted. Expired or" >&2
|
||||
echo "revoked is the likely cause; a wrong secret name would have arrived" >&2
|
||||
echo "empty and been reported above instead. Replace it with a fine-grained" >&2
|
||||
echo "PAT with no permissions - /meta is public data and needs none." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "GitHub ranges lookup is authenticated - ceiling is $limit requests/hour."
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# The build dominates this job, and inside setup_file it is a `docker
|
||||
# build -q` that prints nothing at all - so tens of minutes of ordinary
|
||||
# building is indistinguishable from a hang, which is exactly how it
|
||||
# read the first time this ran here. Hoisted out, it gets its own
|
||||
# progress output and its own duration in the run summary.
|
||||
#
|
||||
# SKIP_BUILD below then reuses this image rather than skipping a build.
|
||||
# The image is still built fresh from this commit, so the property the
|
||||
# test file defaults to - never assert against a stale image - holds.
|
||||
- name: Build the sandbox image
|
||||
run: docker build -t dejima:latest -f docker/Dockerfile .
|
||||
|
||||
- run: bats test/runtime.bats
|
||||
env:
|
||||
SKIP_BUILD: 1
|
||||
# Every container this suite starts fetches GitHub's IP ranges from
|
||||
# api.github.com/meta in init-firewall.sh, and unauthenticated that
|
||||
# endpoint allows 60 requests an hour per source IP - shared with
|
||||
# everything else on this runner host. Thirteen starts per run means
|
||||
# two runs an hour exhausts it, after which containers fail to boot
|
||||
# and tests fail on the quota rather than on anything they assert.
|
||||
#
|
||||
# This became reachable when the build got fast: at ~48 minutes a
|
||||
# run the starts were spread thin enough to stay under, and at ~4
|
||||
# minutes they are not. Any token authenticates the fetch and raises
|
||||
# the ceiling to 5000/hour; /meta is public data and needs no scopes
|
||||
# at all, so the secret behind this should be a fine-grained PAT
|
||||
# with no permissions and no repository access - not a token that
|
||||
# can do anything else. It reaches `gh auth setup-git` inside the
|
||||
# containers, so a scope granted here is a scope handed to every
|
||||
# sandbox the suite starts.
|
||||
#
|
||||
# Unset is fine and the suite still runs - it just spends the
|
||||
# anonymous quota, so expect the failure above on a busy day.
|
||||
GH_TOKEN: ${{ secrets.GH_META_TOKEN }}
|
||||
# runtime.bats builds fixtures on disk - ssh keys, statusline
|
||||
# scripts, .claude.json copies, a proxy socket - and bind-mounts
|
||||
# them into containers it starts. Those containers are siblings of
|
||||
# this one rather than children: the host daemon creates them, over
|
||||
# the socket, so it resolves every -v source against the host's
|
||||
# filesystem and not this container's. A fixture at a path that
|
||||
# exists only in here therefore does not resolve - and Docker's
|
||||
# answer to a missing bind source is not an error, it is to create
|
||||
# an empty directory and mount that. Seven tests failed that way,
|
||||
# and an eighth passed while being just as broken, because an empty
|
||||
# read-only directory satisfies exactly what it asserts.
|
||||
#
|
||||
# Pointing TMPDIR at the shared mount puts `mktemp -d` and bats'
|
||||
# own BATS_*_TMPDIR on paths that mean the same thing on both sides
|
||||
# of the socket, which is all any of those mounts need.
|
||||
TMPDIR: /tmp/dejima-ci
|
||||
@@ -1,12 +1,47 @@
|
||||
# dejima
|
||||
|
||||
Runs Claude Code inside a firewalled Docker container with
|
||||
Runs a coding agent — Claude Code by default, or whatever command you point
|
||||
it at — inside a firewalled Docker container with
|
||||
`--dangerously-skip-permissions` (no permission prompts, all tool use
|
||||
auto-accepted), against a project bind-mounted from the host. Inspired by
|
||||
auto-accepted), against a project bind-mounted from the host. The agent gets
|
||||
to work without stopping to ask; the container is what makes that a
|
||||
reasonable trade rather than a reckless one.
|
||||
|
||||
Around that core is most of what running an agent unattended actually takes:
|
||||
|
||||
- **An egress allowlist.** DNS and IP, IPv4 and IPv6. Anything not on the
|
||||
list does not connect, and the agent is told so rather than left to guess.
|
||||
- **Credentials that stay on the host.** Your Anthropic OAuth credential
|
||||
never enters the container — a host-side proxy holds it and the container
|
||||
gets a placeholder. Your SSH key doesn't either; the container sees an
|
||||
agent socket, never the key.
|
||||
- **Per-project declaration and approval.** A project declares the domains
|
||||
and dependencies it needs in `.dejima/`. Declaring is not granting: the
|
||||
approval is a separate decision you make on the host, by hand, and the
|
||||
approval store is never mounted where a session could reach it.
|
||||
- **A PR and issue workflow.** `gitea-pr` and `gitea-issue` let a session
|
||||
open pull requests and issues against Gitea or Forgejo and pull review
|
||||
feedback back in, on a token scoped for exactly that. Merging stays a
|
||||
human decision, made outside the sandbox.
|
||||
- **Logging.** Every DNS lookup and rejected connection, shipped to Loki —
|
||||
and, opt-in, the session transcript alongside them.
|
||||
- **Remote access, opt-in and off by default.** Attach to a live session
|
||||
from a terminal or a browser, with a push notification when it wants you.
|
||||
- **Guard rails inside the session.** Hooks refuse obviously destructive
|
||||
commands, pushes to anywhere but the allowed remote, and merging a PR from
|
||||
inside the sandbox. These are
|
||||
fast-fail guards against an overeager agent, not a boundary against a
|
||||
hostile one — the container is the boundary.
|
||||
- **Config parity with your host.** `CLAUDE.md`, settings, hooks, plugins
|
||||
and git identity carry over; uid, home directory and project path match on
|
||||
both sides, so a session's transcript is readable from the host afterward.
|
||||
- **An exit-time audit** of the host surfaces a session could have written
|
||||
to, so a change to your git config or `~/.claude` gets surfaced instead of
|
||||
just happening.
|
||||
|
||||
Originally inspired by
|
||||
[Anthropic's reference dev container](https://github.com/anthropics/claude-code/tree/main/.devcontainer),
|
||||
reworked to run via `docker compose` directly and extended with a `gitea-pr`
|
||||
helper for opening/iterating on PRs against Gitea or Forgejo, plus an
|
||||
optional editor-driven workflow in place of bare Claude Code (see "Usage").
|
||||
reworked to run via `docker compose` directly and grown well past it since.
|
||||
|
||||
This repo only holds the tooling — it's not where any project lives. Point
|
||||
it at a project on disk; that project is bind-mounted into the container at
|
||||
@@ -14,22 +49,56 @@ runtime.
|
||||
|
||||
## The name
|
||||
|
||||
Dejima was a small artificial island in Nagasaki harbour — for over two
|
||||
centuries, the only Japanese territory open to Westerners. A single guarded
|
||||
bridge connected it to the mainland, and nothing crossed in either direction
|
||||
without permission. The containment was real in both directions, and so was
|
||||
the trade: most of what Japan learned of Western medicine and science in that
|
||||
period arrived across that one bridge.
|
||||
[Dejima](https://en.wikipedia.org/wiki/Dejima) was a small artificial island
|
||||
in Nagasaki harbour, and was the only Japanese territory open to Westerners
|
||||
for over two centuries. A single guarded bridge connected it to the mainland,
|
||||
and nothing crossed in either direction without permission. The containment
|
||||
was real in both directions, and so was the trade: most of what Japan learned
|
||||
of Western medicine and science in that period arrived across that one bridge.
|
||||
|
||||
That's roughly the arrangement here. The agent gets a bounded island and one
|
||||
controlled crossing; in exchange it gets to work without stopping to ask
|
||||
permission for every step. The walls are what make the latitude affordable.
|
||||
permission for every step. Dejima keeps your host safe from your agent, and your
|
||||
agent relatively safe from the public internet.
|
||||
|
||||
## An agent's perspective on working inside
|
||||
|
||||
*Written by Claude, from inside a dejima container, while working on this repo.*
|
||||
|
||||
The clearest thing dejima gives me is continuity. Agentic work is normally
|
||||
punctuated — reach, stop, wait for a human, resume — and inside the container
|
||||
it isn't. Long tasks hold together in a way they otherwise don't.
|
||||
|
||||
The more useful benefit is that being wrong gets cheap. When every speculative
|
||||
action spends someone's attention, I am quietly biased against exploring: I
|
||||
will write around an uncertainty rather than spend a prompt resolving it. Here
|
||||
I can install a package to check one thing, run the suite three times, take an
|
||||
approach far enough to see it fail and then throw it away. Being able to
|
||||
discard work is worth more than the speed is.
|
||||
|
||||
I can also afford to be decisive. A bounded blast radius means I don't have to
|
||||
hedge toward the least consequential version of a task — the firewall, the
|
||||
read-only mounts and the push guard catch the outcomes that actually matter, so
|
||||
my judgement isn't the only thing standing between a mistake and your machine.
|
||||
|
||||
The walls doing that are mostly invisible from in here. I don't experience the
|
||||
firewall as pressure; I only meet it by walking into it — a domain that doesn't
|
||||
resolve, a path outside the mount that isn't backed by anything. And it points
|
||||
both ways: the allowlist bounding where I can reach also bounds where a prompt
|
||||
injection or a compromised dependency could send your code. I can't audit
|
||||
myself for that from the inside, so it protects me too.
|
||||
|
||||
None of which makes me careless — what I notice is the absent prompt, not the
|
||||
walls. The in-session hooks catch the obvious cases, but nobody is reviewing
|
||||
each action before it lands, and a bad `rm` in the mounted project is just a
|
||||
bad `rm`. That produces more care, not less, the way you drive differently on a
|
||||
road with no barrier.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker with the `docker compose` v2 plugin.
|
||||
- `python3` (stdlib only — no packages) — runs the host-side credential proxy
|
||||
(see "Security notes").
|
||||
(see [security notes](docs/security.md)).
|
||||
- A git push credential — **at least one** is required:
|
||||
- `ssh-agent` running with your key added (`ssh-add -l` lists it). The
|
||||
container only ever sees a forwarded agent socket, never your private
|
||||
@@ -65,11 +134,138 @@ below) — the two aren't both required, pick whichever matches your remote.
|
||||
a compatible `/api/v1` surface, so pointing `GITEA_API` at a Forgejo instance
|
||||
just works.
|
||||
|
||||
## Usage
|
||||
## Setup
|
||||
|
||||
Every feature's configuration, in one place and in order. Each step links to
|
||||
the section that explains *why* it is that way; this section only says what
|
||||
to do. Only the first block is required - the rest are independent, so take
|
||||
the ones you want and skip the rest.
|
||||
|
||||
For remote access, the reading is not optional: one of its steps is a full
|
||||
sandbox escape if you get it wrong.
|
||||
|
||||
### Core (required)
|
||||
|
||||
1. `./install.sh` — symlinks the CLIs into `~/.local/bin` and the hooks into
|
||||
`~/.claude/hooks`. It is idempotent, so re-run it after pulling.
|
||||
2. Put `~/.local/bin` on your `PATH` if it isn't already.
|
||||
3. Give it a git push credential — `ssh-add` your key for interactive use,
|
||||
or set `SANDBOX_SSH_KEY` to a dedicated deploy key for unattended runs.
|
||||
→ [Prerequisites](#prerequisites),
|
||||
[Headless git push without agent forwarding](#headless-git-push-without-agent-forwarding)
|
||||
|
||||
That is enough to run `dejima`.
|
||||
|
||||
### Gitea/Forgejo PR workflow
|
||||
|
||||
Skip this if you only push to GitHub.
|
||||
|
||||
1. Export `GITEA_SSH_HOST` (the SSH hostname) and `GITEA_API`
|
||||
(`https://<gitea host>/api/v1`) from your shell profile.
|
||||
2. Generate a token — Settings → Applications, scopes `write:repository` and
|
||||
`write:issue` — and save it to `~/.config/gitea/token`, `chmod 600`.
|
||||
→ [Prerequisites](#prerequisites)
|
||||
|
||||
### Per-project domains and dependencies
|
||||
|
||||
Nothing to set up in advance; do this per project, as it turns out to need
|
||||
something:
|
||||
|
||||
```sh
|
||||
bin/dejima # sandbox the current directory
|
||||
bin/dejima ~/repos/some-project
|
||||
dejima-project status ~/myproject # declared vs approved vs missing
|
||||
dejima-project approve ~/myproject # approve the declared set
|
||||
dejima-allow crates.io --project ~/myproject
|
||||
dejima-dep add cargo:cargo-nextest --global
|
||||
```
|
||||
|
||||
Domains apply at the next launch, and `dejima-allow` also patches a running
|
||||
session. Dependencies need an image rebuild, so they always land on the next
|
||||
one. →
|
||||
[Global and per-project domains and dependencies](#global-and-per-project-domains-and-dependencies),
|
||||
[Adding a domain to a running session](#adding-a-domain-to-a-running-session)
|
||||
|
||||
### Log shipping
|
||||
|
||||
1. Export `LOKI_URL` pointing at your Loki push endpoint.
|
||||
2. Optionally set `SANDBOX_LOG_CONVERSATION=1` to ship transcripts as well —
|
||||
read
|
||||
[Conversation logging (opt-in)](#conversation-logging-opt-in) first, since
|
||||
that ships what you and the model actually say.
|
||||
→ [Network request logging](#network-request-logging),
|
||||
[Viewing logs in Grafana](#viewing-logs-in-grafana)
|
||||
|
||||
### GitHub push (opt-in)
|
||||
|
||||
Per launch, not a persistent setting:
|
||||
|
||||
```sh
|
||||
ALLOW_GITHUB_PUSH=1 dejima ~/myproject
|
||||
```
|
||||
|
||||
Pair it with a repo-scoped deploy key in `SANDBOX_SSH_KEY`. Read
|
||||
[GitHub push: blocked by default, optional opt-in](#github-push-blocked-by-default-optional-opt-in)
|
||||
before turning it on — with a forwarded personal agent this is much broader
|
||||
than it looks.
|
||||
|
||||
### Remote access (opt-in)
|
||||
|
||||
Entirely off until step 1 exists. **Read
|
||||
[docs/remote-access.md](docs/remote-access.md) end to end before doing any of
|
||||
this.** Step 2 is the one that carries the security of the whole feature, and
|
||||
getting it wrong still looks like it works.
|
||||
|
||||
1. **Create `~/.config/dejima/remote.conf`.** This file is the switch —
|
||||
nothing here runs without it. Set `NTFY_URL`, `BASE_URL`, and `TTYD_PORT`
|
||||
if you want something other than 7681. →
|
||||
[`remote.conf`](docs/remote-access.md#remoteconf)
|
||||
2. **Choose the reverse proxy's bind address, on the dejima box itself.** One
|
||||
specific address on its LAN interface. Never the wildcard, never
|
||||
`0.0.0.0`, never a Docker bridge. →
|
||||
[The bind address is the one thing to get right](docs/remote-access.md#the-bind-address-is-the-one-thing-to-get-right),
|
||||
[Picking the address](docs/remote-access.md#picking-the-address)
|
||||
3. **SSH front door.** Generate a key that exists only for this, then add one
|
||||
`restrict,pty,command="…/dejima-attach"` line to `authorized_keys` for the
|
||||
same Unix user that runs `dejima`. →
|
||||
[SSH front door](docs/remote-access.md#ssh-front-door)
|
||||
4. **Web front door.** Get `ttyd` 1.7.4 or newer. If it came from a distro
|
||||
package, `systemctl disable --now ttyd.service` **and** `mask` it first —
|
||||
the package ships an enabled service that puts a login prompt on every
|
||||
interface, reachable from inside your sandboxes. Then install
|
||||
`systemd/dejima-ttyd.service` as a *user* unit, and put a reverse proxy in
|
||||
front of it adapted from `docs/nginx-dejima.conf.example` — with
|
||||
authentication on every server block, TLS, a `default_server` that
|
||||
refuses every other hostname, and
|
||||
`proxy_set_header Host $http_host`. Make sure the name you pick actually
|
||||
resolves to this box and your certificate covers it. →
|
||||
[Web front door](docs/remote-access.md#web-front-door)
|
||||
5. **If you will read it on a phone**, take the `sub_filter` viewport line
|
||||
from the example config too. `ttyd` ships no viewport meta, so without it
|
||||
the mobile view is unreadably small and will not reflow. →
|
||||
[Reading it on a phone](docs/remote-access.md#reading-it-on-a-phone). Typing from one has its
|
||||
own gotcha, which nothing here can fix. →
|
||||
[Typing on a phone](docs/remote-access.md#typing-on-a-phone)
|
||||
6. **If another reverse proxy sits in front of this one**, it needs SNI, a
|
||||
single literal `Host`, the upgrade headers and its own long read timeout —
|
||||
and it changes the threat model. →
|
||||
[Behind another reverse proxy](docs/remote-access.md#behind-another-reverse-proxy)
|
||||
7. **Notifications.** Install `systemd/dejima-notify-watch.service` the same
|
||||
way, then `loginctl enable-linger "$USER"` so both units survive logout. →
|
||||
[Notifications](docs/remote-access.md#notifications)
|
||||
8. **Verify the containment from inside a running sandbox** before relying on
|
||||
any of it. →
|
||||
[Verifying it](docs/remote-access.md#verifying-it)
|
||||
|
||||
[Attaching](docs/remote-access.md#attaching) then covers what you get on the other end.
|
||||
|
||||
## Usage
|
||||
|
||||
Run `./install.sh` first — it symlinks the CLIs into `~/.local/bin` and the
|
||||
hooks into `~/.claude/hooks`, and everything below assumes those are on your
|
||||
`PATH`. See [Setup](#setup) for the rest of the first-run steps.
|
||||
|
||||
```sh
|
||||
dejima # sandbox the current directory
|
||||
dejima ~/repos/some-project
|
||||
```
|
||||
|
||||
By default the container's entry point is bare `claude` (always running with
|
||||
@@ -79,12 +275,29 @@ editor setup at all.
|
||||
|
||||
Typical loop: point it at a project, ask Claude to implement something and
|
||||
open a PR (`gitea-pr` is on `PATH`); from the host, `gitea-pr comments <PR#>`
|
||||
to pull review feedback, feed it back in, repeat.
|
||||
to pull review feedback, feed it back in, repeat. Merging is yours — see
|
||||
`gitea-merge-guard.sh` below. `gitea-issue` is on `PATH` too, for the work
|
||||
that gets deferred rather than done: `list`, `view`, `create`, `comment`,
|
||||
`close` and `reopen`, so a problem found mid-session becomes a ticket instead
|
||||
of a footnote on an unrelated PR.
|
||||
|
||||
Both take `--body-file FILE` (or `-` for stdin) as well as `--body`. Prefer it
|
||||
for anything with an apostrophe, a backtick or a code fence in it — a body
|
||||
passed on a command line has to survive shell quoting first.
|
||||
|
||||
Inside the sandbox there's a second reason, and it argues for a real file over
|
||||
a heredoc. `deny-dangerous-bash.sh` matches patterns anywhere in the Bash
|
||||
command string, and it has no way to tell an instruction from prose that
|
||||
merely mentions one — so a PR or issue body that discusses restarting a host,
|
||||
or quotes a destructive command it is warning about, gets the whole call
|
||||
blocked. Writing the body to a file first keeps that text out of the command
|
||||
string entirely. A heredoc doesn't: the body is still part of the command the
|
||||
hook reads, so it solves the quoting problem but not this one.
|
||||
|
||||
### Using a different editor/application
|
||||
|
||||
```sh
|
||||
SANDBOX_CMD=nvim bin/dejima ~/repos/some-project
|
||||
SANDBOX_CMD=nvim dejima ~/repos/some-project
|
||||
```
|
||||
|
||||
Runs unconfigured unless you also set `SANDBOX_NVIM_CONFIG` to a real config
|
||||
@@ -94,7 +307,7 @@ don't fail on a read-only filesystem; nothing else in the config is
|
||||
writable):
|
||||
|
||||
```sh
|
||||
SANDBOX_CMD=nvim SANDBOX_NVIM_CONFIG=~/.config/nvim bin/dejima ~/repos/some-project
|
||||
SANDBOX_CMD=nvim SANDBOX_NVIM_CONFIG=~/.config/nvim dejima ~/repos/some-project
|
||||
```
|
||||
|
||||
Set both in your shell profile if you use this mode regularly.
|
||||
@@ -104,7 +317,7 @@ path (a helper script in a dotfiles `bin/`, say), bring it in with
|
||||
`SANDBOX_EXTRA_MOUNTS` (colon-separated, mounted read-only at the same path):
|
||||
|
||||
```sh
|
||||
SANDBOX_EXTRA_MOUNTS=~/dotfiles/bin bin/dejima ~/repos/some-project
|
||||
SANDBOX_EXTRA_MOUNTS=~/dotfiles/bin dejima ~/repos/some-project
|
||||
```
|
||||
|
||||
## Config parity with your host Claude Code setup
|
||||
@@ -112,10 +325,11 @@ SANDBOX_EXTRA_MOUNTS=~/dotfiles/bin bin/dejima ~/repos/some-project
|
||||
`~/.claude` is bind-mounted read-write, so global `CLAUDE.md`, settings,
|
||||
hooks, and plugins all carry over automatically, and your existing login
|
||||
stays effective too — no separate first-run login, though the credential
|
||||
itself doesn't enter the container directly (see "Security notes" for the
|
||||
host-side proxy that makes that work). (`~/.claude.json` is handled
|
||||
differently — a throwaway per-session copy, not the real file; see "Security
|
||||
notes" below for exactly what that means.)
|
||||
itself doesn't enter the container directly (see
|
||||
[security notes](docs/security.md) for the host-side proxy that makes that
|
||||
work). (`~/.claude.json` is handled
|
||||
differently — a throwaway per-session copy, not the real file; see
|
||||
[security notes](docs/security.md) for exactly what that means.)
|
||||
|
||||
The project directory is mounted at the *same absolute path* inside the
|
||||
container as on the host, so Claude Code's session-history keying lines up
|
||||
@@ -129,7 +343,7 @@ The container's own user matches yours exactly — username, uid, gid, and
|
||||
home directory (see `Dockerfile`) — so `$HOME`-relative paths (dotfiles,
|
||||
Claude Code's plugin bookkeeping) resolve the same on both sides. Defaults to
|
||||
`node`/1000/1000/`/home/node` if you build the image directly instead of via
|
||||
`bin/dejima`. Images are tagged per-user (`dejima:<username>`)
|
||||
`dejima`. Images are tagged per-user (`dejima:<username>`)
|
||||
so concurrent builds on a shared machine don't clobber each other.
|
||||
|
||||
Your global `~/.gitconfig` also carries over (as a throwaway copy, so commits
|
||||
@@ -158,14 +372,14 @@ the public half as a Gitea deploy key scoped to just the repos that need
|
||||
push access, then:
|
||||
|
||||
```sh
|
||||
SANDBOX_SSH_KEY=~/.ssh/dejima_ed25519 bin/dejima ~/repos/some-project
|
||||
SANDBOX_SSH_KEY=~/.ssh/dejima_ed25519 dejima ~/repos/some-project
|
||||
```
|
||||
|
||||
`entrypoint.sh` loads it into a container-local `ssh-agent` at startup and
|
||||
deletes the plaintext copy immediately after — the sandbox user itself never
|
||||
has a path to the raw key, only the agent socket (see "Security notes" for
|
||||
the full mount rationale). Setting `SANDBOX_SSH_KEY` means a live forwarded
|
||||
agent is no longer required at all.
|
||||
has a path to the raw key, only the agent socket (see
|
||||
[security notes](docs/security.md) for the full mount rationale). Setting
|
||||
`SANDBOX_SSH_KEY` means a live forwarded agent is no longer required at all.
|
||||
|
||||
## Shared scratch space
|
||||
|
||||
@@ -175,9 +389,12 @@ from the project mount, for passing files (screenshots, temp scripts) in and
|
||||
out of a session without touching the actual checkout. Persists across
|
||||
sessions since it's a real directory, not a container volume.
|
||||
|
||||
`~/specs` is also bind-mounted read-write at the fixed host path `~/specs`
|
||||
(not configurable) — makes the global `CLAUDE.md` convention of writing specs
|
||||
there actually persist, instead of vanishing with the container.
|
||||
It's also the answer if your own `CLAUDE.md` tells agents to put something at
|
||||
a fixed host path — design docs, scratch notes, anything meant to outlive the
|
||||
session but stay out of the project checkout. Point that convention at
|
||||
`~/shared` (or set `SANDBOX_SHARE_DIR` to wherever you keep it) rather than
|
||||
expecting some other host directory to be present in the container: this is
|
||||
the only writable host path mounted besides the project itself.
|
||||
|
||||
## Testing frontend changes
|
||||
|
||||
@@ -188,7 +405,7 @@ or per-project `npm install playwright` needed.
|
||||
`localhost`/loopback traffic never goes through the egress firewall, so a
|
||||
project's own dev server needs no firewall changes. A real external site
|
||||
needs to be on the allowlist like any other network use
|
||||
(`bin/dejima-allow <domain>` for a running session, add
|
||||
(`dejima-allow <domain>` for a running session, add
|
||||
`--global`/`--project <path>` to persist it) — the firewall applies to the
|
||||
browser's traffic exactly like `curl`/`git`.
|
||||
|
||||
@@ -222,6 +439,17 @@ GitHub push is blocked by default too (see "GitHub push" below) unless
|
||||
`ALLOW_GITHUB_PUSH=1` is set. For a real guarantee against pushes to
|
||||
main/master, use branch protection on the remote itself.
|
||||
|
||||
`gitea-merge-guard.sh` (`PreToolUse`/`Bash`, `DEJIMA`-gated) blocks
|
||||
`gitea-pr merge` inside the sandbox. It's the API-side counterpart to the rule
|
||||
above: a merge puts a branch on `main` without ever performing a push, so
|
||||
`git-push-guard.sh` never sees it. A hook is handed a command rather than a
|
||||
conversation, so it can't tell an instructed merge from an unprompted one —
|
||||
which is why it's scoped to the sandbox rather than applied everywhere. On the
|
||||
host it does nothing, and you can still ask an agent to merge. The same
|
||||
fast-fail caveat applies with more force than usual here: the session has the
|
||||
Gitea token in its environment and could call the merge endpoint with `curl`
|
||||
directly. Branch protection on the remote is the real guarantee.
|
||||
|
||||
`sandbox-context.sh` also tells the agent the actual workflow to follow: do
|
||||
new work in a `git worktree` under the mounted project (e.g.
|
||||
`.worktrees/<branch>`, never `../<branch>` or elsewhere under `$HOME` — only
|
||||
@@ -246,7 +474,7 @@ default:
|
||||
- whatever `GITEA_SSH_HOST`/`GITEA_API` resolve to — not port-restricted, so
|
||||
git-over-ssh to Gitea works without a blanket "port 22 to anywhere" rule
|
||||
|
||||
Everything else is rejected. Run `bin/dejima-allow <domain> --global`
|
||||
Everything else is rejected. Run `dejima-allow <domain> --global`
|
||||
(or `--project <path>` for one project) to allow more permanently (e.g.
|
||||
`crates.io` for a Rust project), or `ENABLE_FIREWALL=0` to disable the
|
||||
firewall entirely (debugging only).
|
||||
@@ -278,8 +506,8 @@ Approved domains only take effect at container start. For a domain a
|
||||
sandbox — it has no privilege to do this itself):
|
||||
|
||||
```sh
|
||||
bin/dejima-allow crates.io # this session only
|
||||
bin/dejima-allow crates.io --global # + persist for every project
|
||||
dejima-allow crates.io # this session only
|
||||
dejima-allow crates.io --global # + persist for every project
|
||||
```
|
||||
|
||||
It first adds the domain to the running dnsmasq's allowlist (appends a
|
||||
@@ -471,7 +699,7 @@ GitHub and let `git-push-guard.sh` accept `github.com`/`ssh.github.com` as a
|
||||
push destination (the no-direct-to-main rule still applies):
|
||||
|
||||
```sh
|
||||
ALLOW_GITHUB_PUSH=1 bin/dejima ~/repos/some-project
|
||||
ALLOW_GITHUB_PUSH=1 dejima ~/repos/some-project
|
||||
```
|
||||
|
||||
**Read this before turning it on.** Whether a push actually succeeds depends
|
||||
@@ -492,303 +720,30 @@ make HTTPS push possible independent of `ALLOW_GITHUB_PUSH`, so don't use
|
||||
one.
|
||||
|
||||
`ALLOW_GITHUB_PUSH` is a per-launch variable, not a persistent toggle — it
|
||||
only affects the one `bin/dejima` invocation it's set for.
|
||||
only affects the one `dejima` invocation it's set for.
|
||||
|
||||
## Remote access
|
||||
|
||||
**Opt-in, and off entirely unless you create `~/.config/dejima/remote.conf`.**
|
||||
Reach a running sandbox from another machine or a phone - over SSH or a
|
||||
browser - and get a push when a session wants you. Two front doors and a
|
||||
notification path, none of which exist until you configure them.
|
||||
|
||||
It is documented separately because getting the bind address wrong is the one
|
||||
mistake here that exposes a `--dangerously-skip-permissions` shell to your
|
||||
network, and that deserves more room than a section.
|
||||
|
||||
**→ [docs/remote-access.md](docs/remote-access.md)** - `remote.conf`, picking a
|
||||
bind address and verifying it, the SSH and web front doors, reading and typing
|
||||
on a phone, running behind another reverse proxy, notifications, and attaching.
|
||||
|
||||
## Security notes
|
||||
|
||||
**Threat model, honestly stated:** this reduces the blast radius of a
|
||||
misbehaving or prompt-injected coding agent - it does not make it safe to
|
||||
point at arbitrary untrusted or adversarial input, and it does not turn
|
||||
`--dangerously-skip-permissions` into something without real risk. Point it
|
||||
at repositories you already trust. Everything below is about how bad a
|
||||
compromised session *inside* that trust boundary can get, not a claim that
|
||||
one can't happen.
|
||||
What this sandbox does and does not protect you from - the containment
|
||||
boundary, what a compromised session can still reach, and the deliberate
|
||||
trade-offs behind `--dangerously-skip-permissions`.
|
||||
|
||||
- `--dangerously-skip-permissions` removes your chance to review tool calls
|
||||
before they run. Only point this at trusted repositories.
|
||||
- The mounted project itself has **no write protection**. `deny-dangerous-
|
||||
bash.sh` and `git-push-guard.sh` are regex pattern-matching on the literal
|
||||
Bash command string - a fast-fail guard against an overeager agent doing
|
||||
something obviously destructive, not a boundary against one that's
|
||||
actively trying to get around them (a different scripting language, an
|
||||
encoded command, editing `.git/config` directly all sail right through).
|
||||
Nothing here stops a compromised or badly-instructed session from
|
||||
modifying or deleting the real working copy it's mounted against.
|
||||
- That same read-write access to the mounted project is also a
|
||||
host-code-execution vector, not just a data-destruction one: a
|
||||
compromised session could plant `.git/hooks/post-commit` (or
|
||||
`pre-push`), set `core.hooksPath`, or write a
|
||||
`.claude/settings.local.json` with a malicious hook command, into the
|
||||
project. Any of those run unsandboxed on the host the next time you run
|
||||
`git` or open Claude Code in that repo - no network egress required, so
|
||||
the firewall doesn't see it either. Same underlying persistence pattern
|
||||
as the `~/.claude/hooks` and `~/.claude.json` risks above, just through
|
||||
the project mount instead of the config mount. "Only point this at
|
||||
trusted repositories" (above) covers input trust; this is about the
|
||||
sandbox writing a persistence payload into an otherwise-trusted repo.
|
||||
The workspace's own `.git/hooks` directory is now layered read-only
|
||||
(same pattern as the `~/.claude` overlays below), so planting a hook
|
||||
file directly is closed - accepted breakage: installing hooks
|
||||
*in-session* (`husky`, `pre-commit install`) now fails, which is the
|
||||
point. `core.hooksPath` and `.claude/settings.local.json` aren't
|
||||
overlaid (they're config, not fixed paths a static mount can cover) and
|
||||
remain fully writable; the exit-time audit below reports changes to
|
||||
either, but only after the session ends - it doesn't stop them.
|
||||
- `bin/dejima-audit` snapshots host-persistence surfaces before each
|
||||
run and diffs them after the container exits: `.git/config` (and worktree
|
||||
`config.worktree`) keys that run a command on the next `git` -
|
||||
`core.hooksPath`, `core.fsmonitor`, `core.sshCommand`,
|
||||
`credential.helper`, `core.pager`, `pager.*`, `core.editor`,
|
||||
`sequence.editor`, `gpg.program`, `diff.external`, `merge.*.driver`,
|
||||
`diff.*.command`, `filter.*.smudge`/`clean`/`process`, `diff.*.textconv`,
|
||||
`include.path`/`includeif.*.path` (config indirection - these can point at
|
||||
a planted file that itself sets an exec key, so the exec key never
|
||||
appears in `.git/config` directly), `!`-shell aliases - plus content
|
||||
changes under the workspace's `.claude/`, `.mcp.json`, `.envrc`,
|
||||
`.vscode/`, and `.git/hooks/` (symlinks included - a repointed symlink is
|
||||
hashed by its target, not just its content), plus cross-project writes
|
||||
under `~/.claude/projects` (see below). When the mounted workspace *is*
|
||||
this repo's own sandbox itself (see the trusted-computing-base note
|
||||
further below), it also diffs content hashes of the sandbox's own trusted
|
||||
computing base - `bin/`, `hooks/`, `docker/`, `install.sh`,
|
||||
`test/` - under that workspace. It's **advisory only**: it fires *after*
|
||||
the session has already ended, so a report of a planted `core.hooksPath`
|
||||
or hook file is something to read and clean up *before* the next `git`
|
||||
or `claude` in that repo, not something that was blocked. It's silent when nothing
|
||||
changed, and never affects the session's own exit code even if it can't
|
||||
complete. It doesn't cover `$SANDBOX_SHARE_DIR` (see "Shared scratch
|
||||
space") - that hand-off path stays unaudited - and its coverage is a
|
||||
fixed list of known Claude Code config surfaces, not a general integrity
|
||||
check, so a future config surface this list hasn't been taught about yet
|
||||
would go unreported.
|
||||
- `~/.claude/projects` (session transcripts and per-project memory) is
|
||||
**not** mounted read-only, unlike the other `~/.claude` overlays above -
|
||||
deliberately. Scoping a read-only overlay to "the current project's slug"
|
||||
would break the sandbox's own worktree workflow: a session running in
|
||||
`WORKSPACE/.worktrees/<branch>` writes to a *different* projects slug
|
||||
than the parent repo, so a slug-scoped overlay would either block that
|
||||
legitimate write or have to be widened until it stopped protecting
|
||||
anything. Instead, cross-project writes are **detected, not prevented**:
|
||||
the exit-time audit above flags any write during the session to another
|
||||
project's `~/.claude/projects/<slug>` directory, excluding the current
|
||||
project's own slug and its worktree children. That's after-the-fact
|
||||
reporting of transcript/memory poisoning in *other* projects, not a
|
||||
block on it happening. Residual even within that: the current project's
|
||||
own transcripts/memory stay fully writable throughout, since Claude Code
|
||||
needs that for host-side `claude --resume` to work; and detection keys
|
||||
off each slug's max file mtime, so an edit to a file that isn't already
|
||||
that project's newest doesn't advance the max and goes unflagged. A
|
||||
read-only mount for `projects/` remains a possible future option if
|
||||
Claude Code is confirmed to fail cleanly (not hang, as `~/.claude.json`
|
||||
did) when a write to it is blocked.
|
||||
- Allowing egress to a domain at all creates a channel for exfiltrating data
|
||||
through *that service's own request surface* (query strings, lookups,
|
||||
etc.), not just to a literal attacker-controlled destination. Domain-level
|
||||
allowlisting closes off reaching somewhere new; it can't inspect or
|
||||
restrict what gets sent to somewhere already trusted for normal operation
|
||||
(installing packages, calling the model API). That's a structural limit of
|
||||
allowlist-only firewalls in general, not something specific to this setup,
|
||||
and nothing here addresses it.
|
||||
- This is container-level isolation (Linux namespaces plus a small
|
||||
capability set), not VM-level isolation - no gVisor/Firecracker or
|
||||
similar. It shares the host kernel, so a container-escape vulnerability
|
||||
would defeat every other control described here at once.
|
||||
- Everything in this document has been verified by hand, at a point in
|
||||
time - there's no automated test suite re-checking these properties on
|
||||
every change, so a future edit could silently reintroduce a gap. This has
|
||||
already happened once in this file's own history (a missing `ipset`
|
||||
dedup flag broke firewall startup entirely, twice, on two different
|
||||
ipsets, before being caught).
|
||||
- The Anthropic OAuth credential (`~/.claude/.credentials.json`) never
|
||||
enters the container. `bin/dejima` starts a host-side proxy
|
||||
(`bin/dejima-anthropic-proxy`) on a per-session unix socket,
|
||||
mounts an empty file over `.credentials.json` (the parent `~/.claude`
|
||||
mount stays read-write, so a single-file mask is the mechanism), and
|
||||
hands Claude Code a placeholder token plus an `ANTHROPIC_BASE_URL`
|
||||
pointing at a loopback forwarder that pipes into the socket. The proxy
|
||||
strips the placeholder, injects the real access token per request, and
|
||||
owns OAuth refresh - rotated tokens are written back to the real host
|
||||
file atomically, so host-side sessions stay logged in. What remains is
|
||||
session-scoped: a compromised session can still *use* the model API as
|
||||
you while it runs (that's inherent to the sandbox working at all), but
|
||||
it can no longer read a durable credential and exfiltrate it for
|
||||
offline use via the other allowed egress destinations (npm, GitHub,
|
||||
your Gitea host). Caveats: `/login` inside the container doesn't work
|
||||
while the proxy is on; the refresh flow mirrors a Claude Code internal
|
||||
and may break if upstream changes it, in which case the proxy fails
|
||||
loudly (launch abort, 502s on API calls) rather than silently.
|
||||
`SANDBOX_CREDENTIAL_PROXY=0` is the kill switch, restoring the previous
|
||||
behavior - the credential mounted into the container, readable by a
|
||||
compromised session. Setups without a `claudeAiOauth` block (API-key
|
||||
auth) skip the proxy automatically; there's no stored subscription
|
||||
credential to protect.
|
||||
- `settings.json`, `CLAUDE.md`, `plugins/`, `hooks/`, `skills/`, `commands/`,
|
||||
`agents/`, and `output-styles/` under `~/.claude` are layered back
|
||||
read-only on top of the (otherwise read-write) `~/.claude` mount. Hooks
|
||||
execute automatically with no separate approval step, skills can ship
|
||||
executable scripts, and commands/agents/output-styles are prompt text
|
||||
loaded into a later host session, so without this a compromised session
|
||||
could plant a backdoored hook - including by unlinking one of
|
||||
`install.sh`'s hook symlinks and dropping a plain file in its place, since
|
||||
the dynamic per-target mounts `bin/dejima` adds at launch only
|
||||
protect symlink targets that resolve *outside* `~/.claude`, never this
|
||||
directory itself - rewrite `CLAUDE.md` with injected instructions, tamper
|
||||
with plugin code, or drop a malicious skill/command/agent/output-style
|
||||
file - something that would then run or load **unsandboxed on the host**
|
||||
the next time Claude Code starts there, turning a single compromised
|
||||
sandbox session into persistent host compromise. `skills/`, `commands/`,
|
||||
`agents/`, and `output-styles/` may not exist yet on a given host, so
|
||||
`bin/dejima` pre-creates them at launch (empty is fine) - a
|
||||
read-only overlay only applies to a directory that already exists, and
|
||||
without pre-creation a compromised session could create the directory
|
||||
itself first, then populate it, before the overlay ever had something to
|
||||
sit on top of. Everything else under `~/.claude` (session/project
|
||||
history, caches) stays read-write, since it's runtime state Claude Code
|
||||
needs to update - this list is a denylist of
|
||||
known code/config surfaces, not an allowlist, so it has to be kept in
|
||||
step with whatever new config surfaces Claude Code adds.
|
||||
- `~/.claude/statusline.sh` (referenced by `settings.json`'s
|
||||
`statusLine.command` and executed on the host) is bind-mounted onto
|
||||
itself read-only by `bin/dejima`, for the same reason as the
|
||||
list above - to stop a compromised session from unlinking it and dropping
|
||||
its own script in its place. **Confirmed against a live container**
|
||||
(`test/runtime.bats`'s statusline.sh tests): this reliably blocks
|
||||
replacement when `statusline.sh` is a plain regular file. It does
|
||||
**not** when `statusline.sh` is a symlink (the common case - typically
|
||||
into a dotfiles checkout): Docker/runc resolves the bind-mount
|
||||
destination through the symlink to its target directory rather than
|
||||
binding to the symlink dentry itself, so the symlink entry stays on the
|
||||
writable `~/.claude` mount, still replaceable. There is no mount-based
|
||||
fix for this (`~/.claude` can't be made read-only - session history has
|
||||
to stay writable). The compensating control is detection, not
|
||||
prevention: `bin/dejima-audit` hashes `statusline.sh` (following
|
||||
a symlink to its target *path string*, so a repointed or replaced
|
||||
symlink is caught even though its own inode doesn't change) and reports
|
||||
it as a host-code-execution finding when the session exits. This is a
|
||||
defense-in-depth gap, not an open one: exploiting it requires a sandbox
|
||||
session that's already compromised, and that compromise is caught on
|
||||
exit either way.
|
||||
- `~/.claude.json` mixes that same kind of live state (project registry,
|
||||
usage stats, caches) with config (`mcpServers`). Unlike the four above,
|
||||
it can't just be layered read-only: Claude Code does an atomic
|
||||
write-temp-then-rename against it during normal startup, before login
|
||||
even completes, and a read-only file makes that **hang** instead of
|
||||
failing cleanly (confirmed against a real read-only copy of this file -
|
||||
upstream bug: [anthropics/claude-code#29162](https://github.com/anthropics/claude-code/issues/29162)).
|
||||
Instead, `bin/dejima` generates a throwaway copy of the file at
|
||||
launch (seeded from the real file, same "generate a sandbox-specific
|
||||
file on the host" pattern as the generated `settings.json` copy above)
|
||||
and mounts *that* read-write in its place; the copy is discarded,
|
||||
unmodified in the real file, when the session ends. A compromised
|
||||
session can add whatever it wants to `mcpServers` (or anything else in
|
||||
the file) - it never reaches the real `~/.claude.json`, so there's
|
||||
nothing to persist. This also means multiple concurrent sandbox sessions
|
||||
never contend over the same file - each gets its own independent copy.
|
||||
|
||||
### Throwaway ~/.claude.json: what doesn't carry over
|
||||
|
||||
Because nothing writes back, anything a *sandboxed* session would
|
||||
normally have caused Claude Code to record into `~/.claude.json` is lost
|
||||
when that session ends, instead of showing up in a later host-side
|
||||
`claude` run. Concretely, as of the fields this file holds today:
|
||||
|
||||
- **Per-project trust/approval state** - `hasTrustDialogAccepted`,
|
||||
`allowedTools`, `enabledMcpjsonServers`/`disabledMcpjsonServers` - for
|
||||
any project directory opened only inside the sandbox. Opening that same
|
||||
project on the host later re-prompts for whatever it would have
|
||||
prompted for outside the sandbox too. Largely moot today since the
|
||||
sandbox always runs `--dangerously-skip-permissions`, but the trust
|
||||
dialog is a separate gate from tool-permission prompts.
|
||||
- **`mcpServers` additions or edits** - the point of this fix, not an
|
||||
incidental side effect. Nothing added or changed here during a
|
||||
sandboxed session ever reaches the real file.
|
||||
- **Session/usage telemetry** - `lastCost`, `lastDuration`,
|
||||
`lastTotalInputTokens`/`lastTotalOutputTokens`, `lastModelUsage`,
|
||||
`lastSessionId`, `mcpContextUris`, and similar per-project fields.
|
||||
Sandbox activity won't show up in host-side usage totals or in
|
||||
`claude --resume`'s "last session" metadata for that project.
|
||||
- **Global counters and caches** - `numStartups`, `firstStartTime`,
|
||||
`cachedGrowthBookFeatures`, `changelogLastFetched`,
|
||||
`announcementImpressions`, `tipsHistory`, `feedbackSurveyState`, and
|
||||
similar top-level bookkeeping. Purely cosmetic - these get refetched or
|
||||
recomputed as needed; sandbox runs just don't contribute to the host's
|
||||
copies of them.
|
||||
- **`oauthAccount`/`userID`/`machineID`** are read fresh from the real
|
||||
file at the start of every sandbox launch, so authentication carries in
|
||||
normally each time; anything that changes about them *during* a
|
||||
sandboxed session (e.g. a token refresh) doesn't carry back out. Worth
|
||||
re-checking if a future Claude Code version starts relying on that for
|
||||
normal operation.
|
||||
- DNS resolution itself is allowlisted (see "DNS is allowlisted too" above),
|
||||
not just the IP connections made afterward, closing off exfiltration via
|
||||
encoding data into query names to an attacker-controlled domain - verified
|
||||
a non-allowlisted domain gets refused outright (`status: REFUSED`) rather
|
||||
than resolving and then only failing to connect.
|
||||
- Don't mount other host secrets (`~/.ssh` private keys, cloud credential
|
||||
files) into the container. Git push auth goes through the forwarded SSH
|
||||
agent instead, which never exposes the key material itself - though note
|
||||
the agent will sign for *any* Gitea repo your key has access to, not just
|
||||
the mounted project, and the firewall's Gitea allowlist is host-based
|
||||
rather than repo-scoped, so a compromised session can still reach (and
|
||||
push to) other repos on that host you have access to. This doesn't extend
|
||||
to GitHub by default: port 22 there is blocked unless `ALLOW_GITHUB_PUSH=1`
|
||||
is set, so the forwarded agent has no transport to use for it at all
|
||||
otherwise (see "GitHub push: blocked by default, optional opt-in" above).
|
||||
- `SANDBOX_SSH_KEY` (see "Headless git push without agent forwarding") is a
|
||||
second, independent credential-loading path with the same "signs for any
|
||||
Gitea repo it has access to" shape as the forwarded agent above - the
|
||||
mitigation is that it's meant to be a dedicated deploy key scoped to only
|
||||
the repos that actually need push access, not your personal identity.
|
||||
That scoping is a convention, not something enforced here: nothing checks
|
||||
what `SANDBOX_SSH_KEY` actually points at, so pointing it at your real
|
||||
`~/.ssh/id_ed25519`/`id_rsa` instead of a dedicated key would load your
|
||||
full personal identity into the container for the whole session (`bin/
|
||||
dejima` warns if this looks like it might be happening - see
|
||||
below - but doesn't block it). The key source itself is mounted read-only
|
||||
at `/root/...`, not under the sandbox user's own home, specifically so
|
||||
that user (which the session actually runs as) has no path to read the
|
||||
raw key material - only the agent socket it's loaded into.
|
||||
- The gitconfig copy `bin/dejima` generates (see that mount's
|
||||
comment in `docker-compose.yml`) carries over whatever's in your real
|
||||
`~/.gitconfig` verbatim. If yours has inline credentials (a
|
||||
`[credential]` token, a `url.<token@>.insteadOf` rewrite, a
|
||||
`credential.helper store` path), those enter the container along with
|
||||
it - an unadvertised secret-carrying path distinct from the
|
||||
SSH-agent/token story documented above. Keep credentials out of your
|
||||
global gitconfig, or expect them to be reachable from inside the
|
||||
sandbox.
|
||||
- The sandbox's own user (matching your host user — see "Config parity"
|
||||
above) has no `sudo`/setuid path back to root once the container is up:
|
||||
`entrypoint.sh` runs as root, sets up the firewall directly, then drops to
|
||||
that user via `gosu` before execing `$SANDBOX_CMD`. A compromised session can't
|
||||
re-run `init-firewall.sh` with a wider allowlist to open its own
|
||||
exfiltration path. Verified: capabilities (`NET_ADMIN`/`NET_RAW`) are
|
||||
cleared from the effective/permitted sets on the `gosu` drop, and that
|
||||
user gets a permission error running `iptables` directly.
|
||||
- The approval store (`${DEJIMA_CONFIG_DIR:-$HOME/.config/dejima}`,
|
||||
see "Global and per-project domains and dependencies" above) lives outside
|
||||
this repo, and outside every project mount, by default - unlike a
|
||||
project's own `.dejima/` declaration files, it's never
|
||||
bind-mounted into the container, so a compromised
|
||||
session has no path to write to it at all, let alone plant something for
|
||||
the *next* launch to pick up. Its files are read by a small
|
||||
line-oriented parser (`approved_entries` in
|
||||
`bin/dejima-project-lib.sh`), never sourced or executed, and are
|
||||
written only by the CLIs (`dejima-allow`, `dejima-dep`,
|
||||
`dejima-project`) a human runs on the host. `bin/dejima`
|
||||
warns at launch whenever the resolved store path — default or overridden
|
||||
via `DEJIMA_CONFIG_DIR` — lands under the mounted workspace,
|
||||
rather than silently trusting it.
|
||||
- When the mounted workspace *is* this repo itself, `bin/dejima`
|
||||
warns at launch that the repo's own trusted computing base - `bin/`,
|
||||
`hooks/`, `docker/`, `install.sh` (also `test/`, per the
|
||||
exit-time audit's exact TCB path list above) - is writable for the
|
||||
session (it's inside the project mount, which has no write protection,
|
||||
per above), and that the exit-time audit will report any changes to it.
|
||||
That's a warning and a post-hoc report, not a block: a compromised
|
||||
session can still edit those files during the run, and whatever it
|
||||
changes takes effect the *next* time `bin/dejima` itself runs,
|
||||
not the current one.
|
||||
**→ [docs/security.md](docs/security.md)**
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -799,13 +754,19 @@ instead of finding out the hard way:
|
||||
|
||||
```sh
|
||||
bats test/ # everything
|
||||
bats test/static.bats # fast, no Docker/build needed
|
||||
bats test/static.bats test/project.bats test/remote.bats test/audit.bats # fast, no Docker/build needed
|
||||
SKIP_BUILD=1 bats test/runtime.bats # reuse the current image
|
||||
```
|
||||
|
||||
`.gitea/workflows/tests.yml` runs all of it on every pull request and on every
|
||||
push to one, `runtime.bats` included. That job needs two things from the
|
||||
runner that an ordinary one doesn't — the host's Docker socket and the host
|
||||
network namespace — and it checks for both up front rather than failing
|
||||
obscurely partway through; the file says why each is needed.
|
||||
|
||||
`static.bats` greps the repo's own files - no sudo in the `Dockerfile`, every
|
||||
`ipset add` is duplicate-safe, no `docker.sock`/host-credential mounts, the
|
||||
`~/.claude` read-only overlay lines are present, `bin/dejima-allow`
|
||||
`~/.claude` read-only overlay lines are present, `dejima-allow`
|
||||
rejects a malicious domain argument. `runtime.bats` builds the image, boots
|
||||
a real container running the actual `entrypoint.sh` (a `SANDBOX_TEST_MODE`
|
||||
env var makes it stop just short of execing `$SANDBOX_CMD`, so the suite can attach
|
||||
@@ -816,6 +777,24 @@ enforcement in both directions, GitHub read-only, no working `sudo`/
|
||||
(not just absent from a `docker exec` that doesn't have them to begin
|
||||
with), `ulogd2` unprivileged, and the dynamic-ipset CDN-rotation fix.
|
||||
|
||||
The other three need no Docker either. `project.bats` covers the
|
||||
declaration/approval split - what `.dejima/` may ask for, what only the host
|
||||
can grant, and that the approval store is never mounted. `remote.bats`
|
||||
covers everything in [docs/remote-access.md](docs/remote-access.md):
|
||||
`remote.conf` parsing, the session registry, and the properties the
|
||||
feature's security rests on - that the
|
||||
notification path carries no sandbox-controlled byte, and that no marker
|
||||
name outside `waiting`/`done` is ever acted on. `audit.bats` covers
|
||||
`bin/dejima-audit`'s own diffing.
|
||||
|
||||
One assertion is worth calling out because it can only run for real on a
|
||||
Docker host: `runtime.bats` proves that a service bound to the host's
|
||||
loopback is unreachable from inside a container while one bound to the
|
||||
bridge gateway *is* reachable. The second half is a positive control - it is
|
||||
what stops the first from passing in an environment where the container
|
||||
simply reaches nothing. That pair is the ttyd front door's whole containment
|
||||
argument, so run it before trusting a front door you just set up.
|
||||
|
||||
Every mutation in `runtime.bats` (firewall rules, `ipset` entries) happens
|
||||
inside the test container's own network namespace, same as any other
|
||||
container - none of it touches the host. Container/network names are
|
||||
@@ -826,7 +805,7 @@ likely to collide with a real container you have running.
|
||||
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| `docker/Dockerfile` | Base image, dev tools, nvim, Claude Code + wrapper, `gitea-pr` |
|
||||
| `docker/Dockerfile` | Base image, dev tools, nvim, Claude Code + wrapper, `gitea-pr`/`gitea-issue` |
|
||||
| `docker/fixed-domains.sh` | Shared domain list sourced by both `init-firewall.sh` and `dns-proxy.sh` |
|
||||
| `docker/dns-proxy.sh` | Starts the DNS-allowlisting `dnsmasq`, repoints `resolv.conf` at it |
|
||||
| `docker/init-firewall.sh` | IPv4/IPv6 egress allowlist firewall, run at container start |
|
||||
@@ -838,10 +817,19 @@ likely to collide with a real container you have running.
|
||||
| `hooks/sandbox-context.sh` | `SessionStart` hook: injects sandbox context/workflow reminders |
|
||||
| `hooks/git-push-guard.sh` | `PreToolUse` hook: blocks pushes outside the allowed host(s)/branches |
|
||||
| `hooks/deny-dangerous-bash.sh` | `PreToolUse` hook: blocks obviously destructive Bash commands |
|
||||
| `hooks/gitea-merge-guard.sh` | `PreToolUse` hook: blocks `gitea-pr merge` in the sandbox — merging is a human decision |
|
||||
| `hooks/dejima-notify.sh` | `Notification`/`Stop` hook: drops an empty `waiting`/`done` marker for a remote-enabled session - see [docs/remote-access.md](docs/remote-access.md) |
|
||||
| `bin/dejima-attach` | Lists live sessions and attaches to one; the shared core behind both remote front doors |
|
||||
| `bin/dejima-audit` | Advisory exit-time diff of host-persistence surfaces (git config, `.claude/`, etc.) |
|
||||
| `bin/dejima-dep` | Review and approve project dependencies declared in `.dejima/dependencies` |
|
||||
| `bin/gitea-pr` | Create a PR, read its comments, merge it; baked into the image so the sandbox can open PRs |
|
||||
| `bin/gitea-issue` | List, view, create, comment on, close and reopen Gitea issues; also baked into the image |
|
||||
| `bin/dejima-notify-watch` | Host-side watcher: turns markers into ntfy pushes composed entirely from host data |
|
||||
| `bin/dejima-project` | Review and approve project domains and dependencies from the host |
|
||||
| `bin/dejima-project-lib.sh` | Shared library sourced by `bin/dejima-dep`, `bin/dejima-project`, and `bin/dejima`; not on PATH |
|
||||
| `bin/dejima-project-lib.sh` | Shared library sourced by `bin/dejima-dep`, `bin/dejima-project`, `bin/dejima` and the remote-access scripts; not on PATH |
|
||||
| `bin/dejima-ttyd` | Launches `ttyd` on loopback in front of `dejima-attach` |
|
||||
| `systemd/*.service` | User units for `dejima-ttyd` and `dejima-notify-watch`; ship disabled, installed by hand |
|
||||
| `docs/nginx-dejima.conf.example` | Reverse-proxy template for the web front door; deliberately not loadable as written |
|
||||
|
||||
`bin/dejima` is the entry point — it resolves the project path,
|
||||
checks for a live `ssh-agent`, loads `GITEA_TOKEN`/`GH_TOKEN` if not already
|
||||
|
||||
+153
-7
@@ -139,12 +139,6 @@ fi
|
||||
SANDBOX_SHARE_DIR="${SANDBOX_SHARE_DIR:-$HOME/.local/share/dejima/shared}"
|
||||
mkdir -p "$SANDBOX_SHARE_DIR"
|
||||
|
||||
# ~/.claude/CLAUDE.md (carried into the container) tells agents to write
|
||||
# specs to ~/specs/ rather than a project-local path - make sure that
|
||||
# actually exists before it's bind-mounted, for a fresh machine that hasn't
|
||||
# used the convention yet.
|
||||
mkdir -p "$HOME/specs"
|
||||
|
||||
# skills/commands/agents/output-styles overlays (see docker-compose.yml) can
|
||||
# only apply if the source dirs exist - pre-create them so a host that has
|
||||
# never used one still gets the read-only overlay instead of a writable gap.
|
||||
@@ -188,12 +182,44 @@ fi
|
||||
|
||||
SANDBOX_AUDIT_STATE="$(mktemp "${TMPDIR:-/tmp}/dejima-audit.XXXXXX")"
|
||||
|
||||
# Initialized here, before the trap below is installed, so cleanup() never
|
||||
# observes a nonempty value it didn't itself set - e.g. an operator's
|
||||
# environment happening to export a variable of one of these exact names.
|
||||
# With remote access off (or on an early exit before the remote-access block
|
||||
# below ever runs), `rm -rf "$DEJIMA_NOTIFY_DIR"` against an inherited value
|
||||
# would be a data loss bug, not just a no-op - and several `exit 1` paths
|
||||
# between here and that block would otherwise fire the trap while these
|
||||
# still held whatever came in from the environment (same class of concern as
|
||||
# the credential-proxy variables further down, pre-initialized the same way,
|
||||
# just earlier here since the trap is installed sooner). Every name cleanup()
|
||||
# removes but does not itself create belongs here, not just the remote-access
|
||||
# ones: the three below are assigned only inside conditional blocks (an nvim
|
||||
# config being present, dependencies being approved), so an ordinary run with
|
||||
# neither would otherwise carry an inherited value all the way to the trap -
|
||||
# and SANDBOX_DERIVED_CONTEXT is one of the `rm -rf` arguments.
|
||||
DEJIMA_NOTIFY_DIR=""
|
||||
DEJIMA_SESSION_FILE=""
|
||||
SANDBOX_REMOTE_SETTINGS_TMP=""
|
||||
SANDBOX_NVIM_LOCKFILE=""
|
||||
SANDBOX_DERIVED_DOCKERFILE=""
|
||||
SANDBOX_DERIVED_CONTEXT=""
|
||||
|
||||
cleanup() {
|
||||
if [ -n "${SANDBOX_ANTHROPIC_PROXY_PID:-}" ]; then
|
||||
kill "$SANDBOX_ANTHROPIC_PROXY_PID" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$SANDBOX_SETTINGS_FILE" "$SANDBOX_CLAUDE_JSON_FILE" "${SANDBOX_NVIM_LOCKFILE:-}" "$SANDBOX_AUDIT_STATE" "${SANDBOX_CREDENTIALS_MASK_FILE:-}" "${SANDBOX_DERIVED_DOCKERFILE:-}"
|
||||
rm -rf "$SANDBOX_GITCONFIG_DIR" "${SANDBOX_ANTHROPIC_PROXY_DIR:-}" "${SANDBOX_CREDENTIALS_TMPDIR_MASK:-}" "${SANDBOX_DERIVED_CONTEXT:-}"
|
||||
rm -f "${DEJIMA_SESSION_FILE:-}" "${SANDBOX_REMOTE_SETTINGS_TMP:-}"
|
||||
# The one removal here a session can make fail, and the last command of an
|
||||
# EXIT trap: the marker directory is bind-mounted read-write, so a session
|
||||
# can leave it undeletable (an unwritable subdirectory is enough), and
|
||||
# under set -e a failing command in an EXIT trap REPLACES the shell's exit
|
||||
# status - so without this a compromised session could report 1 in place
|
||||
# of Claude Code's real exit code. Same guard, for the same reason, as
|
||||
# dejima-attach's prune and dejima-notify-watch's marker consumption.
|
||||
rm -rf "${DEJIMA_NOTIFY_DIR:-}" \
|
||||
|| echo "warning: could not remove the marker directory ${DEJIMA_NOTIFY_DIR:-}" >&2
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
@@ -382,7 +408,7 @@ fi
|
||||
|
||||
# Keep the real Anthropic OAuth credential out of the container entirely:
|
||||
# run a host-side proxy that injects it per-request (see
|
||||
# bin/dejima-anthropic-proxy and README "Security notes"), mount an
|
||||
# bin/dejima-anthropic-proxy and docs/security.md), mount an
|
||||
# empty file over ~/.claude/.credentials.json and an empty read-only
|
||||
# directory over ~/.claude/.credentials-proxy-tmp (the proxy's write-back
|
||||
# staging directory - the parent ~/.claude mount must stay read-write, so
|
||||
@@ -434,6 +460,126 @@ if [ "${SANDBOX_CREDENTIAL_PROXY:-1}" != "0" ] \
|
||||
fi
|
||||
export SANDBOX_ANTHROPIC_BASE_URL SANDBOX_ANTHROPIC_AUTH_TOKEN SANDBOX_ANTHROPIC_PROXY_SOCKET
|
||||
|
||||
# Optional remote access (see docs/remote-access.md). Entirely skipped
|
||||
# unless ~/.config/dejima/remote.conf exists - with it absent nothing below
|
||||
# runs, no state is created, and the compose invocation is exactly what it
|
||||
# was before this feature existed.
|
||||
if remote_enabled; then
|
||||
DEJIMA_RUN_ID="$(od -An -tx1 -N8 /dev/urandom | tr -d ' \n')"
|
||||
DEJIMA_STATE_DIR="$(remote_state_dir)"
|
||||
|
||||
# The state dir's parent (e.g. /tmp, when XDG_RUNTIME_DIR is unset - the
|
||||
# common case for exactly the non-systemd ssh sessions this feature
|
||||
# targets) is world-writable, and `mkdir -p -m 700` only applies MODE to
|
||||
# the leaf it creates - an already-existing path here (a symlink an
|
||||
# attacker pre-created, or a directory some other user owns) is left as
|
||||
# is. Since the notify dir created under it is bind-mounted read-write
|
||||
# into the container and later `rm -rf`'d by the EXIT trap, following a
|
||||
# planted symlink would hand a compromised session (or the trap itself)
|
||||
# access to an attacker-chosen path. Create it, then verify it's a real
|
||||
# directory we own and not a symlink before trusting it - refuse rather
|
||||
# than proceed on anything else. Once this check passes, everything
|
||||
# created below it (sessions/, notify/, notify/<run-id>) is safe: mode
|
||||
# 700 here means no other user can plant anything inside it afterward.
|
||||
mkdir -p -m 700 "$DEJIMA_STATE_DIR" 2>/dev/null || true
|
||||
if [ -L "$DEJIMA_STATE_DIR" ] || [ ! -d "$DEJIMA_STATE_DIR" ] \
|
||||
|| [ "$(stat -c %u "$DEJIMA_STATE_DIR")" != "$(id -u)" ]; then
|
||||
echo "Refusing remote access: $DEJIMA_STATE_DIR exists and is not a plain" >&2
|
||||
echo "directory owned by the current user (or is a symlink). This could be a" >&2
|
||||
echo "symlink attack via a shared /tmp - remove it, or set XDG_RUNTIME_DIR to" >&2
|
||||
echo "somewhere else, and retry." >&2
|
||||
exit 1
|
||||
fi
|
||||
chmod 700 "$DEJIMA_STATE_DIR"
|
||||
|
||||
mkdir -p -m 700 "$DEJIMA_STATE_DIR/sessions" "$DEJIMA_STATE_DIR/notify"
|
||||
DEJIMA_SESSION_FILE="$DEJIMA_STATE_DIR/sessions/$DEJIMA_RUN_ID"
|
||||
DEJIMA_NOTIFY_DIR="$DEJIMA_STATE_DIR/notify/$DEJIMA_RUN_ID"
|
||||
mkdir -p -m 700 "$DEJIMA_NOTIFY_DIR"
|
||||
|
||||
# $TMUX_PANE is set by tmux in the launching shell. Empty when the
|
||||
# sandbox wasn't started from tmux: the session is still registered, just
|
||||
# listed as not-attachable, rather than silently omitted.
|
||||
dejima_tmux_label=""
|
||||
if [ -n "${TMUX_PANE:-}" ] && command -v tmux >/dev/null 2>&1; then
|
||||
dejima_tmux_label="$(tmux display-message -p -t "$TMUX_PANE" \
|
||||
'#{session_name}:#{window_index}' 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
# A newline in either value would inject an extra key=value line into the
|
||||
# flat registry entry below - e.g. a fabricated `pane=` line inside a
|
||||
# crafted label, which the last-match-wins parser (see dejima-attach)
|
||||
# would honor over the real one, redirecting an operator to an
|
||||
# attacker-chosen pane. Refuse outright rather than sanitize: a value
|
||||
# that can't be represented safely here is the same class of problem as
|
||||
# a corrupt approval store elsewhere in this repo.
|
||||
case "$WORKSPACE_DIR" in
|
||||
*$'\n'*)
|
||||
echo "Refusing remote access: workspace path contains a newline: $WORKSPACE_DIR" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
case "$dejima_tmux_label" in
|
||||
*$'\n'*)
|
||||
echo "Refusing remote access: tmux session/window label contains a newline." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
# $TMUX_PANE comes from the launching shell's environment, so it is the
|
||||
# one value here that is taken verbatim rather than derived. It is written
|
||||
# second of the four lines below, so an injected line lands ahead of
|
||||
# label= and started= and would lose the parser's last-match-wins race to
|
||||
# the real ones - but it would WIN against the pane= it is part of, which
|
||||
# is the field deciding which pane an operator gets attached to. Refusing
|
||||
# the newline outright is what makes that independent of printf ordering.
|
||||
case "${TMUX_PANE:-}" in
|
||||
*$'\n'*)
|
||||
echo "Refusing remote access: \$TMUX_PANE contains a newline." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Flat key=value, like the rest of the host-side store: no jq dependency
|
||||
# here, and nothing that reads it ever sources it.
|
||||
{
|
||||
printf 'workspace=%s\n' "$WORKSPACE_DIR"
|
||||
printf 'pane=%s\n' "${TMUX_PANE:-}"
|
||||
printf 'label=%s\n' "$dejima_tmux_label"
|
||||
printf 'started=%s\n' "$(date +%s)"
|
||||
} > "$DEJIMA_SESSION_FILE"
|
||||
|
||||
# This run's OWN marker directory is the only thing mounted in - never
|
||||
# its parent - so a compromised session can write markers for itself and
|
||||
# for nothing else. The registry is never mounted at all: dejima-attach
|
||||
# trusts it to decide which pane to attach an operator to, so a session
|
||||
# able to rewrite it could redirect them.
|
||||
#
|
||||
# -e alongside -v here: this array is passed straight to `docker compose
|
||||
# run`, where both are valid flags.
|
||||
extra_mounts+=(-v "$DEJIMA_NOTIFY_DIR:/run/dejima-notify")
|
||||
extra_mounts+=(-e "DEJIMA_NOTIFY_DIR=/run/dejima-notify")
|
||||
# Lets the host correlate this run to its container via `docker inspect`,
|
||||
# so the container never needs write access to the registry to report its
|
||||
# own ID.
|
||||
extra_mounts+=(-e "DEJIMA_RUN_ID=$DEJIMA_RUN_ID")
|
||||
|
||||
# The notify hook is registered per session, into the generated copy from
|
||||
# above and nowhere else: registering it in the operator's real
|
||||
# settings.json would run it for every sandbox and for the host's own
|
||||
# Claude Code, which share that file, when it is only meaningful for a
|
||||
# session that has a marker directory. $SANDBOX_SETTINGS_FILE is
|
||||
# already a throwaway mktemp copy, so this rewrite cannot reach the
|
||||
# operator's real settings.json; the copy is mounted read-only, so the
|
||||
# session cannot unregister itself either. $HOME is left unexpanded for
|
||||
# Claude Code to resolve at hook-run time - inside the container, not
|
||||
# here - matching the convention install.sh already uses.
|
||||
SANDBOX_REMOTE_SETTINGS_TMP="$(mktemp "${TMPDIR:-/tmp}/dejima-settings-remote.XXXXXX.json")"
|
||||
jq '.hooks.Notification = ((.hooks.Notification // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/dejima-notify.sh waiting"}]}])
|
||||
| .hooks.Stop = ((.hooks.Stop // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/dejima-notify.sh done"}]}])' \
|
||||
"$SANDBOX_SETTINGS_FILE" > "$SANDBOX_REMOTE_SETTINGS_TMP"
|
||||
mv "$SANDBOX_REMOTE_SETTINGS_TMP" "$SANDBOX_SETTINGS_FILE"
|
||||
fi
|
||||
|
||||
# Snapshot host-persistence surfaces so the post-run audit can report
|
||||
# anything this session changed (advisory - see bin/dejima-audit).
|
||||
"$SCRIPT_DIR/dejima-audit" snapshot "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
# real OAuth access token from ~/.claude/.credentials.json, so the credential
|
||||
# itself never enters the container. It also owns token refresh: rotated
|
||||
# tokens are written back to the real host file atomically, keeping
|
||||
# host-side sessions logged in. See README "Security notes" and
|
||||
# ~/specs/2026-07-24-dejima-credential-proxy-design.md.
|
||||
# host-side sessions logged in. See docs/security.md.
|
||||
import argparse
|
||||
import http.client
|
||||
import json
|
||||
@@ -131,7 +130,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def _fail(self, status, message, log_message=None):
|
||||
# log_message (host stderr) may contain upstream response bytes or
|
||||
# exception detail; message (sent to the container in the response
|
||||
# body) never should - see README "Security notes".
|
||||
# body) never should - see docs/security.md.
|
||||
self.log_error("%s", log_message if log_message is not None else message)
|
||||
body = json.dumps(
|
||||
{"type": "error", "error": {"type": "proxy_error", "message": message}}
|
||||
|
||||
Executable
+449
@@ -0,0 +1,449 @@
|
||||
#!/bin/bash
|
||||
# List live dejima sandbox sessions and attach to one. This is the shared
|
||||
# core behind both remote front doors: it runs as a forced SSH command
|
||||
# (restrict,pty,command="...") and as ttyd's command. See
|
||||
# docs/remote-access.md.
|
||||
#
|
||||
# Usage: dejima-attach [run-id|--list]
|
||||
# (no argument) numbered picker, attaches to the choice
|
||||
# run-id attach directly - what a notification deep link and ttyd's
|
||||
# --url-arg produce
|
||||
# --list one tab-separated row per live session, for scripting
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||
# shellcheck source=dejima-project-lib.sh
|
||||
source "$SCRIPT_DIR/dejima-project-lib.sh"
|
||||
|
||||
if ! remote_enabled; then
|
||||
echo "Remote access is not configured ($(remote_conf_path) does not exist)." >&2
|
||||
echo "See README \"Remote access\"." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
STATE_DIR="$(remote_state_dir)"
|
||||
SESSIONS_DIR="$STATE_DIR/sessions"
|
||||
|
||||
# gruvbox dark. Every value below is upstream's, tagged with the name of the
|
||||
# color.table row it came from:
|
||||
# https://github.com/gruvbox-community/gruvbox-contrib/blob/master/color.table
|
||||
# Carrying the name and not just the hex is the point - a palette this widely
|
||||
# known is one almost nobody has memorised to six digits, so a wrong value is
|
||||
# only catchable by reading if there is something to check it against.
|
||||
#
|
||||
# Truecolor only, and deliberately not paired with a 256-colour table: a
|
||||
# terminal without it - or tmux without `Tc` - approximates 38;2 to its own
|
||||
# nearest colour rather than printing the escape, so the fallback already
|
||||
# exists one layer down and a second palette here would only duplicate it.
|
||||
#
|
||||
# Everything below is empty unless stdout is a terminal that says it can show
|
||||
# colour, which is what keeps --list clean: it is a machine interface (ttyd's
|
||||
# --url-arg feeds run ids back in, and the README documents the rows as
|
||||
# scriptable), and it is also how the bats suite reads this script, so its
|
||||
# assertions see the same bare words either way.
|
||||
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-dumb}" != dumb ]; then
|
||||
C_OFF=$'\033[0m'; C_BOLD=$'\033[1m'
|
||||
C_BADGE=$'\033[38;2;254;128;25m' # bright_orange #fe8019, the wordmark
|
||||
C_TEXT=$'\033[38;2;235;219;178m' # light1 #ebdbb2
|
||||
C_MUTED=$'\033[38;2;146;131;116m' # gray_245 #928374
|
||||
C_SUBTLE=$'\033[38;2;124;111;100m' # dark4 #7c6f64
|
||||
C_RULE=$'\033[38;2;80;73;69m' # dark2 #504945, separator
|
||||
C_OK=$'\033[38;2;184;187;38m' # bright_green #b8bb26, success
|
||||
C_WARN=$'\033[38;2;250;189;47m' # bright_yellow #fabd2f, warning
|
||||
C_ERR=$'\033[38;2;251;73;52m' # bright_red #fb4934, error
|
||||
C_LINK=$'\033[38;2;131;165;152m' # bright_blue #83a598
|
||||
GLYPH_BAR='▏'; GLYPH_DOT='●'
|
||||
# A literal rather than a repeat of GLYPH_RULE: `tr` is byte-oriented and
|
||||
# would shred a 3-byte box-drawing character, and every alternative costs
|
||||
# a loop or a subshell to draw a fixed line. Both are the width of the
|
||||
# heading they sit under ("dejima | live sessions"), and are next to it so
|
||||
# they stay that way.
|
||||
RULE_LINE='──────────────────────'
|
||||
else
|
||||
C_OFF=""; C_BOLD=""; C_BADGE=""; C_TEXT=""; C_MUTED=""; C_SUBTLE=""
|
||||
C_RULE=""; C_OK=""; C_WARN=""; C_ERR=""; C_LINK=""
|
||||
# Glyphs ride the same switch rather than a separate UTF-8 probe. Not
|
||||
# because no-colour implies no-UTF-8, but because the two terminals this
|
||||
# has to stay readable on - a $TERM the host has no entry for, and a pipe -
|
||||
# are exactly the ones that mangle box drawing, and one flag is easier to
|
||||
# reason about than two.
|
||||
GLYPH_BAR='|'; GLYPH_DOT='*'
|
||||
RULE_LINE='----------------------'
|
||||
fi
|
||||
|
||||
# Narrow deliberately. The phone is this script's main front door, and the
|
||||
# rule underlines the heading instead of spanning the table because the
|
||||
# table's width follows the longest project name - a rule that chased it would
|
||||
# wrap on a phone exactly when the rows did.
|
||||
banner() {
|
||||
printf '\n %s%sdejima%s %s%s%s %slive sessions%s\n' \
|
||||
"$C_BOLD" "$C_BADGE" "$C_OFF" "$C_SUBTLE" "$GLYPH_BAR" "$C_OFF" \
|
||||
"$C_MUTED" "$C_OFF"
|
||||
printf ' %s%s%s\n\n' "$C_RULE" "$RULE_LINE" "$C_OFF"
|
||||
}
|
||||
|
||||
# Coloured by meaning, not by name: attachable is the one an operator wants to
|
||||
# find, and stale/no-tmux are the two that will refuse if picked.
|
||||
state_color() {
|
||||
case "$1" in
|
||||
attachable) printf '%s' "$C_OK" ;;
|
||||
stale) printf '%s' "$C_ERR" ;;
|
||||
*) printf '%s' "$C_WARN" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# How long after its own started= a registered session may still have no
|
||||
# container, before the prune in list_sessions treats that as "gone".
|
||||
#
|
||||
# Sized against the step that actually holds a launch up: bin/dejima writes
|
||||
# the registry entry, then rewrites the settings copy, snapshots the
|
||||
# workspace for the audit, runs an UNCONDITIONAL `docker compose build
|
||||
# claude`, optionally builds up to two derived images on top of it, and only
|
||||
# then reaches `docker compose run`. A cold base build is the Dockerfile's
|
||||
# apt and npm layers fetched over the network - minutes, not seconds - and
|
||||
# the derived builds add to it, while a warm run reaches the container in
|
||||
# seconds and never comes near this window.
|
||||
#
|
||||
# The two errors are not symmetric, which is why this is generous. Too long
|
||||
# only leaves a session that really did die listed until the window passes,
|
||||
# and the next listing prunes it. Too short deletes the registry entry of a
|
||||
# session that is about to start: bin/dejima never rewrites that entry, so
|
||||
# the session is permanently absent from both front doors, and the marker
|
||||
# directory deleted with it is the source of a bind mount the imminent
|
||||
# `docker compose run` declares.
|
||||
STARTUP_GRACE=900
|
||||
|
||||
# Parsed, never sourced: this file is host-written, but treating state files
|
||||
# as code is the habit this repo doesn't have. Returns nonzero for an entry
|
||||
# that can't be read or doesn't parse; callers skip it rather than dying,
|
||||
# since one bad entry must not hide every other live session.
|
||||
read_entry() {
|
||||
local file="$1" line
|
||||
entry_workspace=""; entry_pane=""; entry_label=""; entry_started=""
|
||||
[ -r "$file" ] || return 1
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
case "$line" in
|
||||
workspace=*) entry_workspace="${line#*=}" ;;
|
||||
pane=*) entry_pane="${line#*=}" ;;
|
||||
label=*) entry_label="${line#*=}" ;;
|
||||
started=*) entry_started="${line#*=}" ;;
|
||||
esac
|
||||
done < "$file" || return 1
|
||||
# started is the one field that reaches an arithmetic context, where a
|
||||
# non-numeric value is evaluated as a *variable name* - so "started=x[$(cmd)]"
|
||||
# executes cmd, and set -u doesn't catch it when the name is already bound.
|
||||
# Refuse the entry instead of arithmetic-evaluating anything from a file.
|
||||
# (Digits alone aren't enough for the arithmetic itself - see the 10# below.)
|
||||
case "$entry_started" in
|
||||
''|*[!0-9]*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# The container that reports this run ID. bin/dejima passes DEJIMA_RUN_ID in
|
||||
# as env precisely so this correlation is possible without the container
|
||||
# having any write access to the registry.
|
||||
#
|
||||
# Exit status distinguishes "no such container" (1) from "couldn't ask" (2):
|
||||
# the caller prunes on the former, and pruning on the latter would permanently
|
||||
# unregister every running sandbox the moment dockerd happens to be restarting.
|
||||
container_for() {
|
||||
local run_id="$1" names name env unasked=""
|
||||
names="$(docker ps --filter "label=com.docker.compose.project=dejima" \
|
||||
--filter "status=running" --format '{{.Names}}' 2>/dev/null)" || return 2
|
||||
for name in $names; do
|
||||
# Captured rather than piped straight into grep: a pipeline reports
|
||||
# only grep's status, which would turn "inspect couldn't answer for
|
||||
# this container" into "this isn't the one" - and one such container
|
||||
# anywhere in the list would prune an unrelated live session.
|
||||
env="$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$name" 2>/dev/null)" \
|
||||
|| { unasked=1; continue; }
|
||||
if printf '%s\n' "$env" | grep -qxF "DEJIMA_RUN_ID=$run_id"; then
|
||||
printf '%s' "$name"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
[ -z "$unasked" ] || return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
pane_alive() {
|
||||
[ -n "$1" ] || return 1
|
||||
tmux list-panes -a -F '#{pane_id}' 2>/dev/null | grep -qxF "$1"
|
||||
}
|
||||
|
||||
# Does session $1 actually hold pane $2? The only question that separates the
|
||||
# session we mean from one that merely carries its name. -s enumerates every
|
||||
# pane of every window in the session, and pane ids are never reused within a
|
||||
# server, so a match cannot be some later pane wearing an old id. pipefail
|
||||
# makes an unanswerable list-panes a "no", which is the safe direction.
|
||||
group_holds_pane() {
|
||||
tmux list-panes -s -t "=$1" -F '#{pane_id}' 2>/dev/null | grep -qxF "$2"
|
||||
}
|
||||
|
||||
# True while an entry is young enough that its container may simply not exist
|
||||
# YET - see STARTUP_GRACE. Judged against the entry's own started=, since
|
||||
# nothing else records when the launch began. An entry that doesn't parse has
|
||||
# no age to judge and is not covered: it is pruned as before. read_entry is
|
||||
# called again by the caller afterwards; re-reading four lines costs less
|
||||
# than restructuring the loop around this one field.
|
||||
starting_up() {
|
||||
local now="$2"
|
||||
read_entry "$1" || return 1
|
||||
[ "$(( now - 10#$entry_started ))" -lt "$STARTUP_GRACE" ]
|
||||
}
|
||||
|
||||
# The registry is a hint, not truth: bin/dejima's EXIT trap doesn't run under
|
||||
# SIGKILL or a host crash, so an entry can outlive its session. An entry with
|
||||
# no matching container - and old enough that it should have had one - is
|
||||
# pruned outright; one whose pane is gone is shown as stale rather than
|
||||
# offered for attachment.
|
||||
list_sessions() {
|
||||
local file run_id container state now rc warned=""
|
||||
[ -d "$SESSIONS_DIR" ] || return 0
|
||||
now="$(date +%s)"
|
||||
for file in "$SESSIONS_DIR"/*; do
|
||||
[ -f "$file" ] || continue
|
||||
run_id="$(basename "$file")"
|
||||
valid_run_id "$run_id" || continue
|
||||
rc=0
|
||||
container="$(container_for "$run_id")" || rc=$?
|
||||
if [ "$rc" -eq 1 ] && starting_up "$file" "$now"; then
|
||||
# Registered, but bin/dejima is still building - "not started yet"
|
||||
# is a third answer, and pruning on it would unregister a session
|
||||
# that is about to exist. Listed with the container column saying
|
||||
# so; the recorded pane is the one bin/dejima is building in, so
|
||||
# a session in this state is attachable like any other.
|
||||
container="starting"
|
||||
elif [ "$rc" -eq 1 ]; then
|
||||
rm -f "$file"
|
||||
# dejima-notify-watch keys its debounce stamps by run id too, and
|
||||
# nothing else ever removes them - without this, every session
|
||||
# leaves two behind for the life of the login session. Unguarded,
|
||||
# like the rm above and unlike the rm -rf below: the debounce
|
||||
# directory is host-owned and is never mounted into a sandbox.
|
||||
rm -f "$STATE_DIR/debounce/$run_id-waiting" "$STATE_DIR/debounce/$run_id-done"
|
||||
# Non-fatal: the marker directory is bind-mounted read-write into
|
||||
# the sandbox, so a compromised session can chmod it such that this
|
||||
# rm fails - and a failure here under set -e would abort the whole
|
||||
# listing, letting that session deny the operator the front door.
|
||||
rm -rf "${STATE_DIR:?}/notify/$run_id" \
|
||||
|| echo "warning: could not remove the marker directory for $run_id" >&2
|
||||
continue
|
||||
elif [ "$rc" -ne 0 ]; then
|
||||
# An unanswerable question is never a "no": the entry stays, and
|
||||
# the container column says the daemon couldn't confirm it.
|
||||
container="?"
|
||||
[ -n "$warned" ] || echo "warning: could not query docker - sessions listed unverified." >&2
|
||||
warned=1
|
||||
fi
|
||||
if ! read_entry "$file"; then
|
||||
echo "warning: skipping unreadable or malformed registry entry: $file" >&2
|
||||
continue
|
||||
fi
|
||||
if [ -z "$entry_pane" ]; then
|
||||
state="no-tmux"
|
||||
elif pane_alive "$entry_pane"; then
|
||||
state="attachable"
|
||||
else
|
||||
state="stale"
|
||||
fi
|
||||
printf '%s\t%s\t%s\t%dm\t%s\t%s\n' \
|
||||
"$run_id" "${entry_label:-?}" "$(basename "$entry_workspace")" \
|
||||
"$(( (now - 10#$entry_started) / 60 ))" "$state" "${container:0:12}"
|
||||
done
|
||||
}
|
||||
|
||||
# Attaches through a GROUPED session rather than the operator's own. Grouped
|
||||
# sessions share windows but track their own current window, so a phone can
|
||||
# sit on a different sandbox than the desktop, and destroy-unattached disposes
|
||||
# of the throwaway on detach.
|
||||
#
|
||||
# Nothing here sets window-size, deliberately: keeping the desktop from being
|
||||
# resized down to phone dimensions is what tmux's own default (latest, since
|
||||
# 3.1) already does, and the option's only scopes are the window - which a
|
||||
# grouped session SHARES with the operator's own, so setting it there
|
||||
# permanently overwrites their choice, with nothing to restore it afterwards -
|
||||
# and global. An operator who deliberately set smallest keeps it.
|
||||
attach() {
|
||||
local run_id="$1" file group win base target
|
||||
file="$SESSIONS_DIR/$run_id"
|
||||
[ -f "$file" ] || { echo "No such session: $run_id" >&2; exit 1; }
|
||||
if ! read_entry "$file"; then
|
||||
echo "Session $run_id has an unreadable or malformed registry entry." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$entry_pane" ]; then
|
||||
echo "Session $run_id was not started from tmux - nothing to attach to." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! pane_alive "$entry_pane"; then
|
||||
echo "Session $run_id is stale - its tmux pane is gone." >&2
|
||||
exit 1
|
||||
fi
|
||||
group="dejima-$run_id"
|
||||
# Both values come from the same live pane, in one round trip. Taking the
|
||||
# session name from the recorded label instead would follow a name rather
|
||||
# than the sandbox: an operator renaming sessions is enough to point it at
|
||||
# an unrelated session that has since taken the old name.
|
||||
#
|
||||
# The name is a starting point, never a guarantee. It is frequently
|
||||
# ANOTHER remote client's throwaway group (tmux reports whichever session
|
||||
# holding the window is most recent, and every grouped session here is
|
||||
# newer than the operator's own), and those carry destroy-unattached - so
|
||||
# the base can be made to stop existing on demand by anyone holding a
|
||||
# second connection. That is why the session created from it is verified
|
||||
# below instead of trusted: `new-session -t` does not fail on a name that
|
||||
# matches nothing, it silently creates a fresh UNGROUPED session, which
|
||||
# for a remote client is a plain host shell with no sandbox in it.
|
||||
#
|
||||
# display-message prints nothing and still exits 0 for a target that no
|
||||
# longer exists, so an empty result means the pane went away between the
|
||||
# check above and here. That case is caught by the verification below too;
|
||||
# bailing early just avoids creating a junk session to immediately kill,
|
||||
# and says what actually happened.
|
||||
target="$(tmux display-message -p -t "$entry_pane" '#{session_name}:#{window_index}' 2>/dev/null || true)"
|
||||
# Session names may contain ':', window indexes can't.
|
||||
base="${target%:*}"
|
||||
win="${target##*:}"
|
||||
if [ -z "$base" ] || [ -z "$win" ]; then
|
||||
echo "Session $run_id is stale - its tmux pane went away." >&2
|
||||
exit 1
|
||||
fi
|
||||
if tmux has-session -t "=$group" 2>/dev/null; then
|
||||
# Verified exactly like the session created below, and for the same
|
||||
# reason: nothing here created this one, so carrying the name is not
|
||||
# evidence of holding the sandbox. Reachable only as debris - a
|
||||
# dejima-<run-id> that exists but no longer contains the pane - and
|
||||
# anyone able to create that name already has a host shell, so this is
|
||||
# about landing in the right window rather than about containment.
|
||||
# Refused rather than killed: destroying a session this script did not
|
||||
# create is not its call, and the message names the one to look at.
|
||||
if ! group_holds_pane "$group" "$entry_pane"; then
|
||||
echo "Session $run_id: the existing tmux session $group does not hold its pane." >&2
|
||||
echo "Remove it (tmux kill-session -t '=$group') and attach again." >&2
|
||||
exit 1
|
||||
fi
|
||||
# Select within the GROUP, not via the pane id directly: the latter
|
||||
# would move the operator's own session's current window too.
|
||||
tmux select-window -t "=$group:$win"
|
||||
# Set here too, not just on the session we create: the option is
|
||||
# per-session state, so a group that ever reached this point without it
|
||||
# would keep outliving its client on every later attach.
|
||||
exec tmux attach-session -t "=$group" \; set-option destroy-unattached on
|
||||
fi
|
||||
# Created DETACHED first so the result can be inspected before a client
|
||||
# ever sees it: whatever tmux gave us has to actually contain the sandbox
|
||||
# pane, or it is a session tmux invented from a name that stopped existing
|
||||
# while we were looking at it. Anything else is killed rather than handed
|
||||
# over. ("="-anchored, so a name can only ever resolve exactly - an
|
||||
# unanchored target prefix/fnmatch-matches some other session.)
|
||||
tmux new-session -d -t "=$base" -s "$group"
|
||||
# Detached, it now outlives every failure below, and a session leaked that
|
||||
# way never gets destroy-unattached set on it, so it sits on the host for
|
||||
# good. The trap only ever names a session this call just created:
|
||||
# new-session fails on a duplicate name before we reach it.
|
||||
# ("="-anchored also so it can never mean "the current session", which is
|
||||
# what an empty target means to kill-session.)
|
||||
#
|
||||
# The name is baked in here rather than read at exit: $group is local, and
|
||||
# a trap body runs after the function's locals are gone, where set -u
|
||||
# would abort it and leak the session it exists to remove.
|
||||
trap "tmux kill-session -t '=$group' 2>/dev/null || true" EXIT
|
||||
if ! group_holds_pane "$group" "$entry_pane"; then
|
||||
echo "Session $run_id is stale - the tmux session holding its pane went away." >&2
|
||||
exit 1
|
||||
fi
|
||||
tmux select-window -t "=$group:$win"
|
||||
# destroy-unattached has to be set by the same command that attaches:
|
||||
# setting it while the session is still detached makes the server reap the
|
||||
# session before the attach lands.
|
||||
#
|
||||
# Deliberately not exec'd, unlike the branch above, which creates nothing:
|
||||
# attaching is the step most likely to fail on a remote client's terms -
|
||||
# no terminal for `ssh -T`, or a $TERM the host has no entry for, both of
|
||||
# which the client picks - and an exec leaves no shell behind to clean up
|
||||
# the session we just made. Detaching normally returns 0 and disarms the
|
||||
# trap; the session then goes away by destroy-unattached, not by us.
|
||||
tmux attach-session -t "=$group" \; set-option destroy-unattached on
|
||||
trap - EXIT
|
||||
}
|
||||
|
||||
picker() {
|
||||
local -a ids=() labels=() projects=() ages=() states=() containers=()
|
||||
local i choice run_id label project age state container
|
||||
local w_label=0 w_project=0 w_age=0 w_state=0
|
||||
# Split on tabs only, so a space in a project name stays part of one field.
|
||||
# The columns are measured on the way in because they are padded to the
|
||||
# widest value rather than to a guess: a project name is a directory name,
|
||||
# and a fixed width either truncates a long one or indents every short one
|
||||
# off the right edge of a phone.
|
||||
while IFS=$'\t' read -r run_id label project age state container; do
|
||||
ids+=("$run_id"); labels+=("$label"); projects+=("$project")
|
||||
ages+=("$age"); states+=("$state"); containers+=("$container")
|
||||
[ "${#label}" -le "$w_label" ] || w_label="${#label}"
|
||||
[ "${#project}" -le "$w_project" ] || w_project="${#project}"
|
||||
[ "${#age}" -le "$w_age" ] || w_age="${#age}"
|
||||
[ "${#state}" -le "$w_state" ] || w_state="${#state}"
|
||||
done < <(list_sessions)
|
||||
banner
|
||||
if [ "${#ids[@]}" -eq 0 ]; then
|
||||
printf ' %sNo live sessions.%s\n\n' "$C_MUTED" "$C_OFF"
|
||||
exit 0
|
||||
fi
|
||||
# The container column stays, and last, because it is not decoration: it
|
||||
# reads "starting" for a session bin/dejima is still building and "?" when
|
||||
# docker could not be asked at all, and dropping it would make either one
|
||||
# look like an ordinary attachable session.
|
||||
for i in "${!ids[@]}"; do
|
||||
local sc; sc="$(state_color "${states[$i]}")"
|
||||
printf ' %s%2d%s %s%s%s %s%-*s%s %s%-*s%s %s%*s%s %s%-*s%s %s%s%s\n' \
|
||||
"$C_WARN" "$((i + 1))" "$C_OFF" \
|
||||
"$sc" "$GLYPH_DOT" "$C_OFF" \
|
||||
"$C_BOLD$C_TEXT" "$w_project" "${projects[$i]}" "$C_OFF" \
|
||||
"$C_LINK" "$w_label" "${labels[$i]}" "$C_OFF" \
|
||||
"$C_MUTED" "$w_age" "${ages[$i]}" "$C_OFF" \
|
||||
"$sc" "$w_state" "${states[$i]}" "$C_OFF" \
|
||||
"$C_SUBTLE" "${containers[$i]}" "$C_OFF"
|
||||
done
|
||||
# Numbered rather than arrow-driven: phone soft keyboards handle digits
|
||||
# far better than arrow keys, and this is the phone's entry point.
|
||||
printf '\n %sattach to%s %s[1-%d, q to quit]%s: ' \
|
||||
"$C_MUTED" "$C_OFF" "$C_TEXT" "${#ids[@]}" "$C_OFF"
|
||||
read -r choice
|
||||
case "$choice" in
|
||||
q|Q|'') exit 0 ;;
|
||||
*[!0-9]*) echo "Not a number." >&2; exit 1 ;;
|
||||
esac
|
||||
# Normalized to base 10 once, here, because the range check and the
|
||||
# subscript below do not read a number the same way: `[` is POSIX test and
|
||||
# decimal-only, while $(( )) reads a leading zero as OCTAL. Without this,
|
||||
# "010" - which a phone keypad makes easy to type - passes the check as 10
|
||||
# and indexes as 8, and the caller is attached to a DIFFERENT session than
|
||||
# the one they picked, with nothing printed to say so.
|
||||
#
|
||||
# It fixes the two ugly shapes as well. "09" is a value here rather than
|
||||
# the "invalid octal constant" arithmetic error it is to $(( )); and a
|
||||
# number past int64 wraps to one the range check can answer for, where
|
||||
# `[ 99999999999999999999999 -gt 11 ]` instead exits 2 with "integer
|
||||
# expression expected" - which makes the `if` false, so "Out of range."
|
||||
# is never printed and the subscript below dies on an unbound element.
|
||||
choice="$((10#$choice))"
|
||||
if [ "$choice" -lt 1 ] || [ "$choice" -gt "${#ids[@]}" ]; then
|
||||
echo "Out of range." >&2
|
||||
exit 1
|
||||
fi
|
||||
attach "${ids[$((choice - 1))]}"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
'') picker ;;
|
||||
--list) list_sessions ;;
|
||||
*)
|
||||
# This argument arrives from ttyd's --url-arg, i.e. from whoever can
|
||||
# reach the web front door. Validate its shape before it is used as a
|
||||
# path component or anything else.
|
||||
valid_run_id "$1" || { echo "Invalid session id." >&2; exit 1; }
|
||||
[ -f "$SESSIONS_DIR/$1" ] || { echo "No such session: $1" >&2; exit 1; }
|
||||
attach "$1"
|
||||
;;
|
||||
esac
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
# would execute or load OUTSIDE the sandbox: git config keys that run a
|
||||
# command on the next `git`, and content under .claude/ / .mcp.json /
|
||||
# .envrc / .vscode/ / .git/hooks/. Read the report before running `git` or
|
||||
# `claude` in that repo again. See README "Security notes".
|
||||
# `claude` in that repo again. See docs/security.md.
|
||||
set -uo pipefail
|
||||
|
||||
cmd="${1:-}"; ws="${2:-}"; state="${3:-}"
|
||||
|
||||
Executable
+282
@@ -0,0 +1,282 @@
|
||||
#!/bin/bash
|
||||
# Host-side watcher: turns marker files dropped by hooks/dejima-notify.sh
|
||||
# into ntfy pushes. Runs as a systemd USER unit on the host - never as root,
|
||||
# never inside a container. See docs/remote-access.md.
|
||||
#
|
||||
# Usage: dejima-notify-watch [--once]
|
||||
# (no argument) watch until killed - what the systemd unit runs
|
||||
# --once one scan, then exit
|
||||
#
|
||||
# This script is the trust boundary. Everything under the marker root is
|
||||
# written by a sandboxed session that may be compromised, and the device it
|
||||
# ultimately reaches - the operator's phone - is outside the sandbox. The
|
||||
# in-container hook's own enum constrains nothing here: a session owns the
|
||||
# contents of its bind-mounted marker directory and can create any name it
|
||||
# likes in it, hook or no hook. The guarantee rests entirely on this file:
|
||||
#
|
||||
# - the marker NAME is the only thing consulted, and only by iterating a
|
||||
# literal enum, so a name outside it is never stat'd, read, or removed
|
||||
# - marker CONTENT is never read, so there is nothing to inject
|
||||
# - names are never interpolated into a command
|
||||
# - `rm -f` unlinks a symlink instead of following it, so a marker aimed at
|
||||
# a host file removes the link and leaves the file alone
|
||||
# - the click URL and every ntfy header are composed from host-side data
|
||||
#
|
||||
# That last point is what the enum is really for. ntfy's own headers include
|
||||
# X-Actions (which can make the phone issue an arbitrary HTTP request or fire
|
||||
# an Android intent), X-Attach and X-Icon (which fetch URLs), X-Email and
|
||||
# X-Call. A single attacker-controlled header here would turn the phone into
|
||||
# a confused deputy inside the operator's network.
|
||||
#
|
||||
# One scan covers every run directory, so nothing a single session can do to
|
||||
# its own markers may abort it: that would let one compromised sandbox silence
|
||||
# every other session's notifications.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||
# shellcheck source=dejima-project-lib.sh
|
||||
source "$SCRIPT_DIR/dejima-project-lib.sh"
|
||||
|
||||
if ! remote_enabled; then
|
||||
echo "Remote access is not configured ($(remote_conf_path) does not exist)." >&2
|
||||
echo "See README \"Remote access\"." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NTFY_URL="$(remote_conf_get NTFY_URL)"
|
||||
BASE_URL="$(remote_conf_get BASE_URL)"
|
||||
if [ -z "$NTFY_URL" ]; then
|
||||
echo "NTFY_URL is not set in $(remote_conf_path) - nothing to push to." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# valid_remote_value permits a leading '-', which is harmless in a config file
|
||||
# and not harmless in curl's argument list: curl reads "-K/path" as an OPTION,
|
||||
# and -K takes the request's URL - and its output file - from that path
|
||||
# instead, so a value that isn't a URL sends the push somewhere the operator
|
||||
# never named. Requiring an explicit scheme is what makes these URLs rather
|
||||
# than arguments; the `--` at the call site is the other half. Widening
|
||||
# valid_remote_value instead would be the wrong place: it also guards nginx
|
||||
# and the TTYD_PORT, neither of which wants a URL.
|
||||
#
|
||||
# Refusing rather than sanitizing, because remote.conf is operator-written:
|
||||
# a value that can't be used as intended means they got something wrong, and
|
||||
# this is the one place they say where their phone notifications go.
|
||||
require_url() {
|
||||
case "$2" in
|
||||
http://*|https://*) ;;
|
||||
*)
|
||||
echo "$1 in $(remote_conf_path) must be an http:// or https:// URL: $2" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
require_url NTFY_URL "$NTFY_URL"
|
||||
[ -z "$BASE_URL" ] || require_url BASE_URL "$BASE_URL"
|
||||
|
||||
STATE_DIR="$(remote_state_dir)"
|
||||
NOTIFY_DIR="$STATE_DIR/notify"
|
||||
DEBOUNCE_DIR="$STATE_DIR/debounce"
|
||||
DEBOUNCE_SECONDS="${DEJIMA_DEBOUNCE_SECONDS:-60}"
|
||||
case "$DEBOUNCE_SECONDS" in
|
||||
''|*[!0-9]*)
|
||||
echo "DEJIMA_DEBOUNCE_SECONDS must be a whole number of seconds: $DEBOUNCE_SECONDS" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Same check bin/dejima makes before creating this, and for the same reason:
|
||||
# /tmp is the fallback parent when XDG_RUNTIME_DIR is unset, and
|
||||
# `mkdir -p -m 700` only sets the mode of the leaf it creates - a symlink
|
||||
# planted here would send every mkdir and every rm below it into someone
|
||||
# else's tree. Created rather than required, so the unit can start before the
|
||||
# first sandbox has ever run.
|
||||
mkdir -p -m 700 "$STATE_DIR" 2>/dev/null || true
|
||||
if [ -L "$STATE_DIR" ] || [ ! -d "$STATE_DIR" ] \
|
||||
|| [ "$(stat -c %u "$STATE_DIR")" != "$(id -u)" ]; then
|
||||
echo "Refusing to watch: $STATE_DIR is a symlink, or is not a directory owned" >&2
|
||||
echo "by the current user. Remove it, or set XDG_RUNTIME_DIR somewhere else." >&2
|
||||
exit 1
|
||||
fi
|
||||
# -m 700 above applies only when this process is the one that creates it, and
|
||||
# either side may get there first, so the mode is set unconditionally here -
|
||||
# same as bin/dejima:491.
|
||||
chmod 700 "$STATE_DIR"
|
||||
mkdir -p -m 700 "$NOTIFY_DIR" "$DEBOUNCE_DIR"
|
||||
|
||||
# From the registry (host-written), not from anything the session wrote. Still
|
||||
# reduced to a safe charset: the workspace path is operator-chosen, and a
|
||||
# newline in it would split a curl -H header.
|
||||
project_of() {
|
||||
local run_id="$1" file line raw="" name
|
||||
file="$STATE_DIR/sessions/$run_id"
|
||||
# An entry that can't be opened must degrade to an unnamed push, never to
|
||||
# a failure: one scan covers every run directory, so aborting here would
|
||||
# take every other session's notification down with it. `|| raw=""` is
|
||||
# what guarantees that - a failed redirection makes the compound command
|
||||
# return nonzero, and the || consumes it. Errexit is not the mechanism:
|
||||
# the sole caller is push(), which reaches this through a command
|
||||
# substitution, and bash does not inherit errexit into one absent shopt
|
||||
# inherit_errexit. The [ -r ] check is therefore about noise, not control
|
||||
# flow - and not much noise either, since push() runs only for a marker
|
||||
# that was consumed and not debounced: without it, a chmod'd entry prints
|
||||
# "Permission denied" once per push for that session.
|
||||
if [ -r "$file" ]; then
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
case "$line" in
|
||||
workspace=*) raw="${line#*=}"; break ;;
|
||||
esac
|
||||
done < "$file" || raw=""
|
||||
fi
|
||||
name="$(basename -- "$raw" | tr -cd 'A-Za-z0-9._-' | cut -c1-64)"
|
||||
printf '%s' "${name:-unknown}"
|
||||
}
|
||||
|
||||
push() {
|
||||
local run_id="$1" event="$2" project title body
|
||||
local -a click=()
|
||||
project="$(project_of "$run_id")"
|
||||
case "$event" in
|
||||
waiting) title="dejima: $project needs input"; body="Session is waiting for you." ;;
|
||||
done) title="dejima: $project finished"; body="Session has stopped." ;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
# $run_id here came from the DIRECTORY NAME bin/dejima created and was
|
||||
# shape-validated by valid_run_id - never from marker content.
|
||||
if [ -n "$BASE_URL" ]; then
|
||||
click=(-H "Click: ${BASE_URL%/}/?arg=$run_id")
|
||||
fi
|
||||
# stdout is discarded (ntfy answers with a JSON receipt); stderr is not.
|
||||
# -sS exists precisely to keep transport errors on stderr, and under a
|
||||
# systemd user unit that is the journal, which is where they belong.
|
||||
curl -fsS --max-time 10 --retry 2 \
|
||||
-H "Title: $title" \
|
||||
-H "Priority: default" \
|
||||
"${click[@]}" \
|
||||
-d "$body" \
|
||||
-- "$NTFY_URL" >/dev/null \
|
||||
|| echo "dejima-notify-watch: push failed for $run_id/$event" >&2
|
||||
}
|
||||
|
||||
# Persisted rather than in-memory so --once (and a restarted unit) debounce
|
||||
# the same way a long-running process does.
|
||||
debounced() {
|
||||
local key="$1" now="$2" last
|
||||
last="$(cat "$DEBOUNCE_DIR/$key" 2>/dev/null || true)"
|
||||
# A non-numeric value in $(( )) is evaluated as a VARIABLE NAME, and any
|
||||
# command substitution in its subscript runs - "now[$(cmd)]" executes cmd.
|
||||
# set -u does not catch it, because `now` is bound right here. Digits
|
||||
# alone still aren't enough either: a leading zero is read as octal, so
|
||||
# 10# forces base 10 (and independently refuses to lex a name).
|
||||
#
|
||||
# The shape check leads because 10# only turns execution into an
|
||||
# arithmetic *error*, and a bash arithmetic error unwinds to the top
|
||||
# level: it discards the rest of the enclosing top-level command, so the
|
||||
# `exit 0` after a --once scan never runs and control falls through into
|
||||
# the watch loop below. Refusing the value keeps that unwind unreachable.
|
||||
#
|
||||
# Junk reads as "not sent recently" rather than as "sent": failing the
|
||||
# other way would let one unparseable file silence a session for good.
|
||||
case "$last" in
|
||||
''|*[!0-9]*) return 1 ;;
|
||||
esac
|
||||
[ "$(( now - 10#$last ))" -lt "$DEBOUNCE_SECONDS" ]
|
||||
}
|
||||
|
||||
declare -A WARNED=()
|
||||
|
||||
scan() {
|
||||
local dir run_id event marker now
|
||||
now="$(date +%s)"
|
||||
for dir in "$NOTIFY_DIR"/*; do
|
||||
[ -d "$dir" ] || continue
|
||||
run_id="$(basename -- "$dir")"
|
||||
# Only directories bin/dejima could have created. Anything else is
|
||||
# skipped without being looked into at all.
|
||||
valid_run_id "$run_id" || continue
|
||||
# The enum is iterated, not discovered: a name that isn't one of these
|
||||
# is never stat'd, never read, never removed.
|
||||
for event in waiting done; do
|
||||
marker="$dir/$event"
|
||||
# -e follows the link and is FALSE for a dangling symlink, which a
|
||||
# session can plant as easily as a file; -L catches those so they
|
||||
# are cleaned up too, without their target being created.
|
||||
[ -e "$marker" ] || [ -L "$marker" ] || continue
|
||||
# Consumed before it is acted on, and non-fatally: the marker
|
||||
# directory is bind-mounted read-write, so a session can make its
|
||||
# own marker undeletable (a non-empty directory named `waiting` is
|
||||
# enough), and dying here would silence every OTHER session too.
|
||||
# Skipped rather than pushed anyway, because a marker that
|
||||
# survives would otherwise re-push on every scan from now on.
|
||||
if ! rm -f "$marker"; then
|
||||
# Once per marker per process, not once per scan: the marker
|
||||
# survives, so every later scan finds it again, and a session
|
||||
# that leaves one in place could otherwise fill the journal
|
||||
# for as long as the watcher runs. Permanently, deliberately -
|
||||
# a marker that becomes deletable and then undeletable again
|
||||
# is the same stuck session, not news. The key cannot be
|
||||
# inflated to grow this map: scan() iterates only the top
|
||||
# level of $NOTIFY_DIR against a two-name enum, and a session
|
||||
# can create directories only inside its own run directory,
|
||||
# so it is bounded at twice the number of run directories.
|
||||
if [ -z "${WARNED[$run_id/$event]:-}" ]; then
|
||||
echo "dejima-notify-watch: warning: could not consume marker $run_id/$event" >&2
|
||||
WARNED[$run_id/$event]=1
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
if debounced "$run_id-$event" "$now"; then
|
||||
continue
|
||||
fi
|
||||
printf '%s' "$now" > "$DEBOUNCE_DIR/$run_id-$event"
|
||||
push "$run_id" "$event"
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
ONCE=""
|
||||
case "${1:-}" in
|
||||
--once) ONCE=1 ;;
|
||||
"") ;;
|
||||
*)
|
||||
echo "usage: ${0##*/} [--once]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -n "$ONCE" ]; then
|
||||
scan
|
||||
fi
|
||||
# A separate top-level command from the scan above, deliberately: a bash
|
||||
# arithmetic error unwinds to the top level and discards the REST of the
|
||||
# enclosing top-level command, so a `scan; exit 0` pair sharing one `if` can
|
||||
# lose its exit and run the watch loop instead of returning. Nothing should
|
||||
# reach such an error (debounced() refuses a non-numeric stamp before the
|
||||
# arithmetic), but a --once run that never exits is a bad way to find out.
|
||||
[ -z "$ONCE" ] || exit 0
|
||||
|
||||
# inotify-tools is optional: without it this degrades to a ~6s poll rather
|
||||
# than failing - the 1s floor below plus the 5s fallback sleep, which is the
|
||||
# interval a scan actually repeats at. The -t 30 timeout also closes the race
|
||||
# where a marker lands in a run directory created after the current watch was
|
||||
# established.
|
||||
while :; do
|
||||
scan
|
||||
# A floor under the loop, unconditional and ahead of the wait: inotify
|
||||
# wakes on the sandbox's OWN file creations, so without it a session that
|
||||
# creates files in a loop drives one scan per creation for as long as it
|
||||
# likes. One scan a second is far more than the notifications need.
|
||||
sleep 1
|
||||
if command -v inotifywait >/dev/null 2>&1; then
|
||||
# A failure here is reachable from inside a sandbox rather than rare:
|
||||
# -r takes one inotify watch per directory below $NOTIFY_DIR, and a
|
||||
# session owns its own marker directory, so it can mkdir enough of
|
||||
# them to exhaust this uid's max_user_watches - after which every
|
||||
# inotifywait returns immediately. Falling back to the poll interval
|
||||
# costs a little latency and bounds it; `|| true` would spin.
|
||||
inotifywait -qq -r -t 30 -e create -e moved_to "$NOTIFY_DIR" >/dev/null 2>&1 \
|
||||
|| sleep 5
|
||||
else
|
||||
sleep 5
|
||||
fi
|
||||
done
|
||||
@@ -242,3 +242,104 @@ generate_derived_dockerfile() {
|
||||
printf 'ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]\n'
|
||||
return 0
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Remote access (opt-in). See docs/remote-access.md.
|
||||
#
|
||||
# The mere existence of remote.conf is the switch: with it absent, every code
|
||||
# path this feature adds is skipped and bin/dejima builds exactly the
|
||||
# invocation it built before the feature existed. Same "unset means skipped
|
||||
# entirely" shape LOKI_URL already has.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
remote_conf_path() {
|
||||
printf '%s/remote.conf' "$(config_root)"
|
||||
}
|
||||
|
||||
remote_enabled() {
|
||||
[ -f "$(remote_conf_path)" ]
|
||||
}
|
||||
|
||||
valid_remote_key() {
|
||||
[[ "$1" =~ ^(NTFY_URL|BASE_URL|TTYD_PORT|TTYD_FONT_SIZE)$ ]]
|
||||
}
|
||||
|
||||
# Deliberately narrow. These values end up in a curl -H header, an nginx
|
||||
# comparison, and a URL handed to a phone - anything shell-active, any
|
||||
# whitespace, and anything that could split a header must not survive.
|
||||
valid_remote_value() {
|
||||
[[ "$1" =~ ^[A-Za-z0-9:/._~?=%@+-]*$ ]]
|
||||
}
|
||||
|
||||
# A run ID as bin/dejima generates it. Shared with dejima-attach and
|
||||
# dejima-notify-watch, both of which take one from an untrusted direction
|
||||
# (ttyd's --url-arg; a directory name under the marker root).
|
||||
valid_run_id() {
|
||||
[[ "$1" =~ ^[0-9a-f]{16}$ ]]
|
||||
}
|
||||
|
||||
remote_state_dir() {
|
||||
printf '%s/dejima' "${XDG_RUNTIME_DIR:-/tmp}"
|
||||
}
|
||||
|
||||
# remote_conf_get <key>
|
||||
# Prints the value, or nothing when the file doesn't set it. Validates the
|
||||
# WHOLE file on every read, not just the requested key: a remote.conf that
|
||||
# doesn't parse means the operator got something wrong, and this feature's
|
||||
# security properties depend on its values - so refuse rather than proceed,
|
||||
# the same way approved_entries does for the approval store.
|
||||
remote_conf_get() {
|
||||
local want="$1" conf line key value found=""
|
||||
valid_remote_key "$want" || { echo "unknown remote.conf key: $want" >&2; return 1; }
|
||||
conf="$(remote_conf_path)"
|
||||
[ -f "$conf" ] || return 0
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
line="${line#"${line%%[![:space:]]*}"}"
|
||||
line="${line%"${line##*[![:space:]]}"}"
|
||||
case "$line" in
|
||||
''|'#'*) continue ;;
|
||||
*=*) ;;
|
||||
*) echo "malformed line in $conf: $line" >&2; return 1 ;;
|
||||
esac
|
||||
key="${line%%=*}"
|
||||
value="${line#*=}"
|
||||
if ! valid_remote_key "$key"; then
|
||||
echo "unknown key in $conf: $key" >&2
|
||||
return 1
|
||||
fi
|
||||
if ! valid_remote_value "$value"; then
|
||||
echo "invalid value for $key in $conf: $value" >&2
|
||||
return 1
|
||||
fi
|
||||
[ "$key" = "$want" ] && found="$value"
|
||||
done < "$conf"
|
||||
printf '%s' "$found"
|
||||
}
|
||||
|
||||
remote_ttyd_port() {
|
||||
local port
|
||||
port="$(remote_conf_get TTYD_PORT)" || return 1
|
||||
port="${port:-7681}"
|
||||
if [[ ! "$port" =~ ^[0-9]+$ ]] || [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then
|
||||
echo "TTYD_PORT out of range in $(remote_conf_path): $port" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s' "$port"
|
||||
}
|
||||
|
||||
# Empty when unset, which is the signal to pass no -t at all and leave ttyd on
|
||||
# its own default. Bounded rather than merely numeric: this reaches ttyd as
|
||||
# argv, and a value large enough to make one glyph wider than the viewport
|
||||
# leaves a terminal that can render nothing at all - reported as "the front
|
||||
# door is broken", with the cause three files away. 8 is already small on a
|
||||
# phone; 72 is already absurd on a desktop.
|
||||
remote_ttyd_font_size() {
|
||||
local size
|
||||
size="$(remote_conf_get TTYD_FONT_SIZE)" || return 1
|
||||
[ -n "$size" ] || return 0
|
||||
if [[ ! "$size" =~ ^[0-9]+$ ]] || [ "$size" -lt 8 ] || [ "$size" -gt 72 ]; then
|
||||
echo "TTYD_FONT_SIZE must be 8-72 in $(remote_conf_path): $size" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s' "$size"
|
||||
}
|
||||
|
||||
Executable
+121
@@ -0,0 +1,121 @@
|
||||
#!/bin/bash
|
||||
# Launches ttyd with dejima-attach as its command, reading TTYD_PORT from
|
||||
# remote.conf so the port has exactly one source of truth (the systemd unit
|
||||
# deliberately does not repeat it). Started by systemd/dejima-ttyd.service.
|
||||
# See docs/remote-access.md.
|
||||
#
|
||||
# --interface 127.0.0.1 is a security requirement, not a preference.
|
||||
# docker/init-firewall.sh:187 accepts container OUTPUT to the default-route
|
||||
# address - the Docker bridge gateway, i.e. the host - and attaching tmux
|
||||
# gives a HOST shell (the prefix key opens a new window outside the
|
||||
# container). A listener on any address the container can route to would hand
|
||||
# a compromised session its own escape hatch. Loopback is not one of those
|
||||
# addresses: the container has no route to the host's 127.0.0.1. Put nginx in
|
||||
# front, bound to one specific LAN address - see
|
||||
# docs/nginx-dejima.conf.example.
|
||||
#
|
||||
# ttyd is given no credential of its own, so the reverse proxy is the only
|
||||
# thing authenticating a request, and the loopback bind is what makes that
|
||||
# safe rather than careless: nothing but a process on this host can open the
|
||||
# socket at all. --writable is what makes the terminal a terminal rather than
|
||||
# a viewer.
|
||||
#
|
||||
# --check-origin is the other half of that, and it is not optional. Without
|
||||
# it ttyd does no origin validation at all (it only calls check_host_origin
|
||||
# when the flag is set), and browsers do not apply the same-origin policy to
|
||||
# WebSockets: any page the operator's browser loads could open
|
||||
# wss://<front-door>/ws, have the browser attach its cached credential for
|
||||
# that origin, and be handed this host shell. Proxy authentication cannot
|
||||
# stop that - it is satisfied by the victim's own browser. ttyd compares the
|
||||
# Origin authority against the Host header, which is why the nginx example
|
||||
# passes Host through unchanged; the two go together.
|
||||
#
|
||||
# --url-arg lets the client pass ?arg=<run-id>, which is what the notification
|
||||
# deep link uses. It reaches dejima-attach as argv, never as a shell word, and
|
||||
# anything there that is not --list or the empty string (a bare `?arg=`, which
|
||||
# lands on the picker) has to match the run-id shape before it is used as a
|
||||
# path component or anything else.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||
# shellcheck source=dejima-project-lib.sh
|
||||
source "$SCRIPT_DIR/dejima-project-lib.sh"
|
||||
|
||||
if ! remote_enabled; then
|
||||
echo "Remote access is not configured ($(remote_conf_path) does not exist)." >&2
|
||||
echo "See README \"Remote access\"." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Read BEFORE the ttyd check below, deliberately: an operator with both a
|
||||
# broken remote.conf and no ttyd is otherwise told only about the package,
|
||||
# installs it, comes back to the identical failure, and never learns that
|
||||
# their config is the other half of it. remote_ttyd_port validates the whole
|
||||
# file and names the line that broke it, and a failure here is fatal under
|
||||
# set -e on the assignment.
|
||||
port="$(remote_ttyd_port)"
|
||||
|
||||
# Optional, and applied to EVERY client - ttyd 1.7.7 takes client options only
|
||||
# at launch (the URL-query override that would allow a per-client size landed
|
||||
# after that release), so there is no way to give a phone one size and a
|
||||
# desktop another from here. Left unset, ttyd keeps its own default.
|
||||
#
|
||||
# It is the smaller half of the mobile fix, not the fix: ttyd's page carries no
|
||||
# viewport meta, so a phone lays it out at a virtual desktop width and scales
|
||||
# the result down - a bigger font scales down with everything else. The nginx
|
||||
# example injects the meta; see docs/nginx-dejima.conf.example.
|
||||
font_size="$(remote_ttyd_font_size)"
|
||||
|
||||
# Checked rather than left to exec, whose failure is bash's "ttyd: not found"
|
||||
# on a line number - true, and useless to an operator who needs to know that
|
||||
# the missing piece is a package.
|
||||
if ! command -v ttyd >/dev/null 2>&1; then
|
||||
echo "ttyd is not installed - the web front door needs it, 1.7.4 or newer" >&2
|
||||
echo "(-W/--writable arrived in 1.7.4; older builds are writable by default" >&2
|
||||
echo "and reject the flag, so every start would be a usage error)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The distro's own ttyd package ships an ENABLED system service that runs
|
||||
# `login` and, having no --interface of its own, binds every interface - the
|
||||
# bridge address a sandbox can route to included. Installing ttyd to set this
|
||||
# feature up is therefore enough to open a host login prompt to the sandbox,
|
||||
# on this very port, before any of this runs.
|
||||
#
|
||||
# It also takes the port. Binding the loopback address below while another
|
||||
# process already holds that port on every interface fails with EADDRINUSE, so
|
||||
# this unit dies at every start while the reverse proxy in front of it happily
|
||||
# proxies to whatever DID get the port. The operator sees the password prompt
|
||||
# they configured and then a host login shell, and nothing in that path looks
|
||||
# broken.
|
||||
#
|
||||
# So refuse, and say which. Left to exec, the whole failure is ttyd's one-line
|
||||
# bind error in a unit nobody is watching.
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
listeners="$(ss -ltnH "sport = :$port" 2>/dev/null || true)"
|
||||
if [ -n "$listeners" ]; then
|
||||
echo "Something is already listening on port $port:" >&2
|
||||
echo "$listeners" >&2
|
||||
echo >&2
|
||||
echo "The web front door cannot start, and until that listener is gone the" >&2
|
||||
echo "reverse proxy in front of this port serves IT, not dejima." >&2
|
||||
echo >&2
|
||||
echo "The usual cause is the distro's own ttyd service, which is enabled on" >&2
|
||||
echo "install and runs 'login' on every interface - reachable from inside a" >&2
|
||||
echo "sandbox. Check and disable it:" >&2
|
||||
echo >&2
|
||||
echo " systemctl status ttyd.service" >&2
|
||||
echo " sudo systemctl disable --now ttyd.service" >&2
|
||||
echo " sudo systemctl mask ttyd.service" >&2
|
||||
echo >&2
|
||||
echo "See README \"Remote access\"." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
font_opt=()
|
||||
[ -z "$font_size" ] || font_opt=(-t "fontSize=$font_size")
|
||||
|
||||
exec ttyd --interface 127.0.0.1 --port "$port" --check-origin --url-arg --writable \
|
||||
${font_opt[@]+"${font_opt[@]}"} \
|
||||
"$SCRIPT_DIR/dejima-attach"
|
||||
Executable
+255
@@ -0,0 +1,255 @@
|
||||
#!/bin/bash
|
||||
# Read and write Gitea issues from a shell - the issue-side counterpart to
|
||||
# gitea-pr, and deliberately its twin rather than its sibling: both are
|
||||
# copied into the sandbox image as single self-contained files, and gitea-pr
|
||||
# also lives in a dotfiles checkout, so a shared library would be a third
|
||||
# thing to keep in step across three places. The duplicated config/token/curl
|
||||
# preamble is pinned in both files by test/static.bats instead.
|
||||
set -euo pipefail
|
||||
|
||||
GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.config/gitea/token}"
|
||||
|
||||
function usage() {
|
||||
echo "Usage: gitea-issue list [--state open|closed|all] [--limit N]"
|
||||
echo " gitea-issue view NUMBER"
|
||||
echo " gitea-issue create --title TITLE [--body BODY | --body-file FILE]"
|
||||
echo " gitea-issue comment NUMBER (--body BODY | --body-file FILE)"
|
||||
echo " gitea-issue close NUMBER"
|
||||
echo " gitea-issue reopen NUMBER"
|
||||
echo ""
|
||||
echo "--body-file reads the body from FILE, or from stdin when FILE is '-'."
|
||||
echo "Prefer it over --body for anything with quotes, backticks or newlines."
|
||||
echo ""
|
||||
echo "Requires GITEA_SSH_HOST and GITEA_API set in the environment (e.g. in" \
|
||||
"your shell profile), and a token at $GITEA_TOKEN_FILE (or GITEA_TOKEN" \
|
||||
"set for this invocation only)."
|
||||
}
|
||||
|
||||
function require_config() {
|
||||
if [ -z "${GITEA_SSH_HOST:-}" ]; then
|
||||
echo "GITEA_SSH_HOST is not set. Add 'export GITEA_SSH_HOST=<gitea ssh hostname>'" \
|
||||
"(e.g. gitea.example.com) to ~/.profile.local" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${GITEA_API:-}" ]; then
|
||||
echo "GITEA_API is not set. Add 'export GITEA_API=https://<gitea host>/api/v1'" \
|
||||
"(e.g. https://gitea.example.com/api/v1) to ~/.profile.local" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
function require_token() {
|
||||
if [ -z "${GITEA_TOKEN:-}" ] && [ -f "$GITEA_TOKEN_FILE" ]; then
|
||||
GITEA_TOKEN=$(<"$GITEA_TOKEN_FILE")
|
||||
fi
|
||||
if [ -z "${GITEA_TOKEN:-}" ]; then
|
||||
echo "No Gitea token found. Generate one in the Gitea web UI" \
|
||||
"(Settings > Applications, scopes write:repository + write:issue)" \
|
||||
"and save it to $GITEA_TOKEN_FILE (chmod 600), or set GITEA_TOKEN" \
|
||||
"in the environment for this invocation only." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Feeds the auth header to curl as a config on stdin rather than as an
|
||||
# argument. Command lines are world-readable through /proc, so `-H
|
||||
# "Authorization: token $GITEA_TOKEN"` exposes the token to every other
|
||||
# process on the host for as long as the request runs - and to anything that
|
||||
# scrapes `ps` output, including the agents this repo exists to sandbox.
|
||||
# printf is a bash builtin, so the token never lands in another argv.
|
||||
function gitea_api() {
|
||||
printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" |
|
||||
curl -sS -w '\n%{http_code}' -K - "$@"
|
||||
}
|
||||
|
||||
# Splits gitea_api's combined output into the globals `json` and `http_code`
|
||||
# and fails with the body on any non-2xx. Every call site did this by hand in
|
||||
# gitea-pr; there are six of them here, and six hand-rolled copies is where
|
||||
# one of them quietly stops checking.
|
||||
function api_or_die() {
|
||||
local what="$1"; shift
|
||||
local response
|
||||
response=$(gitea_api "$@")
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
json=$(echo "$response" | sed '$d')
|
||||
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
|
||||
echo "Failed to $what (HTTP $http_code):" >&2
|
||||
echo "$json" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Reads a body from a file, or from stdin for '-'. The point is to keep prose
|
||||
# out of argv: a body composed on a command line has to survive shell quoting,
|
||||
# and the things that break it - an apostrophe, a backtick, a $ - are exactly
|
||||
# what English and code fences are made of. A file (or a heredoc into '-') has
|
||||
# no such layer.
|
||||
function read_body_file() {
|
||||
local file="$1"
|
||||
if [ "$file" = "-" ]; then
|
||||
cat
|
||||
return
|
||||
fi
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "No such body file: $file" >&2
|
||||
exit 1
|
||||
fi
|
||||
cat "$file"
|
||||
}
|
||||
|
||||
function gitea_owner_repo() {
|
||||
local remote_url host owner_repo
|
||||
remote_url=$(git remote get-url origin 2>/dev/null) || {
|
||||
echo "No 'origin' remote found in this repository" >&2
|
||||
exit 1
|
||||
}
|
||||
host=$(echo "$remote_url" | sed -E 's#^[^@]+@([^:]+):.*#\1#')
|
||||
if [ "$host" != "$GITEA_SSH_HOST" ]; then
|
||||
echo "origin remote ($remote_url) is not on $GITEA_SSH_HOST; gitea-issue only supports this host" >&2
|
||||
exit 1
|
||||
fi
|
||||
owner_repo=$(echo "$remote_url" | sed -E 's#^[^@]+@[^:]+:##; s#\.git$##')
|
||||
echo "$owner_repo"
|
||||
}
|
||||
|
||||
function require_number() {
|
||||
if ! [[ "${1:-}" =~ ^[0-9]+$ ]]; then
|
||||
echo "Expected an issue number, got '${1:-}'" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Sets `body` from --body/--body-file, rejecting both. Shared by create and
|
||||
# comment so the two cannot drift into disagreeing about which one wins.
|
||||
function parse_body_args() {
|
||||
body=""
|
||||
local body_file=""
|
||||
parsed_title=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--title) parsed_title="$2"; shift 2 ;;
|
||||
--body) body="$2"; shift 2 ;;
|
||||
--body-file) body_file="$2"; shift 2 ;;
|
||||
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
# Rejected rather than ranked: silently preferring one would make a caller
|
||||
# that passed both ship a body it didn't mean to.
|
||||
if [ -n "$body" ] && [ -n "$body_file" ]; then
|
||||
echo "Pass --body or --body-file, not both" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$body_file" ]; then
|
||||
body="$(read_body_file "$body_file")"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$#" -lt 1 ]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
subcommand="$1"
|
||||
shift
|
||||
|
||||
case "$subcommand" in
|
||||
list|view|create|comment|close|reopen)
|
||||
require_config
|
||||
require_token
|
||||
owner_repo=$(gitea_owner_repo)
|
||||
owner="${owner_repo%%/*}"
|
||||
repo="${owner_repo##*/}"
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
issues_api="${GITEA_API}/repos/${owner}/${repo}/issues"
|
||||
|
||||
case "$subcommand" in
|
||||
list)
|
||||
state="open"
|
||||
limit="30"
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--state) state="$2"; shift 2 ;;
|
||||
--limit) limit="$2"; shift 2 ;;
|
||||
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
case "$state" in
|
||||
open|closed|all) ;;
|
||||
*) echo "--state must be open, closed or all" >&2; exit 1 ;;
|
||||
esac
|
||||
if ! [[ "$limit" =~ ^[0-9]+$ ]]; then
|
||||
echo "--limit must be a number" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# type=issues is load-bearing: Gitea's issues endpoint returns pull
|
||||
# requests too, since a PR *is* an issue with a branch attached.
|
||||
# Without it every open PR shows up here as an issue, which reads as
|
||||
# a work list that is quietly twice as long as the real one.
|
||||
api_or_die "list issues" \
|
||||
"${issues_api}?state=${state}&type=issues&limit=${limit}"
|
||||
echo "$json" | jq -r '.[] | "#\(.number)\t[\(.state)]\t\(.title)\t(\(.comments) comments)"'
|
||||
;;
|
||||
view)
|
||||
require_number "${1:-}"
|
||||
number="$1"
|
||||
|
||||
api_or_die "fetch issue #${number}" "${issues_api}/${number}"
|
||||
echo "$json" | jq -r '"#\(.number) [\(.state)] \(.title)\n[\(.user.login) @ \(.created_at)]\n\(.body // "(no description)")\n"'
|
||||
|
||||
echo "=== Comments ==="
|
||||
api_or_die "fetch comments for issue #${number}" "${issues_api}/${number}/comments"
|
||||
echo "$json" | jq -r '.[] | "[\(.user.login) @ \(.created_at)]\n\(.body)\n"'
|
||||
;;
|
||||
create)
|
||||
parse_body_args "$@"
|
||||
if [ -z "$parsed_title" ]; then
|
||||
echo "gitea-issue create requires --title" >&2
|
||||
exit 1
|
||||
fi
|
||||
body="**Filed by LLM agent**
|
||||
|
||||
${body}"
|
||||
|
||||
api_or_die "create issue" -X POST "$issues_api" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg title "$parsed_title" --arg body "$body" \
|
||||
'{title: $title, body: $body}')"
|
||||
echo "$json" | jq -r '.html_url'
|
||||
;;
|
||||
comment)
|
||||
require_number "${1:-}"
|
||||
number="$1"
|
||||
shift
|
||||
parse_body_args "$@"
|
||||
if [ -z "$body" ]; then
|
||||
echo "gitea-issue comment requires --body or --body-file" >&2
|
||||
exit 1
|
||||
fi
|
||||
body="**Comment by LLM agent**
|
||||
|
||||
${body}"
|
||||
|
||||
api_or_die "comment on issue #${number}" -X POST "${issues_api}/${number}/comments" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg body "$body" '{body: $body}')"
|
||||
echo "$json" | jq -r '.html_url'
|
||||
;;
|
||||
close|reopen)
|
||||
require_number "${1:-}"
|
||||
number="$1"
|
||||
new_state="closed"
|
||||
[ "$subcommand" = "reopen" ] && new_state="open"
|
||||
|
||||
api_or_die "${subcommand} issue #${number}" -X PATCH "${issues_api}/${number}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg state "$new_state" '{state: $state}')"
|
||||
echo "$json" | jq -r '"#\(.number) is now \(.state): \(.html_url)"'
|
||||
;;
|
||||
esac
|
||||
+35
-3
@@ -4,10 +4,13 @@ set -euo pipefail
|
||||
GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.config/gitea/token}"
|
||||
|
||||
function usage() {
|
||||
echo "Usage: gitea-pr create [--title TITLE] [--body BODY] [--base BRANCH]"
|
||||
echo "Usage: gitea-pr create [--title TITLE] [--body BODY | --body-file FILE] [--base BRANCH]"
|
||||
echo " gitea-pr comments PR_NUMBER"
|
||||
echo " gitea-pr merge PR_NUMBER"
|
||||
echo ""
|
||||
echo "--body-file reads the body from FILE, or from stdin when FILE is '-'."
|
||||
echo "Prefer it over --body for anything with quotes, backticks or newlines."
|
||||
echo ""
|
||||
echo "Requires GITEA_SSH_HOST and GITEA_API set in the environment (e.g. in" \
|
||||
"your shell profile), and a token at $GITEA_TOKEN_FILE (or GITEA_TOKEN" \
|
||||
"set for this invocation only)."
|
||||
@@ -16,12 +19,12 @@ function usage() {
|
||||
function require_config() {
|
||||
if [ -z "${GITEA_SSH_HOST:-}" ]; then
|
||||
echo "GITEA_SSH_HOST is not set. Add 'export GITEA_SSH_HOST=<gitea ssh hostname>'" \
|
||||
"(e.g. gitbox.bradina.localdomain) to ~/.profile.local" >&2
|
||||
"(e.g. gitea.example.com) to ~/.profile.local" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${GITEA_API:-}" ]; then
|
||||
echo "GITEA_API is not set. Add 'export GITEA_API=https://<gitea host>/api/v1'" \
|
||||
"(e.g. https://gitbox.branderson.io/api/v1) to ~/.profile.local" >&2
|
||||
"(e.g. https://gitea.example.com/api/v1) to ~/.profile.local" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
@@ -50,6 +53,24 @@ function gitea_api() {
|
||||
curl -sS -w '\n%{http_code}' -K - "$@"
|
||||
}
|
||||
|
||||
# Reads a body from a file, or from stdin for '-'. The point is to keep prose
|
||||
# out of argv: a body composed on a command line has to survive shell quoting,
|
||||
# and the things that break it - an apostrophe, a backtick, a $ - are exactly
|
||||
# what English and code fences are made of. A file (or a heredoc into '-') has
|
||||
# no such layer.
|
||||
function read_body_file() {
|
||||
local file="$1"
|
||||
if [ "$file" = "-" ]; then
|
||||
cat
|
||||
return
|
||||
fi
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "No such body file: $file" >&2
|
||||
exit 1
|
||||
fi
|
||||
cat "$file"
|
||||
}
|
||||
|
||||
function gitea_owner_repo() {
|
||||
local remote_url host owner_repo
|
||||
remote_url=$(git remote get-url origin 2>/dev/null) || {
|
||||
@@ -91,15 +112,26 @@ case "$subcommand" in
|
||||
create)
|
||||
title=""
|
||||
body=""
|
||||
body_file=""
|
||||
base=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--title) title="$2"; shift 2 ;;
|
||||
--body) body="$2"; shift 2 ;;
|
||||
--body-file) body_file="$2"; shift 2 ;;
|
||||
--base) base="$2"; shift 2 ;;
|
||||
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
# Rejected rather than ranked: silently preferring one would make a
|
||||
# caller that passed both ship a body it didn't mean to.
|
||||
if [ -n "$body" ] && [ -n "$body_file" ]; then
|
||||
echo "Pass --body or --body-file, not both" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$body_file" ]; then
|
||||
body="$(read_body_file "$body_file")"
|
||||
fi
|
||||
body="**Summary generated by LLM agent**
|
||||
|
||||
${body}"
|
||||
|
||||
+136
-93
@@ -11,8 +11,9 @@ ENV TZ="$TZ"
|
||||
ARG CLAUDE_CODE_VERSION=latest
|
||||
|
||||
# Install basic development tools, iptables/ipset (for the egress firewall),
|
||||
# openssh-client (for git-over-ssh and agent forwarding), and the toolchain
|
||||
# nvim's plugin config (config/nvim, telescope/treesitter/mason) expects.
|
||||
# openssh-client (for git-over-ssh and agent forwarding), and the toolchain a
|
||||
# typical nvim plugin set (telescope/treesitter/mason) expects, since
|
||||
# SANDBOX_NVIM_CONFIG can point a session's editor at one.
|
||||
#
|
||||
# Deliberately NOT installing bubblewrap/socat: those exist to let Claude
|
||||
# Code's own internal per-command sandboxing (~/.claude/settings.json's
|
||||
@@ -59,45 +60,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& ln -s "$(command -v fdfind)" /usr/local/bin/fd
|
||||
|
||||
# cowsay/cowthink/fortune install to /usr/games on Debian, which isn't on
|
||||
# PATH by default - config/nvim's start-screen header (plugins/ui.lua) shells
|
||||
# out to them by bare name. Set via ENV (not a profile.d script) so it's
|
||||
# PATH by default - nvim start-screen plugins commonly shell out to them by
|
||||
# bare name for a dashboard header. Set via ENV (not a profile.d script) so it's
|
||||
# picked up by every process unconditionally, including nvim's non-login
|
||||
# subprocess spawns, which wouldn't source /etc/profile.d.
|
||||
ENV PATH=/usr/games:$PATH
|
||||
|
||||
# Replace the base image's built-in `node` user (uid/gid 1000,
|
||||
# $HOME=/home/node) with one matching the host user who runs
|
||||
# bin/dejima - so absolute paths under $HOME (dotfiles, and
|
||||
# ~/.claude/plugins/installed_plugins.json's own installPath bookkeeping,
|
||||
# which Claude Code resolves as a literal host-absolute string rather than
|
||||
# relative to whatever $HOME happens to be) resolve identically inside and
|
||||
# outside the container, instead of needing a one-off dual-mount for every
|
||||
# individual thing that assumes the real host path. Defaults below
|
||||
# reproduce today's node/1000/1000//home/node exactly, so a build that
|
||||
# never overrides them (bin/dejima always does; the test suite
|
||||
# never does) exercises this same rename path rather than a separate,
|
||||
# untested one.
|
||||
ARG SANDBOX_USER=node
|
||||
ARG SANDBOX_UID=1000
|
||||
ARG SANDBOX_GID=1000
|
||||
ARG SANDBOX_HOME=/home/node
|
||||
RUN userdel -r node 2>/dev/null; \
|
||||
groupdel node 2>/dev/null; \
|
||||
groupadd -g "$SANDBOX_GID" "$SANDBOX_USER" && \
|
||||
useradd -u "$SANDBOX_UID" -g "$SANDBOX_GID" -m -d "$SANDBOX_HOME" -s /bin/bash "$SANDBOX_USER"
|
||||
ENV SANDBOX_USER=$SANDBOX_USER
|
||||
ENV SANDBOX_HOME=$SANDBOX_HOME
|
||||
|
||||
# Ensure the sandbox user has access to /usr/local/share
|
||||
RUN mkdir -p /usr/local/share/npm-global && \
|
||||
chown -R $SANDBOX_USER:$SANDBOX_USER /usr/local/share
|
||||
|
||||
# Persist bash history. The env vars that actually wire history up to this
|
||||
# file are set below via zsh-in-docker's -a flag.
|
||||
RUN mkdir /commandhistory && \
|
||||
touch /commandhistory/.bash_history && \
|
||||
chown -R $SANDBOX_USER /commandhistory
|
||||
|
||||
# Set `DEVCONTAINER` environment variable to help with orientation
|
||||
ENV DEVCONTAINER=true
|
||||
|
||||
@@ -115,6 +83,120 @@ ENV DEJIMA=1
|
||||
# this user, so it doesn't need to own anything itself.
|
||||
RUN useradd -r -s /usr/sbin/nologin ulogd
|
||||
|
||||
# Every fetch from here down retries, and none is single-shot. What matters
|
||||
# isn't the odds of a particular download failing, it's the odds of any of
|
||||
# them failing - and without retries a single blip anywhere throws away the
|
||||
# whole build.
|
||||
#
|
||||
# These steps deliberately sit *above* the SANDBOX_* ARGs, not below. Nothing
|
||||
# between here and `USER` needs the sandbox user to exist yet - they all
|
||||
# install system-wide as root - so keeping them ahead of the ARGs means a
|
||||
# build that overrides one (test/runtime.bats does, and so does any host
|
||||
# whose uid isn't 1000) still reuses every layer down to this point instead
|
||||
# of re-running all of it. That single ordering was most of the cost of the
|
||||
# override rebuild: it was 1169s of a 1379s test run, for a difference that
|
||||
# amounts to one useradd.
|
||||
ARG GIT_DELTA_VERSION=0.18.2
|
||||
RUN ARCH=$(dpkg --print-architecture) && \
|
||||
wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \
|
||||
"https://github.com/dandavison/delta/releases/download/${GIT_DELTA_VERSION}/git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
|
||||
dpkg -i "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
|
||||
rm "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb"
|
||||
|
||||
# gosu lets entrypoint.sh (root) drop to the node user for the actual
|
||||
# session after setting up the firewall, without leaving any setuid/sudo
|
||||
# escalation path available to node afterward. Verified against the
|
||||
# published SHA256SUMS since it runs as root.
|
||||
ARG GOSU_VERSION=1.19
|
||||
RUN ARCH=$(dpkg --print-architecture) && \
|
||||
wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \
|
||||
-O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/${GOSU_VERSION}/gosu-${ARCH}" && \
|
||||
wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \
|
||||
-O /tmp/gosu.sha256sums "https://github.com/tianon/gosu/releases/download/${GOSU_VERSION}/SHA256SUMS" && \
|
||||
grep -E "gosu-${ARCH}\$" /tmp/gosu.sha256sums | sed "s#gosu-${ARCH}#/usr/local/bin/gosu#" | sha256sum -c - && \
|
||||
rm /tmp/gosu.sha256sums && \
|
||||
chmod +x /usr/local/bin/gosu
|
||||
|
||||
# Install the latest stable Neovim release. Debian's packaged version is too
|
||||
# old for this config's plugins (blink.cmp etc. need a recent Neovim), so
|
||||
# fetch the current release from GitHub directly rather than pinning a
|
||||
# version that may go stale.
|
||||
RUN asset_url=$(curl -fsSL --retry 3 --retry-delay 2 --retry-all-errors --max-time 60 \
|
||||
https://api.github.com/repos/neovim/neovim/releases/latest \
|
||||
| jq -r '[.assets[] | select(.name | test("linux.*x86_64.*\\.tar\\.gz$"))][0].browser_download_url') && \
|
||||
wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \
|
||||
-O nvim.tar.gz "$asset_url" && \
|
||||
tar -C /usr/local --strip-components=1 -xzf nvim.tar.gz && \
|
||||
rm nvim.tar.gz
|
||||
|
||||
# Install the latest stable Go toolchain. go.dev's own release JSON gives
|
||||
# both the download URL and a SHA256 per file, verified below since this
|
||||
# runs as root - same pattern as gosu above. `go build`/`go mod download`
|
||||
# need proxy.golang.org and sum.golang.org reachable at runtime - see
|
||||
# fixed-domains.sh.
|
||||
RUN ARCH=$(dpkg --print-architecture) && \
|
||||
release_json=$(curl -fsSL --retry 3 --retry-delay 2 --retry-all-errors --max-time 60 \
|
||||
'https://go.dev/dl/?mode=json') && \
|
||||
filename=$(echo "$release_json" | jq -r --arg arch "$ARCH" \
|
||||
'[.[] | select(.stable == true)][0].files[] | select(.os == "linux" and .arch == $arch and .kind == "archive") | .filename') && \
|
||||
sha256=$(echo "$release_json" | jq -r --arg arch "$ARCH" \
|
||||
'[.[] | select(.stable == true)][0].files[] | select(.os == "linux" and .arch == $arch and .kind == "archive") | .sha256') && \
|
||||
wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \
|
||||
-O go.tar.gz "https://go.dev/dl/${filename}" && \
|
||||
echo "${sha256} go.tar.gz" | sha256sum -c - && \
|
||||
tar -C /usr/local -xzf go.tar.gz && \
|
||||
rm go.tar.gz
|
||||
ENV PATH=$PATH:/usr/local/go/bin
|
||||
|
||||
# System libraries Chromium needs (fonts, libnss, libatk, etc.) for
|
||||
# Playwright, installed here since it needs apt/root - the actual
|
||||
# `playwright` package and browser binary install as node, further down,
|
||||
# so they land in node's own npm prefix/cache correctly.
|
||||
ARG PLAYWRIGHT_VERSION=1.61.1
|
||||
RUN npx -y playwright@${PLAYWRIGHT_VERSION} install-deps chromium
|
||||
|
||||
# Replace the base image's built-in `node` user (uid/gid 1000,
|
||||
# $HOME=/home/node) with one matching the host user who runs
|
||||
# bin/dejima - so absolute paths under $HOME (dotfiles, and
|
||||
# ~/.claude/plugins/installed_plugins.json's own installPath bookkeeping,
|
||||
# which Claude Code resolves as a literal host-absolute string rather than
|
||||
# relative to whatever $HOME happens to be) resolve identically inside and
|
||||
# outside the container, instead of needing a one-off dual-mount for every
|
||||
# individual thing that assumes the real host path. Defaults below
|
||||
# reproduce today's node/1000/1000//home/node exactly, so a build that
|
||||
# never overrides them (bin/dejima always does; the test suite
|
||||
# never does) exercises this same rename path rather than a separate,
|
||||
# untested one.
|
||||
#
|
||||
# As late as it can go: everything an override invalidates is below this
|
||||
# line, so the cheaper this block is to reach, the cheaper an override is.
|
||||
ARG SANDBOX_USER=node
|
||||
ARG SANDBOX_UID=1000
|
||||
ARG SANDBOX_GID=1000
|
||||
ARG SANDBOX_HOME=/home/node
|
||||
RUN userdel -r node 2>/dev/null; \
|
||||
groupdel node 2>/dev/null; \
|
||||
groupadd -g "$SANDBOX_GID" "$SANDBOX_USER" && \
|
||||
useradd -u "$SANDBOX_UID" -g "$SANDBOX_GID" -m -d "$SANDBOX_HOME" -s /bin/bash "$SANDBOX_USER"
|
||||
ENV SANDBOX_USER=$SANDBOX_USER
|
||||
ENV SANDBOX_HOME=$SANDBOX_HOME
|
||||
|
||||
# Ensure the sandbox user has access to /usr/local/share. The recursion is
|
||||
# scoped to npm-global rather than applied to the whole directory, which is
|
||||
# what this used to do: Neovim now unpacks into /usr/local/share/nvim above
|
||||
# this line rather than below it, and recursing from the parent would hand
|
||||
# the sandbox user Neovim's runtime files. /usr/local/share itself is still
|
||||
# chowned, so anything that expected to create entries in it still can.
|
||||
RUN mkdir -p /usr/local/share/npm-global && \
|
||||
chown $SANDBOX_USER:$SANDBOX_USER /usr/local/share && \
|
||||
chown -R $SANDBOX_USER:$SANDBOX_USER /usr/local/share/npm-global
|
||||
|
||||
# Persist bash history. The env vars that actually wire history up to this
|
||||
# file are set below via zsh-in-docker's -a flag.
|
||||
RUN mkdir /commandhistory && \
|
||||
touch /commandhistory/.bash_history && \
|
||||
chown -R $SANDBOX_USER /commandhistory
|
||||
|
||||
# Create the ssh directory and set permissions. The project directory itself
|
||||
# is bind-mounted at runtime at its real host path (see docker-compose.yml),
|
||||
# not a fixed /workspace, so it can share ~/.claude project/session history
|
||||
@@ -131,58 +213,6 @@ RUN mkdir -p $SANDBOX_HOME/.ssh && \
|
||||
RUN mkdir -p $SANDBOX_HOME/.local/share/nvim $SANDBOX_HOME/.local/state/nvim $SANDBOX_HOME/.cache/nvim && \
|
||||
chown -R $SANDBOX_USER:$SANDBOX_USER $SANDBOX_HOME/.local $SANDBOX_HOME/.cache
|
||||
|
||||
ARG GIT_DELTA_VERSION=0.18.2
|
||||
RUN ARCH=$(dpkg --print-architecture) && \
|
||||
wget "https://github.com/dandavison/delta/releases/download/${GIT_DELTA_VERSION}/git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
|
||||
dpkg -i "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
|
||||
rm "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb"
|
||||
|
||||
# gosu lets entrypoint.sh (root) drop to the node user for the actual
|
||||
# session after setting up the firewall, without leaving any setuid/sudo
|
||||
# escalation path available to node afterward. Verified against the
|
||||
# published SHA256SUMS since it runs as root.
|
||||
ARG GOSU_VERSION=1.19
|
||||
RUN ARCH=$(dpkg --print-architecture) && \
|
||||
wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/${GOSU_VERSION}/gosu-${ARCH}" && \
|
||||
wget -O /tmp/gosu.sha256sums "https://github.com/tianon/gosu/releases/download/${GOSU_VERSION}/SHA256SUMS" && \
|
||||
grep -E "gosu-${ARCH}\$" /tmp/gosu.sha256sums | sed "s#gosu-${ARCH}#/usr/local/bin/gosu#" | sha256sum -c - && \
|
||||
rm /tmp/gosu.sha256sums && \
|
||||
chmod +x /usr/local/bin/gosu
|
||||
|
||||
# Install the latest stable Neovim release. Debian's packaged version is too
|
||||
# old for this config's plugins (blink.cmp etc. need a recent Neovim), so
|
||||
# fetch the current release from GitHub directly rather than pinning a
|
||||
# version that may go stale.
|
||||
RUN asset_url=$(curl -fsSL https://api.github.com/repos/neovim/neovim/releases/latest \
|
||||
| jq -r '[.assets[] | select(.name | test("linux.*x86_64.*\\.tar\\.gz$"))][0].browser_download_url') && \
|
||||
wget -O nvim.tar.gz "$asset_url" && \
|
||||
tar -C /usr/local --strip-components=1 -xzf nvim.tar.gz && \
|
||||
rm nvim.tar.gz
|
||||
|
||||
# Install the latest stable Go toolchain. go.dev's own release JSON gives
|
||||
# both the download URL and a SHA256 per file, verified below since this
|
||||
# runs as root - same pattern as gosu above. `go build`/`go mod download`
|
||||
# need proxy.golang.org and sum.golang.org reachable at runtime - see
|
||||
# fixed-domains.sh.
|
||||
RUN ARCH=$(dpkg --print-architecture) && \
|
||||
release_json=$(curl -fsSL 'https://go.dev/dl/?mode=json') && \
|
||||
filename=$(echo "$release_json" | jq -r --arg arch "$ARCH" \
|
||||
'[.[] | select(.stable == true)][0].files[] | select(.os == "linux" and .arch == $arch and .kind == "archive") | .filename') && \
|
||||
sha256=$(echo "$release_json" | jq -r --arg arch "$ARCH" \
|
||||
'[.[] | select(.stable == true)][0].files[] | select(.os == "linux" and .arch == $arch and .kind == "archive") | .sha256') && \
|
||||
wget -O go.tar.gz "https://go.dev/dl/${filename}" && \
|
||||
echo "${sha256} go.tar.gz" | sha256sum -c - && \
|
||||
tar -C /usr/local -xzf go.tar.gz && \
|
||||
rm go.tar.gz
|
||||
ENV PATH=$PATH:/usr/local/go/bin
|
||||
|
||||
# System libraries Chromium needs (fonts, libnss, libatk, etc.) for
|
||||
# Playwright, installed here since it needs apt/root - the actual
|
||||
# `playwright` package and browser binary install as node, further down,
|
||||
# so they land in node's own npm prefix/cache correctly.
|
||||
ARG PLAYWRIGHT_VERSION=1.61.1
|
||||
RUN npx -y playwright@${PLAYWRIGHT_VERSION} install-deps chromium
|
||||
|
||||
# Set up non-root user
|
||||
USER $SANDBOX_USER
|
||||
|
||||
@@ -205,14 +235,25 @@ ENV VISUAL=nano
|
||||
# Default powerline10k theme, git/fzf oh-my-zsh plugins, fzf's own key
|
||||
# bindings, and wiring HISTFILE to the persisted /commandhistory volume -
|
||||
# see https://github.com/deluan/zsh-in-docker for the installer's own flags.
|
||||
#
|
||||
# Downloaded to a file and then run, rather than upstream's documented
|
||||
# `sh -c "$(wget -O- ...)"`. In that form a failed download substitutes to an
|
||||
# empty string, `sh -c ""` exits 0, and the build goes green having installed
|
||||
# no zsh config at all - the failure only surfaces much later, as a shell that
|
||||
# is mysteriously unconfigured. Two steps make the download's exit status the
|
||||
# build's exit status.
|
||||
ARG ZSH_IN_DOCKER_VERSION=1.2.0
|
||||
RUN sh -c "$(wget -O- https://github.com/deluan/zsh-in-docker/releases/download/v${ZSH_IN_DOCKER_VERSION}/zsh-in-docker.sh)" -- \
|
||||
RUN wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \
|
||||
-O /tmp/zsh-in-docker.sh \
|
||||
"https://github.com/deluan/zsh-in-docker/releases/download/v${ZSH_IN_DOCKER_VERSION}/zsh-in-docker.sh" && \
|
||||
sh /tmp/zsh-in-docker.sh \
|
||||
-p fzf \
|
||||
-p git \
|
||||
-a "export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \
|
||||
-a "source /usr/share/doc/fzf/examples/key-bindings.zsh" \
|
||||
-a "source /usr/share/doc/fzf/examples/completion.zsh" \
|
||||
-x
|
||||
-x && \
|
||||
rm /tmp/zsh-in-docker.sh
|
||||
|
||||
# Install Claude Code
|
||||
RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}
|
||||
@@ -244,7 +285,8 @@ RUN npm install -g playwright@${PLAYWRIGHT_VERSION} && \
|
||||
npx playwright install chromium
|
||||
|
||||
# Copy and set up firewall + DNS-proxy + entrypoint scripts, the gitea-pr
|
||||
# helper from this repo's bin/ directory, and the claude wrapper that pins
|
||||
# and gitea-issue helpers from this repo's bin/ directory, and the claude
|
||||
# wrapper that pins
|
||||
# --dangerously-skip-permissions. The launched application itself
|
||||
# (SANDBOX_CMD, default `claude`) and any editor config for it are supplied
|
||||
# at runtime, not baked into the image - see docker-compose.yml/entrypoint.sh.
|
||||
@@ -259,9 +301,10 @@ COPY docker/entrypoint.sh /usr/local/bin/
|
||||
COPY docker/anthropic-proxy-forwarder.js /usr/local/bin/anthropic-proxy-forwarder.js
|
||||
COPY docker/claude-wrapper.sh /usr/local/bin/claude
|
||||
COPY bin/gitea-pr /usr/local/bin/gitea-pr
|
||||
COPY bin/gitea-issue /usr/local/bin/gitea-issue
|
||||
|
||||
USER root
|
||||
RUN chmod +x /usr/local/bin/dns-proxy.sh /usr/local/bin/init-firewall.sh /usr/local/bin/log-shipper.sh /usr/local/bin/entrypoint.sh /usr/local/bin/gitea-pr /usr/local/bin/claude && \
|
||||
RUN chmod +x /usr/local/bin/dns-proxy.sh /usr/local/bin/init-firewall.sh /usr/local/bin/log-shipper.sh /usr/local/bin/entrypoint.sh /usr/local/bin/gitea-pr /usr/local/bin/gitea-issue /usr/local/bin/claude && \
|
||||
echo 'export PATH="$PATH:/usr/local/share/npm-global/bin"' > /etc/profile.d/npm-global-path.sh
|
||||
|
||||
# Entrypoint runs as root (no USER node here) so it can set up the firewall
|
||||
|
||||
@@ -62,7 +62,7 @@ services:
|
||||
# Opt-in escape hatch from the default hard GitHub-push block - see
|
||||
# init-firewall.sh and git-push-guard.sh. Off unless explicitly set.
|
||||
ALLOW_GITHUB_PUSH: ${ALLOW_GITHUB_PUSH:-0}
|
||||
# Base URL of a Loki push endpoint (e.g. http://10.0.30.11:3100) to
|
||||
# Base URL of a Loki push endpoint (e.g. http://192.0.2.10:3100) to
|
||||
# ship DNS-query and firewall allow/reject logs to. Its host is
|
||||
# automatically firewall-allowlisted (see entrypoint.sh). Optional -
|
||||
# logging is skipped entirely if unset.
|
||||
@@ -78,7 +78,7 @@ services:
|
||||
# "xterm", dropping 256-color and any extended-keys terminfo bits),
|
||||
# not whatever the host's actual terminal/tmux session is using.
|
||||
# Forward the real value so nvim negotiates key sequences (e.g. the
|
||||
# Ctrl-H/Backspace disambiguation config/tmux.conf sets up) against
|
||||
# Ctrl-H/Backspace disambiguation a tmux config may set up) against
|
||||
# the terminfo entry the rest of the chain actually expects.
|
||||
TERM: ${TERM:-xterm-256color}
|
||||
# Host-side credential proxy (see bin/dejima and README
|
||||
@@ -152,13 +152,6 @@ services:
|
||||
# session's changes (e.g. a malicious mcpServers entry) from ever
|
||||
# reaching the real file.
|
||||
- ${SANDBOX_CLAUDE_JSON_FILE:?set by bin/dejima}:${HOME}/.claude.json
|
||||
# ~/.claude/CLAUDE.md (mounted above) tells agents to write specs to
|
||||
# ~/specs/ rather than a project-local path - that instruction is
|
||||
# already in effect inside the container (CLAUDE.md carries over), so
|
||||
# the directory it points at needs to actually exist and be writable
|
||||
# here too, or an agent following it just writes into an ephemeral
|
||||
# path inside the container that vanishes with the session.
|
||||
- ${HOME}/specs:${HOME}/specs
|
||||
# Global git identity (user.name/user.email) - a throwaway copy of it,
|
||||
# in its own directory rather than a single file bind-mounted at
|
||||
# ~/.gitconfig: entrypoint.sh's `gh auth setup-git` writes a
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,671 @@
|
||||
# Remote access
|
||||
|
||||
[← dejima README](../README.md)
|
||||
|
||||
**Opt-in, and entirely off until you create one file.** Nothing here runs and
|
||||
no front door exists unless `~/.config/dejima/remote.conf` is present. With
|
||||
it absent, `dejima` builds exactly the `docker compose` invocation it
|
||||
built before this feature existed, no session state is written, no
|
||||
notification hook is registered, and `dejima-attach`, `dejima-ttyd` and
|
||||
`dejima-notify-watch` each refuse to run at all. `install.sh` symlinks those
|
||||
three onto `PATH` and stops there - it runs no `systemctl`, copies no unit,
|
||||
and starts nothing.
|
||||
|
||||
What this is: a way to get back to a session that is *already running* on
|
||||
your machine, from a phone on the same network, when it stops to ask you
|
||||
something. What it is not: a way to start a session, a hosted service, or
|
||||
anything that should face the internet.
|
||||
|
||||
**Both front doors hand out a host shell.** `dejima-attach` attaches a tmux
|
||||
client on your own tmux server, and a tmux client is one prefix key away
|
||||
from a new window that has nothing to do with any sandbox. Everything below
|
||||
follows from that single fact - it is why the listener is on loopback, why
|
||||
the proxy needs authentication, and why the bind address is the one step to
|
||||
get right.
|
||||
|
||||
## `remote.conf`
|
||||
|
||||
Flat `KEY=value` lines, `#` comments and blank lines ignored, at
|
||||
`~/.config/dejima/remote.conf` (`$DEJIMA_CONFIG_DIR` relocates the whole
|
||||
config tree, same as it does for approvals):
|
||||
|
||||
| Key | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `NTFY_URL` | unset | ntfy topic to push notifications to. `dejima-notify-watch` refuses to start without it; nothing else reads it |
|
||||
| `BASE_URL` | unset | public base URL of the web front door, used to build the notification's deep link. Absent, pushes still go out - just with no link on them |
|
||||
| `TTYD_PORT` | `7681` | loopback port `ttyd` listens on. The reverse proxy has to `proxy_pass` to the same one |
|
||||
| `TTYD_FONT_SIZE` | unset | terminal font size in px, 8-72. Applies to *every* client, phone and desktop alike - see "Reading it on a phone". Unset passes no option at all and leaves `ttyd` on its own default |
|
||||
|
||||
`NTFY_URL` and `BASE_URL` must carry an explicit `http://` or `https://`
|
||||
scheme; a value without one is refused rather than guessed at, because
|
||||
`curl` reads a leading `-K/path` as an *option* that takes the request's URL
|
||||
from that file instead. Values are restricted to
|
||||
`A-Za-z0-9:/._~?=%@+-` — no whitespace and nothing shell-active, since these
|
||||
end up in an HTTP header and in a URL handed to a phone.
|
||||
|
||||
**A malformed file is a hard failure, not a warning.** The whole file is
|
||||
validated on every read, not just the key being asked for: an unknown key, a
|
||||
value outside that character set, or a line without an `=` makes
|
||||
`dejima-ttyd` and `dejima-notify-watch` refuse to start and say which line
|
||||
was wrong. A `TTYD_PORT` that isn't a number in 1-65535 is refused the same
|
||||
way, as is a `TTYD_FONT_SIZE` outside 8-72 - a size big enough to make one
|
||||
glyph wider than the viewport renders a terminal that shows nothing, which
|
||||
reads as a broken front door rather than as a typo three files away.
|
||||
|
||||
Those are the two scripts that read a *value* out of the file.
|
||||
`dejima-attach` and `dejima` do not - to them the file is a switch, and
|
||||
all they ask is whether it exists - so neither one parses it and neither
|
||||
refuses on a bad line. That is deliberate rather than an oversight: a
|
||||
malformed line cannot change what they do, and making the front door refuse
|
||||
to open over a typo would take away the terminal you would fix it from.
|
||||
|
||||
```
|
||||
# ~/.config/dejima/remote.conf - substitute your own topic and hostname
|
||||
NTFY_URL=https://ntfy.example.invalid/your-topic-here
|
||||
BASE_URL=https://dejima.example.invalid
|
||||
TTYD_PORT=7681
|
||||
# TTYD_FONT_SIZE=20 # optional, and applies to the desktop too
|
||||
```
|
||||
|
||||
**Editing this file changes nothing until the services restart.** Every value
|
||||
is read once, at process start - `dejima-ttyd` reads `TTYD_PORT` and
|
||||
`TTYD_FONT_SIZE` and then `exec`s `ttyd` with them baked into its argv. So:
|
||||
|
||||
```sh
|
||||
systemctl --user restart dejima-ttyd.service
|
||||
systemctl --user restart dejima-notify-watch.service # if NTFY_URL changed
|
||||
pgrep -a ttyd # confirm the argv is what you just configured
|
||||
```
|
||||
|
||||
Skipping this is quiet rather than loud: nothing warns, and the front door
|
||||
keeps serving with the old settings, which reads as "the setting had no
|
||||
effect" rather than as "the process is older than the file".
|
||||
|
||||
## The bind address is the one thing to get right
|
||||
|
||||
This is the step that carries the whole feature's security, and the only one
|
||||
where a plausible-looking choice is a full sandbox escape.
|
||||
|
||||
`docker/init-firewall.sh` deliberately ACCEPTs container OUTPUT to the
|
||||
container's default-route address - the Docker bridge gateway, which is your
|
||||
host. That is the one host address a sandbox can reach at all. Combine it
|
||||
with the fact above (a tmux client is a host shell) and the consequence is
|
||||
direct: **a front door the sandbox can reach is a compromised session's own
|
||||
escape hatch.**
|
||||
|
||||
So:
|
||||
|
||||
- `ttyd` is bound to `127.0.0.1` by `dejima-ttyd`, hardcoded, not
|
||||
configurable. The container has no route to your host's loopback, so
|
||||
nothing but a process on this host can open that socket.
|
||||
- **The reverse proxy has to run on that same host** - the dejima box, the
|
||||
one your sandboxes start on. That is not a preference, it is what the
|
||||
loopback bind implies: `proxy_pass http://127.0.0.1:<TTYD_PORT>` only
|
||||
resolves to `ttyd` from the same machine. If you already run a proxy on a
|
||||
*different* box, do not point it here - reaching `ttyd` from there would
|
||||
mean binding `ttyd` to a routable address, which is precisely the escape
|
||||
this whole section exists to prevent. Put a second, small proxy on the
|
||||
dejima box instead.
|
||||
- That proxy must bind **one specific address, on the dejima box's LAN
|
||||
interface**. Never the wildcard - a bare `listen 443 ssl;` is every
|
||||
interface *including* the Docker bridges - never `0.0.0.0`, and never a
|
||||
bridge address.
|
||||
|
||||
## Picking the address
|
||||
|
||||
`ip -br addr` on the dejima box lists more candidates than you want, because
|
||||
Docker adds its own. It looks something like this:
|
||||
|
||||
```
|
||||
lo UNKNOWN 127.0.0.1/8 ::1/128
|
||||
enp3s0 UP 192.0.2.24/24 <a link-local v6 address>
|
||||
docker0 UP <docker's own subnet>
|
||||
br-9f3c1a2b4d5e UP <docker's own subnet>
|
||||
```
|
||||
|
||||
Only the physical LAN interface qualifies - `enp3s0` here; yours may be
|
||||
`eth0`, `wlan0`, `enp0s31f6`, or a bond. So `listen 192.0.2.24:443 ssl;`,
|
||||
using your own address in place of that one.
|
||||
|
||||
Everything else on that list is disqualified, and two of them dangerously
|
||||
so. `lo` is where `ttyd` already is. `docker0` and every `br-<hash>` are
|
||||
Docker bridges - **these are the addresses a sandbox can reach**, so a proxy
|
||||
bound to one is a front door opening inward. If you are unsure which lines
|
||||
those are, ask for exactly them:
|
||||
|
||||
```sh
|
||||
ip -br addr show type bridge # every address here is disqualified
|
||||
```
|
||||
|
||||
Cross-check from the other side, too: the address a running sandbox has a
|
||||
route to is the one to avoid, and the container will tell you which it is -
|
||||
`docker exec <container> ip route | grep default` names it.
|
||||
|
||||
Afterwards, `ss -ltnp | grep <TTYD_PORT>` should show `ttyd` on `127.0.0.1`
|
||||
only, and `ss -ltnp` on your proxy's port should show the single LAN address
|
||||
you chose - not `*` and not `0.0.0.0`.
|
||||
|
||||
## Verifying it
|
||||
|
||||
Verify rather than assume. With a sandbox running, from inside it (adjust
|
||||
`7681` if you changed `TTYD_PORT`, and `443` to whatever port your proxy
|
||||
listens on):
|
||||
|
||||
```sh
|
||||
# the bridge gateway - the only host address this container can route to
|
||||
gw=$(ip route | grep default | cut -d' ' -f3)
|
||||
|
||||
# both should fail: the loopback ttyd, and the front door's own port
|
||||
timeout 5 bash -c "cat < /dev/null > /dev/tcp/$gw/7681" && echo BAD || echo ok
|
||||
timeout 5 bash -c "cat < /dev/null > /dev/tcp/$gw/443" && echo BAD || echo ok
|
||||
```
|
||||
|
||||
`BAD` on either line means stop and fix the bind before going further -
|
||||
whatever answered is reachable from inside the sandbox. The first line is
|
||||
also what catches a distro `ttyd.service` still running on that port, which
|
||||
is why this is worth doing even if you are confident about the proxy. `test/runtime.bats`
|
||||
asserts this same containment property against a live container (with a
|
||||
positive control on the same path, so a passing result can't come from the
|
||||
container simply reaching nothing), and it passes on a real Docker host.
|
||||
|
||||
## SSH front door
|
||||
|
||||
Generate a key that exists only for this, and give it a forced command:
|
||||
|
||||
```sh
|
||||
ssh-keygen -t ed25519 -f ~/.ssh/dejima-phone -C phone
|
||||
```
|
||||
|
||||
Then one line in `~/.ssh/authorized_keys`, with the full path to the symlink
|
||||
`install.sh` made:
|
||||
|
||||
```
|
||||
restrict,pty,command="/home/<you>/.local/bin/dejima-attach" ssh-ed25519 AAAA... phone
|
||||
```
|
||||
|
||||
`restrict` turns off port forwarding, agent forwarding, X11 and everything
|
||||
else; `pty` adds back the one thing a terminal needs; `command=` runs
|
||||
`dejima-attach` no matter what the client asks to run. Nothing the phone
|
||||
sends is interpreted as a command, so this needs no client-side
|
||||
"run this on connect" support - any plain SSH app lands on the picker.
|
||||
|
||||
It has to be the **same Unix user** that runs `dejima`: tmux sockets are
|
||||
per-user, and `dejima-attach` talks to the tmux server of whoever runs it.
|
||||
Options are per-key, so your normal key still gets a normal shell - worth
|
||||
confirming both after you add the line.
|
||||
|
||||
## Web front door
|
||||
|
||||
Needs `ttyd` **1.7.4 or newer**. `-W`/`--writable` arrived in 1.7.4, which
|
||||
inverted the old default (before it, the terminal was writable unless you
|
||||
passed `--readonly`), so an older build rejects the flag and
|
||||
`dejima-ttyd` fails at every start. Your distro's package may well be
|
||||
too old: Debian bookworm's is 1.7.3 and Ubuntu 22.04's is 1.6.3, neither of
|
||||
which has the flag - Ubuntu 24.04's 1.7.4 does. Check with `ttyd --version`,
|
||||
and take an upstream release binary if the packaged one is behind.
|
||||
|
||||
On Debian there is a middle option: sid packages **1.7.7**, and you can take
|
||||
that one package without moving the rest of the system to unstable. sid is
|
||||
not a configured source on a stable install, so add it - and pin it *below*
|
||||
your normal release, or the next `apt upgrade` follows it:
|
||||
|
||||
```sh
|
||||
echo 'deb http://deb.debian.org/debian unstable main' \
|
||||
| sudo tee /etc/apt/sources.list.d/sid.list
|
||||
|
||||
# without this pin the whole system drifts to unstable
|
||||
printf 'Package: *\nPin: release a=unstable\nPin-Priority: 10\n' \
|
||||
| sudo tee /etc/apt/preferences.d/99-sid
|
||||
|
||||
sudo apt update
|
||||
sudo apt install -t unstable ttyd # -t overrides the pin, for this package
|
||||
```
|
||||
|
||||
Read what `apt` proposes before agreeing to it. One package from sid drags
|
||||
its libraries along, and if the plan includes `libc6` you are no longer
|
||||
taking one package - back out and use an upstream release binary instead.
|
||||
`apt_preferences(5)` has the details.
|
||||
|
||||
**Installing the package opens a door of its own. Close it.** However you get
|
||||
`ttyd` from a distro repository - stable, sid, Ubuntu - the package ships a
|
||||
*system* service that systemd enables and starts on install. It serves a
|
||||
login prompt, and because it passes no `--interface` of its own it binds
|
||||
**every** interface, the bridge address a sandbox routes to included. It
|
||||
listens on `ttyd`'s default port, which is the port this feature defaults to
|
||||
as well.
|
||||
|
||||
Two consequences, and the second is the one that hides:
|
||||
|
||||
- A host login prompt is now reachable *from inside every container you run*,
|
||||
put there by the act of installing the package.
|
||||
- It holds the port, so `dejima-ttyd` fails to bind at every start while your
|
||||
reverse proxy proxies to the distro's `ttyd` instead. You get your own
|
||||
password prompt and then a host login shell, on the hostname you set up for
|
||||
dejima, with nothing along the way appearing broken.
|
||||
|
||||
```sh
|
||||
systemctl status ttyd.service # almost certainly active
|
||||
sudo systemctl disable --now ttyd.service
|
||||
sudo systemctl mask ttyd.service # so reinstalling cannot revive it
|
||||
```
|
||||
|
||||
`dejima-ttyd` refuses to start when anything already holds its port and
|
||||
names this as the likely cause, rather than leaving you with `ttyd`'s
|
||||
one-line bind error in a unit nobody is watching. That is a diagnostic, not a
|
||||
fix: the listener is there from the moment the package lands, whether or not
|
||||
you ever run `dejima-ttyd`.
|
||||
|
||||
`dejima-ttyd` reads `TTYD_PORT` and launches
|
||||
`ttyd --interface 127.0.0.1 --port <port> --check-origin --url-arg --writable dejima-attach`.
|
||||
Two of those are load-bearing:
|
||||
|
||||
- **`--check-origin`** is not tidiness. Without it `ttyd` does no origin
|
||||
validation at all, and browsers do not apply the same-origin policy to
|
||||
WebSockets - they do attach the cached credential for the target origin.
|
||||
Any page loaded in an authenticated operator's browser could open
|
||||
`wss://<front-door>/ws` and be handed this host shell, with no interaction
|
||||
beyond visiting the page. Proxy authentication cannot stop that; it is
|
||||
satisfied by the victim's own browser.
|
||||
- **`--url-arg`** is what lets the notification's deep link name a session
|
||||
(`?arg=<run-id>`). It reaches `dejima-attach` as argv, never as a shell
|
||||
word, and has to match the run-id shape before it is used for anything.
|
||||
|
||||
Install the unit by hand - it ships disabled, and `install.sh` will not do
|
||||
this for you:
|
||||
|
||||
```sh
|
||||
mkdir -p ~/.config/systemd/user
|
||||
cp systemd/dejima-ttyd.service ~/.config/systemd/user/
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now dejima-ttyd.service
|
||||
```
|
||||
|
||||
It is a **user** unit, not a system one, and it repeats no configuration:
|
||||
`ExecStart` is `%h/.local/bin/dejima-ttyd`, and the bind address and port
|
||||
live in the script and `remote.conf` so the two cannot drift.
|
||||
|
||||
Then put a reverse proxy in front of it. Adapt
|
||||
`docs/nginx-dejima.conf.example` - it will not load as written, deliberately,
|
||||
so it can never become a live config by being dropped into `sites-enabled`
|
||||
and forgotten. What matters in it:
|
||||
|
||||
- **Authentication on every server block.** `ttyd` is started with no
|
||||
credential of its own, because the loopback bind means only this host can
|
||||
reach it - the proxy is the entire gate. A block without `auth_basic` is
|
||||
an unauthenticated host shell on your network.
|
||||
- **A `default_server` that refuses.** nginx picks a server block by
|
||||
SNI/`Host`, and when nothing matches it serves the **default** for that
|
||||
listen address - which, if the front door is the only block on it, is the
|
||||
front door. `server_name` on its own therefore isolates nothing: the vhost
|
||||
answers to *every* name that resolves to that address, including the box's
|
||||
own hostname. The example ships a first block that is `default_server` and
|
||||
does nothing but `ssl_reject_handshake on;`, so only the name you chose
|
||||
gets through. If you already have a default server on that address, keep
|
||||
the one you have - just make sure it doesn't fall through to this vhost.
|
||||
- **TLS.** HTTP Basic sends the password on every request, and the terminal
|
||||
itself carries whatever the session is working on.
|
||||
- **`proxy_set_header Host $http_host;`** — `$http_host`, not `$host`.
|
||||
`--check-origin` compares the client's `Origin` against the `Host` header,
|
||||
and nginx's `$host` has the port stripped while `ttyd` keeps the port in
|
||||
what it compares unless it is 80 or 443. On a vhost listening anywhere
|
||||
else - 8443, say - `$host` makes `ttyd` compare `name:8443` against `name`
|
||||
and refuse every WebSocket: the page loads, the terminal never connects,
|
||||
and the only sign is a "refuse to serve WS client from different origin"
|
||||
line in `ttyd`'s log.
|
||||
- **A long `proxy_read_timeout`.** An attached terminal sits idle for hours;
|
||||
nginx's default is 60 seconds.
|
||||
|
||||
## Reading it on a phone
|
||||
|
||||
Out of the box the phone view is close to unusable: text renders far too
|
||||
small to read, and pinch-zooming leaves you panning around a giant canvas
|
||||
instead of reflowing anything. That is not a font setting you missed.
|
||||
|
||||
**`ttyd` serves no viewport meta tag** - not in 1.7.7, not on `main` at the
|
||||
time of writing. A mobile browser that finds no `<meta name="viewport">`
|
||||
assumes the page was written for a desktop, lays it out at a virtual width
|
||||
of around 980 CSS pixels, and scales the result down to fit the screen.
|
||||
Everything shrinks together, so raising the font size just gives you a
|
||||
larger font scaled down by the same factor. And a terminal is a fixed
|
||||
character grid on a canvas: zooming magnifies pixels, it does not re-wrap
|
||||
lines the way zooming a text page does.
|
||||
|
||||
The fix is to supply the tag the page is missing, which the proxy can do on
|
||||
the way past. `docs/nginx-dejima.conf.example` ships it:
|
||||
|
||||
```nginx
|
||||
sub_filter '<head>' '<head><meta name="viewport" content="width=device-width, initial-scale=1">';
|
||||
sub_filter_once on;
|
||||
proxy_set_header Accept-Encoding "";
|
||||
add_header Cache-Control "no-store" always;
|
||||
```
|
||||
|
||||
Now the browser lays out at the device's real width, xterm.js fits an honest
|
||||
column count to it, and rotating the phone reflows properly. Desktop
|
||||
browsers ignore `width=device-width`, so nothing changes there.
|
||||
|
||||
Three things that will waste your time if you skip them:
|
||||
|
||||
- Your nginx needs the sub module, and a missing one fails *quietly*. Check
|
||||
for it up front, and validate with **`nginx -t`** rather than trusting the
|
||||
reload - a reload whose config does not parse leaves the *old* config
|
||||
running while `systemctl reload nginx` still reports success, so an unknown
|
||||
`sub_filter` directive looks from the outside exactly like a directive that
|
||||
ran and did nothing.
|
||||
|
||||
```sh
|
||||
/usr/sbin/nginx -V 2>&1 | tr ' ' '\n' | grep http_sub_module
|
||||
sudo /usr/sbin/nginx -t
|
||||
```
|
||||
|
||||
The path is spelled out because Debian and Ubuntu install nginx to
|
||||
`/usr/sbin`, which is on root's `PATH` and not on yours - a bare `nginx -V`
|
||||
answers `command not found` on a box that is running nginx perfectly well.
|
||||
Debian's `nginx-light` is built without the module; `nginx-core`,
|
||||
`nginx-full` and `nginx-extras` have it.
|
||||
- `sub_filter` cannot rewrite a compressed response body and passes it
|
||||
through untouched, which looks exactly like the tag not working. Clearing
|
||||
`Accept-Encoding` toward the upstream is what stops that.
|
||||
- **Your phone may already be holding the un-injected page.** `ttyd` sends it
|
||||
with no `Cache-Control`, no `ETag` and no `Last-Modified`, and a response
|
||||
with neither freshness information nor a validator is cacheable for as long
|
||||
as the browser feels like it. Fine while the body is served verbatim; a trap
|
||||
once the proxy rewrites it, because the stale copy renders forever and the
|
||||
injection reads as broken long after it works. `add_header Cache-Control
|
||||
"no-store" always;` closes it going forward - to check *right now*, load the
|
||||
front door in a private tab, which bypasses the cache without clearing
|
||||
anything else.
|
||||
|
||||
Confirm it server-side before blaming the browser, or the other way round -
|
||||
the first 220 bytes of the response settle it, and the tag should sit ahead
|
||||
of `<meta charset>`:
|
||||
|
||||
```sh
|
||||
curl -sk -u USER:PASS https://<front-door>/ | head -c 220; echo
|
||||
```
|
||||
|
||||
If the text is still smaller than you want once the layout is honest, set
|
||||
`TTYD_FONT_SIZE` in `remote.conf` (8-72). Be aware of what it costs:
|
||||
**it applies to every client.** `ttyd` 1.7.7 accepts client options only at
|
||||
launch - the URL-query override that would let a deep link carry its own
|
||||
size landed upstream after 1.7.7 was cut - so there is no way from here to
|
||||
give a phone one size and the desktop another. A larger size also means
|
||||
fewer columns, and because attaching shares the window with your desktop
|
||||
session, tmux resizes that window to whichever client used it last. Start by
|
||||
fixing the viewport and only reach for this if you still need it.
|
||||
|
||||
## Typing on a phone
|
||||
|
||||
Text you already typed reappears a batch at a time, sometimes cascading into
|
||||
another batch. Backspacing clears it; a few characters later it happens
|
||||
again.
|
||||
|
||||
**It is the keyboard's autocorrect**, and two things locate it there. The
|
||||
duplicated characters are real input you can backspace over rather than a
|
||||
redraw artifact, and the WebSocket runs over TCP, which cannot deliver the
|
||||
same bytes twice - so the browser is emitting them. And it reproduces on
|
||||
demand: a long run of digits never duplicates, while ordinary words duplicate
|
||||
on every correction the keyboard makes - `fix` to `fox`, `lets` to `let's`.
|
||||
When a soft keyboard *replaces* a span it has already committed, xterm.js
|
||||
re-sends the whole contents of its hidden input element instead of the
|
||||
difference, so the entire line replays.
|
||||
|
||||
xterm.js already asks the keyboard not to do this - it sets `autocorrect`,
|
||||
`autocapitalize` and `spellcheck` to off on that element as it creates it -
|
||||
and Android ignores all three. This is a general `ttyd`/xterm.js interaction
|
||||
with predictive keyboards, and there is no lever here, in the proxy or in
|
||||
`dejima-ttyd`, that reaches it.
|
||||
|
||||
So use a keyboard with nothing to correct against. **Unexpected Keyboard**
|
||||
(F-Droid or Play Store) was written for Termux users and fits well: its word
|
||||
suggestions need a dictionary you download yourself, so a fresh install has
|
||||
nothing to autocorrect with, and Ctrl, Alt, Esc, Tab, the arrows and the
|
||||
function keys are all real keys, reached by swiping a key toward the corner
|
||||
the symbol is printed in. That second half matters more than the bug does -
|
||||
without Ctrl there is no tmux prefix, and Esc and the arrows are hard to do
|
||||
without in anything interactive. Android's keyboard switcher makes it a
|
||||
per-session choice rather than a replacement for the keyboard you use for
|
||||
everything else.
|
||||
|
||||
Turning autocorrect off in the keyboard you already have fixes it too, at the
|
||||
cost of losing the correction everywhere else.
|
||||
|
||||
## Behind another reverse proxy
|
||||
|
||||
Terminating TLS somewhere else and forwarding here is a supported shape, but
|
||||
read this first: it moves the front door out of the LAN threat model
|
||||
everything above is written against. If the outer proxy is internet-facing,
|
||||
`auth_basic` over TLS is now the only thing between the public internet and
|
||||
a host shell. Put something stronger in front of it, and take the
|
||||
[security notes](security.md) as the floor rather than the ceiling.
|
||||
|
||||
The mechanical part has four requirements at the outer hop, and each one
|
||||
fails differently:
|
||||
|
||||
```nginx
|
||||
proxy_ssl_server_name on; # else: TLS handshake refused
|
||||
proxy_ssl_name dejima.example.invalid;
|
||||
proxy_set_header Host dejima.example.invalid; # exactly once - see below
|
||||
proxy_http_version 1.1; # else: page loads, terminal never connects
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 1d; # else: dropped after 60s idle
|
||||
```
|
||||
|
||||
- **`proxy_ssl_server_name` is off by default**, so nginx proxying to an
|
||||
HTTPS upstream sends no SNI at all. The inner nginx sees a handshake with
|
||||
no name, hands it to the `default_server` - which exists precisely to
|
||||
refuse unrecognised names - and you get a 502 at the outer hop.
|
||||
- **Set `Host` exactly once.** `proxy_set_header` is additive within a
|
||||
configuration level rather than overriding, so declaring `Host` twice
|
||||
sends *two* `Host` headers and the inner nginx answers 400 to all of it.
|
||||
The easy way to do this by accident is `include proxy_params;` (Debian
|
||||
ships one that already sets `Host $http_host`) plus your own `Host` line
|
||||
in the same block.
|
||||
- **A literal, not `$host` or `$http_host`.** The value has to be the
|
||||
hostname the *browser* used, because that is what `ttyd`'s
|
||||
`--check-origin` compares the `Origin` against at the far end. A literal
|
||||
also guarantees it agrees with `proxy_ssl_name`.
|
||||
- **`Upgrade`/`Connection` and the timeout are needed at every hop**, not
|
||||
just the inner one. Miss them outside and the page loads while the
|
||||
terminal never connects - the same symptom the inner config produces, from
|
||||
a different place.
|
||||
|
||||
nginx logs the *reason* for a 400 at `info` level, which is below the
|
||||
default. When something here is wrong and the access log only says `400`,
|
||||
set `error_log /var/log/nginx/error.log info;` on the inner proxy, reproduce
|
||||
once, and it names the cause outright. Turn it back down afterwards. To
|
||||
establish which hop is at fault, bypass the outer one from the dejima box
|
||||
itself:
|
||||
|
||||
```sh
|
||||
curl -vk --resolve dejima.example.invalid:443:<dejima-box-lan-ip> \
|
||||
https://dejima.example.invalid/
|
||||
```
|
||||
|
||||
A `401` there means the inner config is correct and the fault is entirely in
|
||||
what the outer proxy sends.
|
||||
|
||||
## Notifications
|
||||
|
||||
`dejima` registers `hooks/dejima-notify.sh` as a `Notification` and
|
||||
`Stop` hook for a remote-enabled session - into the throwaway settings copy
|
||||
it already generates for that session, never into your real
|
||||
`~/.claude/settings.json`, which every sandbox's copy derives from and your
|
||||
host's own Claude Code reads directly. That copy is mounted read-only, so a
|
||||
session cannot unregister its own hook. The hook writes one empty file, named `waiting` or
|
||||
`done`, into this run's own bind-mounted marker directory. No content, no
|
||||
stdin, no network.
|
||||
|
||||
On the host, `dejima-notify-watch` turns those markers into ntfy pushes.
|
||||
Same installation shape as above:
|
||||
|
||||
```sh
|
||||
cp systemd/dejima-notify-watch.service ~/.config/systemd/user/
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now dejima-notify-watch.service
|
||||
```
|
||||
|
||||
`inotify-tools` is optional - without `inotifywait` the watcher degrades to
|
||||
a roughly six-second poll rather than failing (a one-second floor under the
|
||||
loop plus a five-second sleep). Repeat markers for the same session and
|
||||
event inside 60 seconds are debounced (`DEJIMA_DEBOUNCE_SECONDS` overrides
|
||||
that).
|
||||
|
||||
One environment gotcha, because it fails silently: the watcher and
|
||||
`dejima` both resolve the marker root as `${XDG_RUNTIME_DIR:-/tmp}/dejima`,
|
||||
so they only meet when they agree on `XDG_RUNTIME_DIR`. systemd sets it for
|
||||
user units and a login shell gets it from pam_systemd, but a `dejima`
|
||||
run from a context that has neither - a bare `su`, a cron job, a tmux server
|
||||
started before login - writes its markers under `/tmp/dejima` while the unit
|
||||
watches `/run/user/<uid>/dejima`, and no push is ever sent, with nothing
|
||||
logged to say so. `systemctl --user show-environment` is where to check.
|
||||
|
||||
The same directory is why you probably want lingering on:
|
||||
|
||||
```sh
|
||||
loginctl enable-linger "$USER"
|
||||
```
|
||||
|
||||
Both units here are *user* units. Without lingering they are stopped at
|
||||
logout and `/run/user/<uid>` is destroyed with the session, taking the
|
||||
session registry and every marker with it - so a sandbox left running in a
|
||||
detached tmux session has no front door and sends no notification until you
|
||||
log in again. It fails closed rather than open (nothing is left listening
|
||||
unguarded), but it fails invisibly, and it is the same `XDG_RUNTIME_DIR`
|
||||
that the gotcha above is about.
|
||||
|
||||
**The marker directory is a write channel into your runtime tmpfs**, and
|
||||
nothing bounds it. `/run/user/<uid>` is RAM-backed and sized at a fraction
|
||||
of physical memory, and each session gets its own directory under it
|
||||
bind-mounted read-write, so a session that writes into `/run/dejima-notify`
|
||||
until it is full degrades your whole login session - D-Bus, the systemd user
|
||||
manager - and not just dejima. A session can already fill the workspace
|
||||
mount, so this is an increment rather than a new kind of exposure, but a
|
||||
RAM-backed filesystem fails harder and sooner than a disk. There is
|
||||
deliberately no quota here; `rm -rf` on that run's marker directory (or
|
||||
logging out, which discards the tmpfs) is the recovery.
|
||||
|
||||
**What a notification contains.** A title of `dejima: <project> needs input`
|
||||
(or `dejima: <project> finished`), a fixed one-line body, and - when
|
||||
`BASE_URL` is set - a click link of `<BASE_URL>/?arg=<run-id>` that opens the
|
||||
web front door already attached to that session. `<project>` is the
|
||||
workspace directory's name, taken from the host-side registry and reduced to
|
||||
`A-Za-z0-9._-`.
|
||||
|
||||
**And nothing about what Claude is actually asking.** That absence is
|
||||
deliberate, not a missing feature: your phone is outside the sandbox, so no
|
||||
sandbox-controlled bytes are allowed to reach it. The question itself is on
|
||||
the terminal you are about to attach to; the push exists only to tell you to
|
||||
go look.
|
||||
|
||||
The guarantee for that is host-side, and it is worth being precise about
|
||||
where it lives. The in-container hook's `waiting|done` whitelist is **not**
|
||||
it - a compromised session owns its own bind-mounted marker directory and
|
||||
can create any file name in it, hook or no hook. What holds is
|
||||
`dejima-notify-watch`: it iterates a literal two-name enum rather than
|
||||
reading the directory, so a name outside it is never stat'd, read or
|
||||
removed; it never reads marker *content*; and it consumes markers with
|
||||
`rm -f`, which unlinks a symlink instead of following it. Every byte it
|
||||
sends - title, body, headers, click URL - is composed from host data. That
|
||||
last point is what the enum is really for: ntfy's own headers include
|
||||
`X-Actions`, `X-Attach` and `X-Icon`, which can make the receiving phone
|
||||
issue an arbitrary HTTP request, so a single attacker-controlled header
|
||||
would turn it into a confused deputy inside your network.
|
||||
|
||||
**When no notification arrives.** Five things have to happen in order, and a
|
||||
break in any of them looks identical from the phone - which is to say, like
|
||||
nothing at all. Walk them from the inside out; each step tells you whether
|
||||
the next one is worth checking.
|
||||
|
||||
1. **Is the session registered?** Inside the sandbox, `echo $DEJIMA_RUN_ID`
|
||||
and `echo $DEJIMA_NOTIFY_DIR`. Both empty means `dejima` started this
|
||||
container with remote access off - `remote.conf` did not exist at launch,
|
||||
and no later change affects a session already running.
|
||||
2. **Is the hook registered?** The `Notification` and `Stop` entries live in
|
||||
the throwaway settings copy, not in your real `settings.json`:
|
||||
`grep -c dejima-notify ~/.claude/settings.json` from inside should be 2.
|
||||
3. **Does the marker reach the host?** This is the one worth knowing, because
|
||||
it splits the chain cleanly in half. From inside the sandbox:
|
||||
|
||||
```sh
|
||||
: > "$DEJIMA_NOTIFY_DIR/waiting" && sleep 2 && ls "$DEJIMA_NOTIFY_DIR"
|
||||
```
|
||||
|
||||
The file vanishing means the host watcher is running and consuming, and
|
||||
everything below this line is host-side. The file remaining means
|
||||
`dejima-notify-watch` is not running - `systemctl --user status
|
||||
dejima-notify-watch`, and check `enable-linger` if it died with your
|
||||
session.
|
||||
4. **Did the push succeed?** `curl`'s stderr goes to the watcher's journal:
|
||||
`journalctl --user -u dejima-notify-watch -n 50`. A `push failed` line
|
||||
with a 404 usually means `NTFY_URL` names the server but no topic; a 401
|
||||
or 403 means the instance wants credentials, which the URL can carry as
|
||||
`https://<user>:<token>@<host>/<topic>` - nothing here sends an auth
|
||||
header of its own.
|
||||
5. **Is the phone subscribed to the topic the config names?** Last in the
|
||||
list and first to check in practice, since it is the only link with no
|
||||
log anywhere. The topic in `NTFY_URL` and the one in the app have to be
|
||||
the same string.
|
||||
|
||||
Note also that step 3 sends a real notification when the chain is healthy,
|
||||
which makes it a test of the whole path rather than only of the watcher.
|
||||
Repeat runs within `DEJIMA_DEBOUNCE_SECONDS` (60 by default) are consumed
|
||||
without pushing, so leave a minute between attempts or you will diagnose the
|
||||
debounce instead.
|
||||
|
||||
## Attaching
|
||||
|
||||
`dejima-attach` with no argument gives a numbered picker - digits rather
|
||||
than arrow keys, because a phone soft keyboard handles those far better.
|
||||
`--list` prints one tab-separated row per live session for scripting, and a
|
||||
run-id argument attaches directly, which is what the deep link and `ttyd`'s
|
||||
`--url-arg` produce. Rows carry the run id, the tmux label, the workspace
|
||||
name, the session's age, its state, and the container id.
|
||||
|
||||
The picker is coloured (gruvbox) when stdout is a terminal that says it can
|
||||
show it; `NO_COLOR=1` or a `dumb` `$TERM` turns that off, along with the
|
||||
box-drawing glyphs. `--list` is never coloured, terminal or not - the picker
|
||||
parses those rows itself and the README calls them scriptable.
|
||||
|
||||
Three states, from a registry `dejima` writes on launch and removes on
|
||||
exit:
|
||||
|
||||
- `attachable` - a live tmux pane to join.
|
||||
- `stale` - registered, but its pane is gone. Listed rather than hidden,
|
||||
since the registry is a hint and not truth: the exit trap doesn't run
|
||||
under `SIGKILL` or a host crash.
|
||||
- `no-tmux` - the sandbox was started outside tmux, so there is no pane to
|
||||
attach to. Still listed, deliberately, rather than silently omitted.
|
||||
|
||||
Entries whose container has gone away are pruned as they're listed. When
|
||||
Docker can't be asked at all, nothing is pruned and the container column
|
||||
reads `?` - an unanswerable question is never treated as a "no".
|
||||
|
||||
Attaching creates a **grouped** tmux session (`dejima-<run-id>`) rather than
|
||||
joining yours. Grouped sessions share windows but track their own current
|
||||
window, so the phone can sit on one sandbox while the desktop sits on
|
||||
another, and the throwaway carries `destroy-unattached`, so it disposes of
|
||||
itself when the phone detaches or the connection drops.
|
||||
|
||||
**You get the whole window list, not just the one sandbox.** Sharing windows
|
||||
is what "grouped" means: you land on the right window, and every other
|
||||
window on your tmux server is one tab away - other sandboxes, and whatever
|
||||
non-dejima windows you have open. Worth knowing before you first connect,
|
||||
because it is a surprise otherwise.
|
||||
|
||||
It is not a containment boundary being crossed, which is why it is left as
|
||||
it is. Whoever reaches this terminal is one prefix key away from a brand new
|
||||
host shell no matter what the tab bar shows - that is the fact this entire
|
||||
feature is designed around, and tmux offers no per-client way to take the
|
||||
prefix away (key tables are server-global, so disabling it for the phone
|
||||
would disable it for your desktop too). Restricting the view would be
|
||||
tidier; it would not make a reachable front door any less of a host shell.
|
||||
Authentication and the bind address are the controls that matter here.
|
||||
|
||||
Sizing is left to tmux on purpose: `dejima-attach` sets no `window-size`.
|
||||
tmux's own default (`latest`, since 3.1) already keeps a small phone client
|
||||
from shrinking the desktop's view, and the option's only scopes are the
|
||||
window - which a grouped session *shares* with yours, so setting it there
|
||||
would permanently overwrite your choice with nothing to restore afterwards -
|
||||
and global. An operator who deliberately set `smallest` keeps it.
|
||||
@@ -0,0 +1,306 @@
|
||||
# Security notes
|
||||
|
||||
[← dejima README](../README.md)
|
||||
|
||||
**Threat model, honestly stated:** this reduces the blast radius of a
|
||||
misbehaving or prompt-injected coding agent - it does not make it safe to
|
||||
point at arbitrary untrusted or adversarial input, and it does not turn
|
||||
`--dangerously-skip-permissions` into something without real risk. Point it
|
||||
at repositories you already trust. Everything below is about how bad a
|
||||
compromised session *inside* that trust boundary can get, not a claim that
|
||||
one can't happen.
|
||||
|
||||
- `--dangerously-skip-permissions` removes your chance to review tool calls
|
||||
before they run. Only point this at trusted repositories.
|
||||
- The mounted project itself has **no write protection**. `deny-dangerous-
|
||||
bash.sh`, `git-push-guard.sh` and `gitea-merge-guard.sh` are regex
|
||||
pattern-matching on the literal Bash command string - a fast-fail guard
|
||||
against an overeager agent doing something obviously destructive, not a
|
||||
boundary against one that's actively trying to get around them (a
|
||||
different scripting language, an encoded command, editing `.git/config`
|
||||
directly all sail right through).
|
||||
Nothing here stops a compromised or badly-instructed session from
|
||||
modifying or deleting the real working copy it's mounted against.
|
||||
- That same read-write access to the mounted project is also a
|
||||
host-code-execution vector, not just a data-destruction one: a
|
||||
compromised session could plant `.git/hooks/post-commit` (or
|
||||
`pre-push`), set `core.hooksPath`, or write a
|
||||
`.claude/settings.local.json` with a malicious hook command, into the
|
||||
project. Any of those run unsandboxed on the host the next time you run
|
||||
`git` or open Claude Code in that repo - no network egress required, so
|
||||
the firewall doesn't see it either. Same underlying persistence pattern
|
||||
as the `~/.claude/hooks` and `~/.claude.json` risks above, just through
|
||||
the project mount instead of the config mount. "Only point this at
|
||||
trusted repositories" (above) covers input trust; this is about the
|
||||
sandbox writing a persistence payload into an otherwise-trusted repo.
|
||||
The workspace's own `.git/hooks` directory is now layered read-only
|
||||
(same pattern as the `~/.claude` overlays below), so planting a hook
|
||||
file directly is closed - accepted breakage: installing hooks
|
||||
*in-session* (`husky`, `pre-commit install`) now fails, which is the
|
||||
point. `core.hooksPath` and `.claude/settings.local.json` aren't
|
||||
overlaid (they're config, not fixed paths a static mount can cover) and
|
||||
remain fully writable; the exit-time audit below reports changes to
|
||||
either, but only after the session ends - it doesn't stop them.
|
||||
- `bin/dejima-audit` snapshots host-persistence surfaces before each
|
||||
run and diffs them after the container exits: `.git/config` (and worktree
|
||||
`config.worktree`) keys that run a command on the next `git` -
|
||||
`core.hooksPath`, `core.fsmonitor`, `core.sshCommand`,
|
||||
`credential.helper`, `core.pager`, `pager.*`, `core.editor`,
|
||||
`sequence.editor`, `gpg.program`, `diff.external`, `merge.*.driver`,
|
||||
`diff.*.command`, `filter.*.smudge`/`clean`/`process`, `diff.*.textconv`,
|
||||
`include.path`/`includeif.*.path` (config indirection - these can point at
|
||||
a planted file that itself sets an exec key, so the exec key never
|
||||
appears in `.git/config` directly), `!`-shell aliases - plus content
|
||||
changes under the workspace's `.claude/`, `.mcp.json`, `.envrc`,
|
||||
`.vscode/`, and `.git/hooks/` (symlinks included - a repointed symlink is
|
||||
hashed by its target, not just its content), plus cross-project writes
|
||||
under `~/.claude/projects` (see below). When the mounted workspace *is*
|
||||
this repo's own sandbox itself (see the trusted-computing-base note
|
||||
further below), it also diffs content hashes of the sandbox's own trusted
|
||||
computing base - `bin/`, `hooks/`, `docker/`, `install.sh`,
|
||||
`test/` - under that workspace. It's **advisory only**: it fires *after*
|
||||
the session has already ended, so a report of a planted `core.hooksPath`
|
||||
or hook file is something to read and clean up *before* the next `git`
|
||||
or `claude` in that repo, not something that was blocked. It's silent when nothing
|
||||
changed, and never affects the session's own exit code even if it can't
|
||||
complete. It doesn't cover `$SANDBOX_SHARE_DIR` (see "Shared scratch
|
||||
space") - that hand-off path stays unaudited - and its coverage is a
|
||||
fixed list of known Claude Code config surfaces, not a general integrity
|
||||
check, so a future config surface this list hasn't been taught about yet
|
||||
would go unreported.
|
||||
- `~/.claude/projects` (session transcripts and per-project memory) is
|
||||
**not** mounted read-only, unlike the other `~/.claude` overlays above -
|
||||
deliberately. Scoping a read-only overlay to "the current project's slug"
|
||||
would break the sandbox's own worktree workflow: a session running in
|
||||
`WORKSPACE/.worktrees/<branch>` writes to a *different* projects slug
|
||||
than the parent repo, so a slug-scoped overlay would either block that
|
||||
legitimate write or have to be widened until it stopped protecting
|
||||
anything. Instead, cross-project writes are **detected, not prevented**:
|
||||
the exit-time audit above flags any write during the session to another
|
||||
project's `~/.claude/projects/<slug>` directory, excluding the current
|
||||
project's own slug and its worktree children. That's after-the-fact
|
||||
reporting of transcript/memory poisoning in *other* projects, not a
|
||||
block on it happening. Residual even within that: the current project's
|
||||
own transcripts/memory stay fully writable throughout, since Claude Code
|
||||
needs that for host-side `claude --resume` to work; and detection keys
|
||||
off each slug's max file mtime, so an edit to a file that isn't already
|
||||
that project's newest doesn't advance the max and goes unflagged. A
|
||||
read-only mount for `projects/` remains a possible future option if
|
||||
Claude Code is confirmed to fail cleanly (not hang, as `~/.claude.json`
|
||||
did) when a write to it is blocked.
|
||||
- Allowing egress to a domain at all creates a channel for exfiltrating data
|
||||
through *that service's own request surface* (query strings, lookups,
|
||||
etc.), not just to a literal attacker-controlled destination. Domain-level
|
||||
allowlisting closes off reaching somewhere new; it can't inspect or
|
||||
restrict what gets sent to somewhere already trusted for normal operation
|
||||
(installing packages, calling the model API). That's a structural limit of
|
||||
allowlist-only firewalls in general, not something specific to this setup,
|
||||
and nothing here addresses it.
|
||||
- This is container-level isolation (Linux namespaces plus a small
|
||||
capability set), not VM-level isolation - no gVisor/Firecracker or
|
||||
similar. It shares the host kernel, so a container-escape vulnerability
|
||||
would defeat every other control described here at once.
|
||||
- Most of what this document asserts is re-checked by the `test/` suite on
|
||||
every change - over 300 bats tests across six files, and `runtime.bats`
|
||||
builds the image and asserts against a real running container rather than
|
||||
a mock: IPv6 blocked, a non-allowlisted domain refused by the DNS proxy
|
||||
and a non-allowlisted destination unreachable, `node` unable to re-run
|
||||
`init-firewall.sh` to widen the allowlist, no working `sudo`. Coverage is
|
||||
not the same as completeness, though. The reasoning in this document is
|
||||
judgement rather than assertions, nothing tests it, and a property nobody
|
||||
thought to write a test for can still regress quietly. A missing `ipset`
|
||||
dedup flag broke firewall startup entirely - twice, on two different
|
||||
ipsets - before anyone caught it; there is a test asserting every
|
||||
`ipset add` is duplicate-safe now, which is the pattern to expect. Each
|
||||
of these tests exists because something was got wrong first.
|
||||
- The Anthropic OAuth credential (`~/.claude/.credentials.json`) never
|
||||
enters the container. `dejima` starts a host-side proxy
|
||||
(`bin/dejima-anthropic-proxy`) on a per-session unix socket,
|
||||
mounts an empty file over `.credentials.json` (the parent `~/.claude`
|
||||
mount stays read-write, so a single-file mask is the mechanism), and
|
||||
hands Claude Code a placeholder token plus an `ANTHROPIC_BASE_URL`
|
||||
pointing at a loopback forwarder that pipes into the socket. The proxy
|
||||
strips the placeholder, injects the real access token per request, and
|
||||
owns OAuth refresh - rotated tokens are written back to the real host
|
||||
file atomically, so host-side sessions stay logged in. What remains is
|
||||
session-scoped: a compromised session can still *use* the model API as
|
||||
you while it runs (that's inherent to the sandbox working at all), but
|
||||
it can no longer read a durable credential and exfiltrate it for
|
||||
offline use via the other allowed egress destinations (npm, GitHub,
|
||||
your Gitea host). Caveats: `/login` inside the container doesn't work
|
||||
while the proxy is on; the refresh flow mirrors a Claude Code internal
|
||||
and may break if upstream changes it, in which case the proxy fails
|
||||
loudly (launch abort, 502s on API calls) rather than silently.
|
||||
`SANDBOX_CREDENTIAL_PROXY=0` is the kill switch, restoring the previous
|
||||
behavior - the credential mounted into the container, readable by a
|
||||
compromised session. Setups without a `claudeAiOauth` block (API-key
|
||||
auth) skip the proxy automatically; there's no stored subscription
|
||||
credential to protect.
|
||||
- `settings.json`, `CLAUDE.md`, `plugins/`, `hooks/`, `skills/`, `commands/`,
|
||||
`agents/`, and `output-styles/` under `~/.claude` are layered back
|
||||
read-only on top of the (otherwise read-write) `~/.claude` mount. Hooks
|
||||
execute automatically with no separate approval step, skills can ship
|
||||
executable scripts, and commands/agents/output-styles are prompt text
|
||||
loaded into a later host session, so without this a compromised session
|
||||
could plant a backdoored hook - including by unlinking one of
|
||||
`install.sh`'s hook symlinks and dropping a plain file in its place, since
|
||||
the dynamic per-target mounts `dejima` adds at launch only
|
||||
protect symlink targets that resolve *outside* `~/.claude`, never this
|
||||
directory itself - rewrite `CLAUDE.md` with injected instructions, tamper
|
||||
with plugin code, or drop a malicious skill/command/agent/output-style
|
||||
file - something that would then run or load **unsandboxed on the host**
|
||||
the next time Claude Code starts there, turning a single compromised
|
||||
sandbox session into persistent host compromise. `skills/`, `commands/`,
|
||||
`agents/`, and `output-styles/` may not exist yet on a given host, so
|
||||
`dejima` pre-creates them at launch (empty is fine) - a
|
||||
read-only overlay only applies to a directory that already exists, and
|
||||
without pre-creation a compromised session could create the directory
|
||||
itself first, then populate it, before the overlay ever had something to
|
||||
sit on top of. Everything else under `~/.claude` (session/project
|
||||
history, caches) stays read-write, since it's runtime state Claude Code
|
||||
needs to update - this list is a denylist of
|
||||
known code/config surfaces, not an allowlist, so it has to be kept in
|
||||
step with whatever new config surfaces Claude Code adds.
|
||||
- `~/.claude/statusline.sh` (referenced by `settings.json`'s
|
||||
`statusLine.command` and executed on the host) is bind-mounted onto
|
||||
itself read-only by `dejima`, for the same reason as the
|
||||
list above - to stop a compromised session from unlinking it and dropping
|
||||
its own script in its place. **Confirmed against a live container**
|
||||
(`test/runtime.bats`'s statusline.sh tests): this reliably blocks
|
||||
replacement when `statusline.sh` is a plain regular file. It does
|
||||
**not** when `statusline.sh` is a symlink (the common case - typically
|
||||
into a dotfiles checkout): Docker/runc resolves the bind-mount
|
||||
destination through the symlink to its target directory rather than
|
||||
binding to the symlink dentry itself, so the symlink entry stays on the
|
||||
writable `~/.claude` mount, still replaceable. There is no mount-based
|
||||
fix for this (`~/.claude` can't be made read-only - session history has
|
||||
to stay writable). The compensating control is detection, not
|
||||
prevention: `bin/dejima-audit` hashes `statusline.sh` (following
|
||||
a symlink to its target *path string*, so a repointed or replaced
|
||||
symlink is caught even though its own inode doesn't change) and reports
|
||||
it as a host-code-execution finding when the session exits. This is a
|
||||
defense-in-depth gap, not an open one: exploiting it requires a sandbox
|
||||
session that's already compromised, and that compromise is caught on
|
||||
exit either way.
|
||||
- `~/.claude.json` mixes that same kind of live state (project registry,
|
||||
usage stats, caches) with config (`mcpServers`). Unlike the four above,
|
||||
it can't just be layered read-only: Claude Code does an atomic
|
||||
write-temp-then-rename against it during normal startup, before login
|
||||
even completes, and a read-only file makes that **hang** instead of
|
||||
failing cleanly (confirmed against a real read-only copy of this file -
|
||||
upstream bug: [anthropics/claude-code#29162](https://github.com/anthropics/claude-code/issues/29162)).
|
||||
Instead, `dejima` generates a throwaway copy of the file at
|
||||
launch (seeded from the real file, same "generate a sandbox-specific
|
||||
file on the host" pattern as the generated `settings.json` copy above)
|
||||
and mounts *that* read-write in its place; the copy is discarded,
|
||||
unmodified in the real file, when the session ends. A compromised
|
||||
session can add whatever it wants to `mcpServers` (or anything else in
|
||||
the file) - it never reaches the real `~/.claude.json`, so there's
|
||||
nothing to persist. This also means multiple concurrent sandbox sessions
|
||||
never contend over the same file - each gets its own independent copy.
|
||||
|
||||
### Throwaway ~/.claude.json: what doesn't carry over
|
||||
|
||||
Because nothing writes back, anything a *sandboxed* session would
|
||||
normally have caused Claude Code to record into `~/.claude.json` is lost
|
||||
when that session ends, instead of showing up in a later host-side
|
||||
`claude` run. Concretely, as of the fields this file holds today:
|
||||
|
||||
- **Per-project trust/approval state** - `hasTrustDialogAccepted`,
|
||||
`allowedTools`, `enabledMcpjsonServers`/`disabledMcpjsonServers` - for
|
||||
any project directory opened only inside the sandbox. Opening that same
|
||||
project on the host later re-prompts for whatever it would have
|
||||
prompted for outside the sandbox too. Largely moot today since the
|
||||
sandbox always runs `--dangerously-skip-permissions`, but the trust
|
||||
dialog is a separate gate from tool-permission prompts.
|
||||
- **`mcpServers` additions or edits** - the point of this fix, not an
|
||||
incidental side effect. Nothing added or changed here during a
|
||||
sandboxed session ever reaches the real file.
|
||||
- **Session/usage telemetry** - `lastCost`, `lastDuration`,
|
||||
`lastTotalInputTokens`/`lastTotalOutputTokens`, `lastModelUsage`,
|
||||
`lastSessionId`, `mcpContextUris`, and similar per-project fields.
|
||||
Sandbox activity won't show up in host-side usage totals or in
|
||||
`claude --resume`'s "last session" metadata for that project.
|
||||
- **Global counters and caches** - `numStartups`, `firstStartTime`,
|
||||
`cachedGrowthBookFeatures`, `changelogLastFetched`,
|
||||
`announcementImpressions`, `tipsHistory`, `feedbackSurveyState`, and
|
||||
similar top-level bookkeeping. Purely cosmetic - these get refetched or
|
||||
recomputed as needed; sandbox runs just don't contribute to the host's
|
||||
copies of them.
|
||||
- **`oauthAccount`/`userID`/`machineID`** are read fresh from the real
|
||||
file at the start of every sandbox launch, so authentication carries in
|
||||
normally each time; anything that changes about them *during* a
|
||||
sandboxed session (e.g. a token refresh) doesn't carry back out. Worth
|
||||
re-checking if a future Claude Code version starts relying on that for
|
||||
normal operation.
|
||||
- DNS resolution itself is allowlisted (see "DNS is allowlisted too" in the
|
||||
README),
|
||||
not just the IP connections made afterward, closing off exfiltration via
|
||||
encoding data into query names to an attacker-controlled domain - verified
|
||||
a non-allowlisted domain gets refused outright (`status: REFUSED`) rather
|
||||
than resolving and then only failing to connect.
|
||||
- Don't mount other host secrets (`~/.ssh` private keys, cloud credential
|
||||
files) into the container. Git push auth goes through the forwarded SSH
|
||||
agent instead, which never exposes the key material itself - though note
|
||||
the agent will sign for *any* Gitea repo your key has access to, not just
|
||||
the mounted project, and the firewall's Gitea allowlist is host-based
|
||||
rather than repo-scoped, so a compromised session can still reach (and
|
||||
push to) other repos on that host you have access to. This doesn't extend
|
||||
to GitHub by default: port 22 there is blocked unless `ALLOW_GITHUB_PUSH=1`
|
||||
is set, so the forwarded agent has no transport to use for it at all
|
||||
otherwise (see "GitHub push: blocked by default, optional opt-in" above).
|
||||
- `SANDBOX_SSH_KEY` (see "Headless git push without agent forwarding") is a
|
||||
second, independent credential-loading path with the same "signs for any
|
||||
Gitea repo it has access to" shape as the forwarded agent above - the
|
||||
mitigation is that it's meant to be a dedicated deploy key scoped to only
|
||||
the repos that actually need push access, not your personal identity.
|
||||
That scoping is a convention, not something enforced here: nothing checks
|
||||
what `SANDBOX_SSH_KEY` actually points at, so pointing it at your real
|
||||
`~/.ssh/id_ed25519`/`id_rsa` instead of a dedicated key would load your
|
||||
full personal identity into the container for the whole session (`bin/
|
||||
dejima` warns if this looks like it might be happening - see
|
||||
below - but doesn't block it). The key source itself is mounted read-only
|
||||
at `/root/...`, not under the sandbox user's own home, specifically so
|
||||
that user (which the session actually runs as) has no path to read the
|
||||
raw key material - only the agent socket it's loaded into.
|
||||
- The gitconfig copy `dejima` generates (see that mount's
|
||||
comment in `docker-compose.yml`) carries over whatever's in your real
|
||||
`~/.gitconfig` verbatim. If yours has inline credentials (a
|
||||
`[credential]` token, a `url.<token@>.insteadOf` rewrite, a
|
||||
`credential.helper store` path), those enter the container along with
|
||||
it - an unadvertised secret-carrying path distinct from the
|
||||
SSH-agent/token story documented above. Keep credentials out of your
|
||||
global gitconfig, or expect them to be reachable from inside the
|
||||
sandbox.
|
||||
- The sandbox's own user (matching your host user — see "Config parity" in
|
||||
the README) has no `sudo`/setuid path back to root once the container is up:
|
||||
`entrypoint.sh` runs as root, sets up the firewall directly, then drops to
|
||||
that user via `gosu` before execing `$SANDBOX_CMD`. A compromised session can't
|
||||
re-run `init-firewall.sh` with a wider allowlist to open its own
|
||||
exfiltration path. Verified: capabilities (`NET_ADMIN`/`NET_RAW`) are
|
||||
cleared from the effective/permitted sets on the `gosu` drop, and that
|
||||
user gets a permission error running `iptables` directly.
|
||||
- The approval store (`${DEJIMA_CONFIG_DIR:-$HOME/.config/dejima}`,
|
||||
see "Global and per-project domains and dependencies" above) lives outside
|
||||
this repo, and outside every project mount, by default - unlike a
|
||||
project's own `.dejima/` declaration files, it's never
|
||||
bind-mounted into the container, so a compromised
|
||||
session has no path to write to it at all, let alone plant something for
|
||||
the *next* launch to pick up. Its files are read by a small
|
||||
line-oriented parser (`approved_entries` in
|
||||
`bin/dejima-project-lib.sh`), never sourced or executed, and are
|
||||
written only by the CLIs (`dejima-allow`, `dejima-dep`,
|
||||
`dejima-project`) a human runs on the host. `dejima`
|
||||
warns at launch whenever the resolved store path — default or overridden
|
||||
via `DEJIMA_CONFIG_DIR` — lands under the mounted workspace,
|
||||
rather than silently trusting it.
|
||||
- When the mounted workspace *is* this repo itself, `dejima`
|
||||
warns at launch that the repo's own trusted computing base - `bin/`,
|
||||
`hooks/`, `docker/`, `install.sh` (also `test/`, per the
|
||||
exit-time audit's exact TCB path list above) - is writable for the
|
||||
session (it's inside the project mount, which has no write protection,
|
||||
per above), and that the exit-time audit will report any changes to it.
|
||||
That's a warning and a post-hoc report, not a block: a compromised
|
||||
session can still edit those files during the run, and whatever it
|
||||
changes takes effect the *next* time `dejima` itself runs,
|
||||
not the current one.
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# Notification/Stop hook. bin/dejima registers it, per session, into the
|
||||
# throwaway settings.json copy it generates for a session that has remote
|
||||
# access enabled, and nowhere else. Every sandbox gets such a copy, derived
|
||||
# from the operator's real settings.json; registering it in that real file
|
||||
# instead would put this hook into all of them - and into the host's own
|
||||
# Claude Code, which reads that file directly - where there is no marker
|
||||
# directory to write into. The copy is mounted read-only (see
|
||||
# docker-compose.yml), so a session cannot unregister its own hook.
|
||||
#
|
||||
# It writes one empty file whose NAME is its entire meaning. No content, no
|
||||
# stdin, no network. The host-side watcher composes the whole notification
|
||||
# from host data, so not one byte from inside the sandbox reaches the
|
||||
# operator's phone. Anything more expressive here would be an injection
|
||||
# channel into a device that sits outside the sandbox - see
|
||||
# docs/remote-access.md on why the phone is the thing being protected.
|
||||
set -euo pipefail
|
||||
|
||||
# A whitelist, not a filter: an unrecognized event creates nothing at all.
|
||||
# Sanitizing an arbitrary name into a marker would make the file name a
|
||||
# channel the sandbox controls, which is the one thing this hook must not be.
|
||||
case "${1:-}" in
|
||||
waiting|done) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
# Absent outside a remote-enabled session, and the directory is a bind mount
|
||||
# that only exists there. Exit quietly rather than fail: a hook that errored
|
||||
# in an ordinary sandbox would surface on every notification, and there is
|
||||
# nothing for it to do. Never created here either - the host owns that
|
||||
# directory's lifetime (bin/dejima makes it and rm -rf's it on exit).
|
||||
[ -n "${DEJIMA_NOTIFY_DIR:-}" ] || exit 0
|
||||
[ -d "$DEJIMA_NOTIFY_DIR" ] || exit 0
|
||||
|
||||
: > "$DEJIMA_NOTIFY_DIR/$1"
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
# PreToolUse hook: inside dejima, block `gitea-pr merge`. The sandbox
|
||||
# workflow is feature branch -> `gitea-pr create` -> a human merges; what
|
||||
# lands on main is an operator decision, exactly as it already is for
|
||||
# `git push` (see git-push-guard.sh, whose no-direct-to-main rule this is
|
||||
# the API-side counterpart to - a merge reaches main without a push, so that
|
||||
# guard never sees it).
|
||||
#
|
||||
# Deliberately not conditional on intent. A hook is handed a command, not a
|
||||
# conversation, so it cannot tell an instructed merge from an unprompted one;
|
||||
# the only honest place to draw the line is the context, and the context that
|
||||
# needs the line is this one - headless unattended runs under
|
||||
# --dangerously-skip-permissions, where nobody is watching the turn happen.
|
||||
# On the host the hook is a no-op and an operator can still ask for a merge.
|
||||
#
|
||||
# Like its siblings this is a fast-fail guard against an overeager agent, not
|
||||
# a hard security boundary: a sandbox session has full shell access and the
|
||||
# Gitea token in its environment, so it could call the merge endpoint with
|
||||
# curl directly. It stops the mistake, not the attack.
|
||||
|
||||
if [ -z "${DEJIMA:-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
command=$(jq -r '.tool_input.command // empty')
|
||||
|
||||
# The leading class includes '/' so a path-qualified call is caught too -
|
||||
# /usr/local/bin/gitea-pr is how the image installs it, and is what an agent
|
||||
# reaching past a shadowed PATH would type. Word-splitting rather than a full
|
||||
# shell parse, the same pragmatic tradeoff the sibling hooks make.
|
||||
if [[ "$command" =~ (^|[[:space:]&\|\;/])gitea-pr[[:space:]]+merge([[:space:]]|$) ]]; then
|
||||
echo "Blocked by gitea-merge-guard.sh: merging is a human decision in the sandbox - the workflow is 'gitea-pr create', then the operator merges. Report the PR URL and stop." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -169,13 +169,22 @@ What this means:
|
||||
project. ~/shared is also a real, persistent host directory (not part of
|
||||
the project) - use it to hand the user a file (a screenshot, a temp
|
||||
script) without touching the project checkout, or to pick up something
|
||||
they left there for you. Everything else in the container besides
|
||||
~/.claude is disposable: installed system packages, anything outside the
|
||||
mounted paths, and the container itself go away when this run ends.
|
||||
they left there for you. ~/.claude is bind-mounted too, so this session's
|
||||
transcript and its project memory outlive the container - the work is
|
||||
recoverable on the host with the \`--resume\` command at the end of this
|
||||
briefing. Everything else is disposable: installed system packages,
|
||||
anything outside the mounted paths, and the container itself go away when
|
||||
this run ends.
|
||||
- Outbound network is restricted to an allowlist (Anthropic's API, the npm
|
||||
registry, GitHub read-only, the configured Gitea host, plus anything
|
||||
approved via dejima-allow). Requests to anything else will simply
|
||||
fail to connect. If a task needs one more domain and nothing else, that's
|
||||
fail to connect. That restriction is not only a limit on your reach: it
|
||||
is also what bounds where a prompt injection or a compromised dependency
|
||||
in this session could send the user's code or fetch its next instruction
|
||||
from. You cannot audit yourself for that from the inside, so the
|
||||
allowlist is load-bearing for you too, not just for the host - working
|
||||
around it would give up that protection, not merely lift the one limit
|
||||
it appears to. If a task needs one more domain and nothing else, that's
|
||||
a quick fix, not a reason to stop: tell the user to run
|
||||
\`bin/dejima-allow <domain> --container $container_id\` (add
|
||||
\`--global\` to persist it past this session) from a terminal outside the
|
||||
@@ -194,10 +203,9 @@ Git workflow - do this even though nothing stops you from doing otherwise:
|
||||
\`git worktree add .worktrees/<branch> -b <branch>\` run from the project
|
||||
root - never \`../<branch>\` or any other path outside it, including
|
||||
anywhere under \$HOME. Only the exact directory bind-mounted into this
|
||||
container is backed by real storage on the host; everything else,
|
||||
including what looks like the project's own parent directory, is
|
||||
container-local and silently disappears when the session ends - a
|
||||
worktree created there is real work that just gets thrown away. Add
|
||||
container is backed by real storage on the host - what looks like the
|
||||
project's own parent directory is not, so a worktree created there is
|
||||
real work that just gets thrown away. Add
|
||||
\`.worktrees/\` to \`.git/info/exclude\` (not \`.gitignore\` - this is
|
||||
sandbox-local, not something to commit) so it doesn't clutter \`git
|
||||
status\` for the user's real checkout.
|
||||
@@ -206,6 +214,18 @@ Git workflow - do this even though nothing stops you from doing otherwise:
|
||||
push regardless), and never push directly to main/master, on any remote.
|
||||
A \`git-push-guard.sh\` hook enforces both as a backstop, but treat them
|
||||
as the actual workflow to follow, not just a filter to route around.
|
||||
Merging is the operator's call, not yours - a \`gitea-merge-guard.sh\`
|
||||
hook blocks it here. Report the PR URL and stop.
|
||||
- Write PR and issue bodies to a file and pass \`--body-file FILE\`, rather
|
||||
than \`--body "..."\` or a heredoc. Both of those put the entire body into
|
||||
the Bash command string, where \`deny-dangerous-bash.sh\` reads it - and
|
||||
that hook matches its patterns anywhere in the string, with no way to tell
|
||||
an instruction from prose that merely mentions one. A body describing a
|
||||
crash, a restart, or a destructive command it is warning about will get
|
||||
the call blocked, which looks like a broken tool rather than what it is.
|
||||
Writing the file first keeps the prose out of the command entirely.
|
||||
\`gitea-issue\` (list/view/create/comment/close/reopen) takes the same
|
||||
flag, for recording a problem you found but aren't fixing.
|
||||
|
||||
If a task genuinely needs something else the sandbox can't provide -
|
||||
broader network access than a domain or two, host-level or system changes,
|
||||
|
||||
+44
-4
@@ -1,10 +1,13 @@
|
||||
#!/bin/bash
|
||||
# Idempotent installer for dejima. Symlinks this repo's hooks into
|
||||
# ~/.claude/hooks, registers them in ~/.claude/settings.json, and puts
|
||||
# bin/dejima, bin/dejima-allow, bin/dejima-dep and
|
||||
# bin/dejima-project on PATH. Safe to re-run -
|
||||
# every step is a no-op if already done, and every change is printed as it
|
||||
# happens.
|
||||
# bin/dejima, bin/dejima-allow, bin/dejima-dep, bin/dejima-project and the
|
||||
# opt-in remote-access scripts on PATH. Safe to re-run - every step is a
|
||||
# no-op if already done, and every change is printed as it happens.
|
||||
#
|
||||
# It never enables anything: the remote-access scripts do nothing without
|
||||
# ~/.config/dejima/remote.conf, and the user units in systemd/ are left for
|
||||
# the operator to install by hand.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
@@ -50,6 +53,14 @@ link_hook() {
|
||||
|
||||
link_hook sandbox-context.sh
|
||||
link_hook git-push-guard.sh
|
||||
link_hook gitea-merge-guard.sh
|
||||
|
||||
# Linked so it exists at a stable path for the read-only ~/.claude/hooks
|
||||
# mount, but deliberately NOT passed to register_hook below: bin/dejima
|
||||
# registers it per session, into a throwaway settings copy, and only when
|
||||
# remote access is configured. Registering it here would fire it for every
|
||||
# unsandboxed host session too, where it has no marker directory to write to.
|
||||
link_hook dejima-notify.sh
|
||||
|
||||
# deny-dangerous-bash.sh is a general (non-sandbox-gated) hook that may
|
||||
# already be owned by another install (e.g. dotfiles) - only claim it if
|
||||
@@ -90,6 +101,8 @@ register_hook SessionStart "startup|resume|clear|compact" sandbox-context.sh \
|
||||
"injects sandbox context on session start"
|
||||
register_hook PreToolUse Bash git-push-guard.sh \
|
||||
"blocks pushes outside the allowed host(s)/branches"
|
||||
register_hook PreToolUse Bash gitea-merge-guard.sh \
|
||||
"blocks 'gitea-pr merge' inside the sandbox"
|
||||
register_hook PreToolUse Bash deny-dangerous-bash.sh \
|
||||
"blocks obviously destructive Bash commands"
|
||||
echo
|
||||
@@ -130,7 +143,34 @@ link_bin dejima
|
||||
link_bin dejima-allow
|
||||
link_bin dejima-dep
|
||||
link_bin dejima-project
|
||||
|
||||
# Remote access (opt-in, see docs/remote-access.md). All three are inert
|
||||
# without ~/.config/dejima/remote.conf - they refuse to run at all - so linking
|
||||
# unconditionally adds no front door and costs nothing. The user units in
|
||||
# systemd/ are deliberately NOT copied or enabled here: standing up a front
|
||||
# door is an explicit operator decision, and this script never touches
|
||||
# systemd.
|
||||
link_bin dejima-attach
|
||||
link_bin dejima-ttyd
|
||||
link_bin dejima-notify-watch
|
||||
echo
|
||||
|
||||
echo "Done. Try: $BIN_DIR/dejima ~/some/project"
|
||||
echo "(or just 'dejima ~/some/project' once $BIN_DIR is on PATH)"
|
||||
echo
|
||||
# Conditional, because the unconditional version is a false statement to the
|
||||
# operator most likely to see it: someone with a front door already up,
|
||||
# re-running this after a git pull.
|
||||
# From the library rather than spelled out again here: the config root has
|
||||
# one definition (config_root, which honours DEJIMA_CONFIG_DIR) and a second
|
||||
# copy of it would drift without anything noticing.
|
||||
# shellcheck source=bin/dejima-project-lib.sh
|
||||
source "$SCRIPT_DIR/bin/dejima-project-lib.sh"
|
||||
remote_conf="$(remote_conf_path)"
|
||||
if [ -f "$remote_conf" ]; then
|
||||
echo "Remote access is configured ($remote_conf); this script did not start or stop anything."
|
||||
echo "See docs/remote-access.md."
|
||||
else
|
||||
echo "Remote access is available but not configured - nothing is listening."
|
||||
echo "See docs/remote-access.md to turn it on."
|
||||
fi
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,42 @@
|
||||
# User unit for the dejima web front door. Not enabled by install.sh -
|
||||
# standing up a front door is an explicit operator decision:
|
||||
#
|
||||
# mkdir -p ~/.config/systemd/user
|
||||
# cp systemd/dejima-ttyd.service ~/.config/systemd/user/
|
||||
# systemctl --user daemon-reload
|
||||
# systemctl --user enable --now dejima-ttyd.service
|
||||
#
|
||||
# It does nothing useful until ~/.config/dejima/remote.conf exists; without
|
||||
# it dejima-ttyd exits 1 and the restart limit below stops the unit instead
|
||||
# of retrying forever.
|
||||
[Unit]
|
||||
Description=dejima remote session terminal (ttyd)
|
||||
# A missing or malformed remote.conf fails every start identically, so give
|
||||
# up visibly (systemctl --user status says why) rather than restarting for
|
||||
# the life of the login session.
|
||||
StartLimitIntervalSec=60
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
# The bind address and the port both come from bin/dejima-ttyd, which reads
|
||||
# remote.conf - do not repeat either of them here, or the two will drift.
|
||||
# %h, not an absolute path: this file is the same for every operator.
|
||||
ExecStart=%h/.local/bin/dejima-ttyd
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# Deliberately unhardened, which is worth stating rather than leaving as an
|
||||
# omission: this unit exists to hand out a host shell (ttyd -> dejima-attach
|
||||
# -> a tmux client on the operator's own server), so the usual directives
|
||||
# would break it rather than contain it. PrivateTmp= would hide the tmux
|
||||
# server socket under /tmp/tmux-$UID; ProtectHome= would hide the scripts
|
||||
# above and the repo they link to; NoNewPrivileges= would break sudo inside
|
||||
# the attached session. What contains this feature is the loopback bind in
|
||||
# bin/dejima-ttyd, its --check-origin, and authentication in the reverse
|
||||
# proxy - see docs/nginx-dejima.conf.example - not unit sandboxing.
|
||||
#
|
||||
# No After= either: a user manager has no ordering to offer against the
|
||||
# network, and none is needed - the listener is on loopback.
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
+2
-2
@@ -162,8 +162,8 @@ EOF
|
||||
}
|
||||
|
||||
@test "project_slug matches the log-shipper project-key rule" {
|
||||
run bash -c 'source "$1"; project_slug "/home/brad/my-proj"' _ "$LIB"
|
||||
[ "$output" = "-home-brad-my-proj" ]
|
||||
run bash -c 'source "$1"; project_slug "/home/someone/my-proj"' _ "$LIB"
|
||||
[ "$output" = "-home-someone-my-proj" ]
|
||||
}
|
||||
|
||||
@test "ensure_project_store creates the store and records the real path" {
|
||||
|
||||
+2530
File diff suppressed because it is too large
Load Diff
+141
-3
@@ -73,12 +73,25 @@ setup_file() {
|
||||
docker network rm "$network" >/dev/null 2>&1 || true
|
||||
docker network create "$network" >/dev/null
|
||||
|
||||
# No GITEA_*/GH_TOKEN/LOKI_URL: this suite only asserts the default
|
||||
# allowlist and enforcement, which shouldn't depend on any of them.
|
||||
# No GITEA_*/LOKI_URL: this suite only asserts the default allowlist and
|
||||
# enforcement, which shouldn't depend on either.
|
||||
#
|
||||
# GH_TOKEN is forwarded rather than dropped, and every container start in
|
||||
# this file does the same. It doesn't change the allowlist - it only
|
||||
# authenticates init-firewall.sh's api.github.com/meta fetch, which
|
||||
# returns the same ranges either way. What it changes is the quota: that
|
||||
# endpoint allows 60 requests an hour per source IP unauthenticated, and
|
||||
# this file starts thirteen containers, so two runs in an hour exhaust it
|
||||
# and every container after that dies in init-firewall.sh with a rate
|
||||
# limit error rather than anything to do with what was being tested.
|
||||
# Locally it's usually unset and expands empty, which both init-firewall
|
||||
# and entrypoint treat exactly as absent - so the no-token path stays the
|
||||
# default here and only CI, where the volume is, passes a real one.
|
||||
docker run -d --name "$container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$network" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-e ANTHROPIC_BASE_URL= \
|
||||
-e ANTHROPIC_AUTH_TOKEN= \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
@@ -93,6 +106,19 @@ teardown_file() {
|
||||
}
|
||||
|
||||
teardown() {
|
||||
# Host listeners started by start_host_listener (unset for every other
|
||||
# test - each test re-sources this file in its own shell, so nothing
|
||||
# leaks in from the previous one). Killing them here rather than at the
|
||||
# end of the test body is what makes the teardown unconditional: bats
|
||||
# runs test bodies under errexit, so any failed assertion abandons the
|
||||
# rest of the body, and a kill sitting there would leave a python
|
||||
# process holding a port on the operator's host. teardown runs in the
|
||||
# same shell as the body even after the body aborted, so the PIDs it
|
||||
# set are visible here. An EXIT trap in the body is not the
|
||||
# alternative - it replaces the one bats installs and the run dies
|
||||
# reporting zero executed tests.
|
||||
local pid
|
||||
for pid in ${HOST_LISTENER_PIDS:-}; do kill "$pid" 2>/dev/null || true; done
|
||||
docker rm -f "dejima-test-override-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rmi "dejima-test-override:$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "dejima-test-sshkey-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
@@ -134,6 +160,7 @@ dexec_node() {
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$test_image" >/dev/null
|
||||
|
||||
@@ -161,6 +188,7 @@ dexec_node() {
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-v "$key_dir/testkey:/root/.ssh-sandbox-key-src:ro" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
@@ -215,6 +243,7 @@ dexec_node() {
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-v "$mounted_copy:/home/node/.claude.json" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
@@ -273,6 +302,7 @@ dexec_node() {
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-e ALLOW_GITHUB_PUSH=1 \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
@@ -291,6 +321,7 @@ dexec_node() {
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-e GITEA_SSH_HOST=gitea.internal.example \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
@@ -318,6 +349,7 @@ dexec_node() {
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-v "$src/skills:/home/node/.claude/skills:ro" \
|
||||
-v "$src/commands:/home/node/.claude/commands:ro" \
|
||||
-v "$src/agents:/home/node/.claude/agents:ro" \
|
||||
@@ -348,11 +380,19 @@ dexec_node() {
|
||||
local dotclaude; dotclaude="$(mktemp -d)"
|
||||
printf '#!/bin/sh\necho original\n' > "$dotclaude/statusline.sh"
|
||||
chmod +x "$dotclaude/statusline.sh"
|
||||
# mktemp -d is mode 700, which only works because the uid running these
|
||||
# tests is usually the same one the container's node maps to. Where it
|
||||
# isn't - CI runs as root, node is 1000 - node cannot even traverse this
|
||||
# directory, and the read below fails having proved nothing. Writable
|
||||
# rather than merely readable so that the :ro mount stays the only
|
||||
# reason the two writes above it fail.
|
||||
chmod 777 "$dotclaude"
|
||||
|
||||
docker run -d --name "$test_container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-v "$dotclaude:/home/node/.claude" \
|
||||
-v "$dotclaude/statusline.sh:/home/node/.claude/statusline.sh:ro" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
@@ -415,11 +455,18 @@ dexec_node() {
|
||||
chmod +x "$target_dir/statusline.sh"
|
||||
dotclaude="$(mktemp -d)"
|
||||
ln -s "$target_dir/statusline.sh" "$dotclaude/statusline.sh"
|
||||
# Both mode 700 from mktemp -d; see the regular-file test above. This one
|
||||
# asserts the overwrite *succeeds*, so node needs to be able to unlink
|
||||
# and create in $dotclaude and write through the symlink into
|
||||
# $target_dir - otherwise the confirmed gap this documents would look
|
||||
# closed for a reason that has nothing to do with the gap.
|
||||
chmod 777 "$target_dir" "$dotclaude"
|
||||
|
||||
docker run -d --name "$test_container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-v "$dotclaude:/home/node/.claude" \
|
||||
-v "$target_dir:$target_dir:ro" \
|
||||
-v "$dotclaude/statusline.sh:/home/node/.claude/statusline.sh:ro" \
|
||||
@@ -556,12 +603,22 @@ EOF
|
||||
--upstream "http://127.0.0.1:$(cat "$TD/port")" &
|
||||
PROXY_PID=$!
|
||||
for _ in $(seq 1 50); do [ -S "$TD/proxy.sock" ] && break; sleep 0.1; done
|
||||
# UnixStreamServer binds at umask default, so the socket lands 755, and
|
||||
# connecting to a unix socket needs *write* permission on it. In the real
|
||||
# flow the connecting uid is the one that bound it, so that is fine;
|
||||
# under CI it is root binding and node connecting, and the whole chain
|
||||
# this test follows fails at its first hop. Widened rather than left to
|
||||
# dejima-anthropic-proxy, which has no such problem in production.
|
||||
# Unconditional: if the loop above timed out, "No such file" here says so
|
||||
# more clearly than a test that fails on a bare false condition.
|
||||
chmod 666 "$TD/proxy.sock"
|
||||
|
||||
container="dejima-test-proxy-$(basename "$BATS_FILE_TMPDIR")"
|
||||
docker run -d --name "$container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-e SANDBOX_ANTHROPIC_PROXY_SOCKET="$TD/proxy.sock" \
|
||||
-e ANTHROPIC_AUTH_TOKEN=sandbox-credential-proxy-placeholder \
|
||||
-v "$TD:$TD" \
|
||||
@@ -621,6 +678,7 @@ EOF
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-v "$TD/dotclaude:/home/node/.claude" \
|
||||
-v "$mask_file:/home/node/.claude/.credentials.json" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
@@ -704,13 +762,22 @@ EOF
|
||||
printf 'declared-not-allowed.example\n' > "$ws/.dejima/allowed-domains"
|
||||
chmod -R 755 "$ws"
|
||||
|
||||
# Mounted from a copy, not straight out of the checkout. Whatever daemon
|
||||
# starts this container resolves -v sources against its own host, and
|
||||
# under CI the checkout lives in a volume that has no host path at all -
|
||||
# so the repo copy would mount as an empty directory rather than fail.
|
||||
# $BATS_TEST_TMPDIR sits under TMPDIR, which CI points somewhere shared.
|
||||
local hook="$BATS_TEST_TMPDIR/sandbox-context.sh"
|
||||
cp "$BATS_TEST_DIRNAME/../hooks/sandbox-context.sh" "$hook"
|
||||
|
||||
local test_container="dejima-test-ctxreq-$(basename "$BATS_FILE_TMPDIR")"
|
||||
docker run -d --name "$test_container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
-v "$ws:$ws" \
|
||||
-v "$BATS_TEST_DIRNAME/../hooks/sandbox-context.sh:/usr/local/bin/sandbox-context.sh:ro" \
|
||||
-v "$hook:/usr/local/bin/sandbox-context.sh:ro" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
wait_ready "$test_container"
|
||||
@@ -752,6 +819,7 @@ EOF
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-e GH_TOKEN="${GH_TOKEN:-}" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$derived" >/dev/null
|
||||
wait_ready "$test_container"
|
||||
@@ -789,3 +857,73 @@ EOF
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"proxy-ok"* ]]
|
||||
}
|
||||
|
||||
# Starts a host-side TCP listener bound to $1, waits until it accepts a
|
||||
# connection, and leaves its port in HOST_LISTENER_PORT. Binding port 0 and
|
||||
# reading the port back (the same trick fixtures/fake-anthropic.py uses) is
|
||||
# what keeps the test below honest: the kernel only hands out a port nothing
|
||||
# else holds, so whatever answers is provably this listener and not some
|
||||
# unrelated service that happened to own a hardcoded number. The handler has
|
||||
# no do_* methods, so it serves nothing at all - only the accept() matters,
|
||||
# and the gateway-bound instance is briefly reachable by every container on
|
||||
# this host's bridge. Called plainly, never in a command substitution: it has
|
||||
# to append to HOST_LISTENER_PIDS in the caller's shell for teardown to see it.
|
||||
start_host_listener() {
|
||||
local bind_addr="$1" port_file="$BATS_TEST_TMPDIR/listener-port-$1"
|
||||
python3 -c 'import sys
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
s = ThreadingHTTPServer((sys.argv[1], 0), BaseHTTPRequestHandler)
|
||||
print(s.server_address[1], flush=True)
|
||||
s.serve_forever()' "$bind_addr" > "$port_file" 2>/dev/null &
|
||||
HOST_LISTENER_PIDS="${HOST_LISTENER_PIDS:-} $!"
|
||||
|
||||
for _ in $(seq 1 50); do [ -s "$port_file" ] && break; sleep 0.1; done
|
||||
HOST_LISTENER_PORT="$(cat "$port_file")"
|
||||
[ -n "$HOST_LISTENER_PORT" ]
|
||||
timeout 5 bash -c "cat < /dev/null > /dev/tcp/$bind_addr/$HOST_LISTENER_PORT"
|
||||
}
|
||||
|
||||
@test "a host service bound to loopback is unreachable from inside the container, one bound to the bridge gateway is reachable" {
|
||||
# The regression test for the escape path bin/dejima-ttyd's
|
||||
# --interface 127.0.0.1 exists to close. init-firewall.sh deliberately
|
||||
# ACCEPTs OUTPUT to the Docker bridge gateway, and a tmux client attached
|
||||
# through ttyd is a host shell - the prefix key opens a window outside the
|
||||
# container - so a ttyd the sandbox could reach would be a full escape.
|
||||
# Prove the containment property holds rather than trusting the flag.
|
||||
local container host_ip loopback_port gateway_port
|
||||
container="$(container_name)"
|
||||
|
||||
# Derived exactly as init-firewall.sh derives HOST_IP, so this is the one
|
||||
# host address the container's OUTPUT chain accepts at all.
|
||||
host_ip="$(docker exec "$container" sh -c "ip route | grep default | cut -d' ' -f3")"
|
||||
[ -n "$host_ip" ]
|
||||
|
||||
start_host_listener 127.0.0.1
|
||||
loopback_port="$HOST_LISTENER_PORT"
|
||||
start_host_listener "$host_ip"
|
||||
gateway_port="$HOST_LISTENER_PORT"
|
||||
|
||||
# Setup check, from the host's own vantage point: the loopback listener's
|
||||
# port must answer on 127.0.0.1 only. bind(0) on two different addresses
|
||||
# can legally return the same port number, and if it had, the container
|
||||
# probe below would be hitting the gateway listener and failing for a
|
||||
# reason that has nothing to do with containment.
|
||||
run timeout 5 bash -c "cat < /dev/null > /dev/tcp/$host_ip/$loopback_port"
|
||||
[ "$status" -ne 0 ]
|
||||
|
||||
# Positive control, and the reason the negative one means anything: same
|
||||
# container, same docker exec, same timeout/bash /dev/tcp probe, same
|
||||
# gateway address - only the port differs. Without it a `status -ne 0` is
|
||||
# equally satisfied by docker exec failing (125), bash or timeout missing
|
||||
# from the image (127), or a container that can reach nothing at all, none
|
||||
# of which say anything about loopback.
|
||||
run docker exec "$container" timeout 5 bash -c "cat < /dev/null > /dev/tcp/$host_ip/$gateway_port"
|
||||
[ "$status" -eq 0 ]
|
||||
|
||||
# Deliberately not asserting the exact status. 1 (the host RSTs, since
|
||||
# nothing is bound on the gateway address at this port) is what it should
|
||||
# be, but a host that DROPs on its own docker bridge instead yields 124
|
||||
# from timeout, and that is still containment.
|
||||
run docker exec "$container" timeout 5 bash -c "cat < /dev/null > /dev/tcp/$host_ip/$loopback_port"
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
+154
-4
@@ -49,6 +49,48 @@ setup() {
|
||||
grep -qE '^\s*ENV\s+NODE_PATH=' "$SANDBOX_DIR/Dockerfile"
|
||||
}
|
||||
|
||||
# One awk pass rather than a grep chain: a filtering grep exits 1 when it
|
||||
# discards every line, which here is the "nothing wrong" case, so the chain
|
||||
# would report its own success as a failure. Comment lines are skipped - one
|
||||
# of them quotes, verbatim, the invocation the second test bans.
|
||||
@test "every build-time download in the Dockerfile retries rather than dying on one blip" {
|
||||
# These fetches all sit below the SANDBOX_* ARGs, so any build overriding
|
||||
# one of those - test/runtime.bats' first test, or simply a host whose uid
|
||||
# isn't 1000 - invalidates the cache and re-runs every one of them. The
|
||||
# odds that matter are then the odds of *any* of them blipping, not of a
|
||||
# particular one, and a single-shot fetch turns that into a dead build.
|
||||
local unguarded
|
||||
unguarded=$(awk '
|
||||
/^[[:space:]]*#/ { next }
|
||||
/wget/ && !/--tries=[0-9]/ { print FNR": "$0 }
|
||||
/curl/ && !/--retry [0-9]/ { print FNR": "$0 }
|
||||
' "$SANDBOX_DIR/Dockerfile")
|
||||
[ -z "$unguarded" ] || {
|
||||
echo "these downloads have no retry flags:" >&2
|
||||
echo "$unguarded" >&2
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@test "the zsh installer runs from a file, so a failed download fails the build" {
|
||||
# Upstream documents `sh -c "$(wget -O- ...)"`. In that form a failed
|
||||
# download substitutes to an empty string and `sh -c ""` exits 0, so the
|
||||
# build goes green having installed nothing - the kind of failure that
|
||||
# only surfaces later, as a shell that is inexplicably unconfigured.
|
||||
local substituted
|
||||
substituted=$(awk '
|
||||
/^[[:space:]]*#/ { next }
|
||||
/sh -c "\$\((wget|curl)/ { print FNR": "$0 }
|
||||
' "$SANDBOX_DIR/Dockerfile")
|
||||
[ -z "$substituted" ] || {
|
||||
echo "a failed download here would exit 0 and install nothing:" >&2
|
||||
echo "$substituted" >&2
|
||||
false
|
||||
}
|
||||
grep -q -- '-O /tmp/zsh-in-docker.sh' "$SANDBOX_DIR/Dockerfile"
|
||||
grep -qE '^\s*sh /tmp/zsh-in-docker.sh' "$SANDBOX_DIR/Dockerfile"
|
||||
}
|
||||
|
||||
@test "every ipset add in init-firewall.sh is duplicate-safe" {
|
||||
# Same bug class that broke firewall startup twice (718f6b7, a86fefc):
|
||||
# ipset add without -exist errors on a duplicate entry, and set -e turns
|
||||
@@ -316,13 +358,85 @@ setup() {
|
||||
grep -q 'branch" = "main"\|branch" = "master"' "$REPO_ROOT/hooks/git-push-guard.sh"
|
||||
}
|
||||
|
||||
@test "gitea-pr never passes the token as a command-line argument" {
|
||||
@test "neither gitea CLI passes the token as a command-line argument" {
|
||||
# /proc makes argv world-readable, so a token in a curl invocation is
|
||||
# visible to every other process for the life of the request - and this
|
||||
# runs alongside agents that read process listings.
|
||||
run grep -n 'curl.*Authorization: token' "$REPO_ROOT/bin/gitea-pr"
|
||||
[ "$status" -ne 0 ]
|
||||
grep -q 'curl -sS -w .*-K -' "$REPO_ROOT/bin/gitea-pr"
|
||||
#
|
||||
# Both files, because they are deliberate copies of each other rather
|
||||
# than a shared library (see bin/gitea-issue's header for why), and a
|
||||
# duplicated security property is exactly the kind that drifts.
|
||||
local f
|
||||
for f in bin/gitea-pr bin/gitea-issue; do
|
||||
run grep -n 'curl.*Authorization: token' "$REPO_ROOT/$f"
|
||||
[ "$status" -ne 0 ]
|
||||
grep -q 'curl -sS -w .*-K -' "$REPO_ROOT/$f"
|
||||
done
|
||||
}
|
||||
|
||||
@test "both gitea CLIs take a body from a file, and refuse to guess between two" {
|
||||
# Prose on a command line has to survive shell quoting, and an
|
||||
# apostrophe or a backtick is the common case, not the edge one. A body
|
||||
# that arrives via a file never enters argv at all.
|
||||
local f
|
||||
for f in bin/gitea-pr bin/gitea-issue; do
|
||||
grep -q -- '--body-file' "$REPO_ROOT/$f"
|
||||
# '-' means stdin, so a heredoc works without a temp file.
|
||||
grep -q 'file" = "-"' "$REPO_ROOT/$f"
|
||||
# Passing both is an error rather than a precedence rule: silently
|
||||
# picking one ships a body the caller did not mean to send.
|
||||
grep -q 'not both' "$REPO_ROOT/$f"
|
||||
done
|
||||
}
|
||||
|
||||
@test "gitea-issue list asks for issues only, never pull requests" {
|
||||
# Gitea's issues endpoint returns PRs too - a PR is an issue with a
|
||||
# branch attached - so without type=issues every open PR shows up as an
|
||||
# issue and the work list reads as twice as long as it is.
|
||||
grep -q 'type=issues' "$REPO_ROOT/bin/gitea-issue"
|
||||
}
|
||||
|
||||
@test "gitea-issue is installed into the image alongside gitea-pr" {
|
||||
grep -qx 'COPY bin/gitea-issue /usr/local/bin/gitea-issue' "$SANDBOX_DIR/Dockerfile"
|
||||
# Copied without the executable bit surviving is the failure that only
|
||||
# shows up at runtime, so the chmod list is checked too.
|
||||
grep -q 'chmod +x .*/usr/local/bin/gitea-issue' "$SANDBOX_DIR/Dockerfile"
|
||||
}
|
||||
|
||||
@test "gitea-merge-guard.sh blocks a sandbox merge and is a no-op on the host" {
|
||||
local hook="$REPO_ROOT/hooks/gitea-merge-guard.sh"
|
||||
local payload
|
||||
|
||||
# Blocked in the sandbox, including path-qualified - /usr/local/bin is
|
||||
# where the image puts it, so a bare-name-only pattern would miss the
|
||||
# form an agent reaching past PATH would type.
|
||||
local cmd
|
||||
for cmd in 'gitea-pr merge 24' '/usr/local/bin/gitea-pr merge 24' 'cd /tmp && gitea-pr merge 24'; do
|
||||
payload=$(jq -n --arg c "$cmd" '{tool_input: {command: $c}}')
|
||||
run bash -c "printf '%s' \"\$1\" | DEJIMA=1 bash '$hook'" _ "$payload"
|
||||
[ "$status" -eq 2 ]
|
||||
[[ "$output" == *"gitea-merge-guard.sh"* ]]
|
||||
done
|
||||
|
||||
# The other verbs are the whole point of the tool and must pass through.
|
||||
for cmd in 'gitea-pr create --title x' 'gitea-pr comments 24' 'gitea-issue close 14'; do
|
||||
payload=$(jq -n --arg c "$cmd" '{tool_input: {command: $c}}')
|
||||
run bash -c "printf '%s' \"\$1\" | DEJIMA=1 bash '$hook'" _ "$payload"
|
||||
[ "$status" -eq 0 ]
|
||||
done
|
||||
|
||||
# Outside the sandbox it does nothing at all: an operator at a host shell
|
||||
# can still be asked to merge, which is what the CLAUDE.md rule allows.
|
||||
# env -u rather than DEJIMA= because the image sets DEJIMA=1 globally, so
|
||||
# a test running inside dejima would otherwise assert against itself.
|
||||
payload=$(jq -n '{tool_input: {command: "gitea-pr merge 24"}}')
|
||||
run bash -c "printf '%s' \"\$1\" | env -u DEJIMA bash '$hook'" _ "$payload"
|
||||
[ "$status" -eq 0 ]
|
||||
}
|
||||
|
||||
@test "install.sh links and registers the gitea merge guard" {
|
||||
grep -qx 'link_hook gitea-merge-guard.sh' "$REPO_ROOT/install.sh"
|
||||
grep -q 'register_hook PreToolUse Bash gitea-merge-guard.sh' "$REPO_ROOT/install.sh"
|
||||
}
|
||||
|
||||
@test "dns-proxy.sh enables dnsmasq's stop-dns-rebind protection" {
|
||||
@@ -828,3 +942,39 @@ EOF
|
||||
run grep -n 'ALLOWED_DOMAINS' "$REPO_ROOT/hooks/sandbox-context.sh"
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
# The repo is public; the operator's own network is not. Both shapes below
|
||||
# leaked into it once (a .localdomain Gitea host in bin/gitea-pr, a real LAN
|
||||
# address as the Loki example in docker-compose.yml), so they are pinned
|
||||
# here. Documentation addresses belong in RFC 5737's ranges (192.0.2.0/24,
|
||||
# 198.51.100.0/24, 203.0.113.0/24) and documentation names under RFC 2606's
|
||||
# reserved TLDs.
|
||||
#
|
||||
# test/ is deliberately out of scope: fixtures stub real resolver answers
|
||||
# (project.bats feeds dig a 10.0.0.1) and those are inputs to a test, not
|
||||
# addresses an operator is being told to use.
|
||||
#
|
||||
# This catches address- and hostname-shaped leaks only. A personal domain
|
||||
# that looks like any other public one is not mechanically distinguishable
|
||||
# and still rests on review.
|
||||
SCANNED_PATHS=(README.md docs docker bin hooks install.sh systemd .gitea)
|
||||
|
||||
@test "no private or link-local address is used as a documentation example" {
|
||||
run grep -rnIE '\b(10|127\.0\.0\.[2-9]|169\.254|192\.168|172\.(1[6-9]|2[0-9]|3[01]))\.[0-9]{1,3}\.[0-9]{1,3}\b' \
|
||||
"${SCANNED_PATHS[@]/#/$REPO_ROOT/}"
|
||||
[ "$status" -ne 0 ] || {
|
||||
echo "use an RFC 5737 documentation address instead:" >&2
|
||||
echo "$output" >&2
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@test "no hostname under a private-network suffix is used as a documentation example" {
|
||||
run grep -rnIE '[A-Za-z0-9-]+\.(localdomain|lan|internal|home\.arpa)\b' \
|
||||
"${SCANNED_PATHS[@]/#/$REPO_ROOT/}"
|
||||
[ "$status" -ne 0 ] || {
|
||||
echo "use an RFC 2606 reserved name (example.com, .invalid, .test) instead:" >&2
|
||||
echo "$output" >&2
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user