Compare commits

...
Author SHA1 Message Date
branderson 0eafdf1d78 Merge branch 'main' into runtime-github-flake
tests / runner-env (confined) (pull_request_target) Successful in 35s
tests / confined (proxy) (pull_request_target) Successful in 1m10s
tests / confined (static) (pull_request_target) Successful in 2m40s
tests / confined (providers) (pull_request_target) Successful in 3m25s
tests / confined (remote) (pull_request_target) Successful in 3m48s
tests / confined (config) (pull_request_target) Successful in 6m0s
tests / privileged (pull_request_target) Successful in 4m14s
tests / runner-env (privileged) (pull_request_target) Successful in 33s
tests / confined (project) (pull_request_target) Successful in 4m53s
tests / confined (audit) (pull_request_target) Successful in 6m55s
2026-08-11 18:28:10 -07:00
branderson 1f92e7a8aa Merge pull request 'Say who wrote the prose' (#120) from writing-note into main
Reviewed-on: #120
Reviewed-by: branderson <brad@branderson.io>
2026-08-11 02:54:14 -07:00
branderson c5c23682af Say who wrote the prose
tests / runner-env (confined) (pull_request_target) Successful in 1m39s
tests / confined (proxy) (pull_request_target) Successful in 2m11s
tests / confined (static) (pull_request_target) Successful in 3m47s
tests / confined (providers) (pull_request_target) Successful in 4m37s
tests / confined (remote) (pull_request_target) Successful in 4m57s
tests / privileged (pull_request_target) Successful in 5m28s
tests / runner-env (privileged) (pull_request_target) Successful in 1m40s
tests / confined (project) (pull_request_target) Successful in 6m8s
tests / confined (config) (pull_request_target) Successful in 7m21s
tests / confined (audit) (pull_request_target) Successful in 8m15s
The repository argues at length in a voice that is not its maintainer's, and
until now nothing said so. A reader's default assumption about a README is
that the person on the copyright line wrote it, and here that is not true.

The note is the maintainer's own words, as a quote. The first version of this
was written about the AI voice in the AI voice, which is a joke the reader
gets to have at the project's expense rather than with it.

Placed above the "Developed in dejima" badge, which now closes the opening:
the note says what the badge means for the words, so it reads before rather
than after it.

Names the contrast with ATTRIBUTION.md, which states the artwork is
human-made with no AI tools. The two claims are opposite and both true, so
stating one without the other invites the wrong inference about whichever is
missing.

Pinned by a test: this is the kind of line that goes quietly in a tidy-up,
and nothing else in the repository makes its absence visible.
2026-08-11 01:30:38 -07:00
branderson 3825d23074 Merge pull request 'Say why GitHub push is blocked, not just how' (#116) from github-push-why into main
Reviewed-on: #116
Reviewed-by: branderson <brad@branderson.io>
2026-08-10 22:37:55 -07:00
branderson 1140ef056d Stop describing the unsolicited pull request as possibly helpful
tests / confined (proxy) (pull_request_target) Successful in 2m44s
tests / runner-env (confined) (pull_request_target) Successful in 1m39s
tests / confined (static) (pull_request_target) Successful in 3m46s
tests / confined (providers) (pull_request_target) Successful in 4m37s
tests / confined (remote) (pull_request_target) Successful in 4m59s
tests / privileged (pull_request_target) Successful in 5m27s
tests / confined (project) (pull_request_target) Successful in 6m5s
tests / runner-env (privileged) (pull_request_target) Successful in 1m39s
tests / confined (config) (pull_request_target) Successful in 7m19s
tests / confined (audit) (pull_request_target) Successful in 8m12s
"Nothing about that is an attack, the change might even be good" framed an
agent pushing to a stranger's repository as a good deed with bad timing,
needing only the operator's sign-off. docs/responsible-use.md says the
opposite and says it at length: maintainers are already triaging a flood of
unsolicited AI-generated pull requests, and adding to it is a cost imposed on
someone who did not ask. Two documents in one repository should not disagree
about that.

It now says the block exists so the push cannot happen, not so it can be
approved first, and points at the fork-and-review workflow as what to do
instead. The "not an attack" clause stays, because the misreading this whole
section exists to correct is that the block is containment - but it no longer
slides from "not an attack" to "therefore harmless".

The test pins the corrected framing rather than the phrasing, so a future
edit that softens it back fails.
2026-08-10 22:15:35 -07:00
branderson 3b1a7098d9 Say why GitHub push is blocked, not just how
The section described the mechanism - GitHub allowlisted on 443, port 22
unreachable - and left the reason to be inferred. An agent working on
dejima-ci-hardener inferred the wrong one: that it is containment against a
hostile session. It is not, and reading it that way makes the default look
like something to switch off once the "real" protection is in place.

The principle, in the words dejima-ci-hardener settled on so the two do not
drift: a session that is not being used to push to a forge should not be able
to push to it. Your configured forge is the one you chose. GitHub is where
everything else lives, so that is where the rule bites by default.

The case is an eager agent rather than a compromised one - it finds a real
bug in an unrelated project and helpfully pushes a fix to somebody else's
repository. Nothing about that is an attack, the change might even be good,
and it is still a decision that was the operator's to make.

Closing the CI chain follows from the principle rather than being the reason
for it, and the docs now say so in that order.
2026-08-10 22:15:35 -07:00
branderson 8cfd763b79 Merge pull request 'Compress the longest comment blocks, keep what they were for' (#113) from comment-slim into main
Reviewed-on: #113
Reviewed-by: branderson <brad@branderson.io>
2026-08-10 22:13:00 -07:00
branderson a63c964df9 Merge pull request 'Tell the session the parts of the policy it can act on' (#118) from github-push-briefing into main
Reviewed-on: #118
Reviewed-by: branderson <brad@branderson.io>
2026-08-10 22:00:04 -07:00
branderson fa1d67018b Make the GitHub reachability probe retry, and say why when it fails
tests / confined (proxy) (pull_request_target) Successful in 2m14s
tests / runner-env (confined) (pull_request_target) Successful in 1m40s
tests / confined (static) (pull_request_target) Successful in 3m42s
tests / confined (providers) (pull_request_target) Successful in 4m15s
tests / confined (remote) (pull_request_target) Successful in 4m51s
tests / privileged (pull_request_target) Successful in 5m29s
tests / confined (project) (pull_request_target) Successful in 6m2s
tests / runner-env (privileged) (pull_request_target) Successful in 1m40s
tests / confined (config) (pull_request_target) Successful in 7m12s
tests / confined (audit) (pull_request_target) Successful in 8m9s
It failed once on CI and left nothing to diagnose: `[ "$status" -eq 0 ]` and
no curl exit code, no HTTP status, no way to separate DNS from the ipset from
the far end. Working out that it was transient took reading the rest of the
run rather than the failure itself.

The rest of that run is what settled it. Tests 12, 13, 14 and 32 passed, and
12 reaches GitHub on port 22 through the push grant - which cannot happen
unless github-domains holds real addresses. So the firewall was correct and
one five-second connect to a live third-party service was not, which is a
thing that will happen again.

Three attempts, and only because this is a positive assertion: more attempts
can only turn a pass into a pass. The unreachability assertions either side
are deliberately left alone - retrying one of those could hide a hole that
opens intermittently, which is the opposite trade.

On failure it now prints the exit code and the last output, and says how to
read them: 6 is DNS, 7 or 28 is the route or the firewall, and an HTTP code
means the connection worked and something else is wrong.
2026-08-10 21:54:12 -07:00
branderson bd21eb5db0 Tell the session the parts of the policy it can act on
tests / confined (proxy) (pull_request_target) Successful in 2m13s
tests / runner-env (confined) (pull_request_target) Successful in 1m38s
tests / confined (providers) (pull_request_target) Successful in 4m13s
tests / confined (config) (pull_request_target) Successful in 6m37s
tests / confined (project) (pull_request_target) Successful in 5m38s
tests / confined (static) (pull_request_target) Successful in 3m36s
tests / confined (audit) (pull_request_target) Successful in 7m38s
tests / confined (remote) (pull_request_target) Successful in 4m46s
tests / privileged (pull_request_target) Successful in 5m31s
tests / runner-env (privileged) (pull_request_target) Successful in 1m40s
docs/responsible-use.md is mostly addressed to the operator - which
repository to point the sandbox at, what else is in the directory, what to
merge. Three of its points are things the agent itself decides, and it only
knows them if it is told.

When GitHub push is enabled - by the flag, or by forge = github under a
pushing workflow - the briefing says the grant is for the repository the
session was pointed at and nothing else, and that an unsolicited pull request
elsewhere is a cost imposed on maintainers rather than a contribution. The
default exists for that reason, so lifting it hands over the ability without
the reason unless something supplies it.

Two more go to every session, since neither depends on a forge. A domain can
be an action rather than a source - a deploy hook or a production API acts
the moment it is called, with no diff to review afterwards - so an agent
should not ask for one to finish a task. And the project directory is mounted
whole, so it can hold a .env or a dump that is not code: say so, do not open
or send it.

Kept to eight lines between them. This text is in every user's context on
every launch, and the scope-selection advice already there covers the rest of
the allowlist point.

.dejima.md gets the two that bind an agent working ON dejima, where there is
no global context cost: .dejima/allowed-domains is read from origin/main by
dejima-ci-hardener and treated as reviewed, so a domain added there widens
every future CI job; and security here is the product rather than a later
pass.
2026-08-10 20:47:45 -07:00
branderson bc05990e33 Fix five README and ATTRIBUTION points, and compress three more doc sections
tests / runner-env (confined) (pull_request_target) Successful in 1m39s
tests / confined (proxy) (pull_request_target) Successful in 2m11s
tests / confined (static) (pull_request_target) Successful in 3m24s
tests / confined (providers) (pull_request_target) Successful in 3m52s
tests / confined (remote) (pull_request_target) Successful in 4m28s
tests / confined (project) (pull_request_target) Successful in 5m27s
tests / confined (config) (pull_request_target) Successful in 6m24s
tests / runner-env (privileged) (pull_request_target) Successful in 1m37s
tests / confined (audit) (pull_request_target) Successful in 7m27s
tests / privileged (pull_request_target) Successful in 5m36s
README:
- The Loki bullet read as though shipping were the default. It is off unless
  [logging] loki_url points at an instance of your own, and there is no
  endpoint of ours in it - which a reader who does not know Loki cannot be
  expected to assume.
- "So this one is work on the runner" -> "Preventing this requires hardening
  your CI runner".
- New bullet for the ~/.claude isolation. A session gets its own project's
  transcripts, memory and edit history and nothing else. That is a property
  users should be told about rather than a fix buried in security.md.
- "One maintainer, no support" moves below the feature list. It interrupted
  the run from what dejima is to what it ships, and reads better once the
  reader knows what is on offer.

ATTRIBUTION.md scopes the trademark claim to the software. dejima is a real
island in Nagasaki harbour, rebuilt and standing today, and a file that reads
as claiming the name of a real place is both wrong and rude. A mark attaches
to particular goods and services, never to a word in the abstract, so the
terms now say so and disclaim the historical and geographic senses outright.
The artwork restrictions stay absolute; the name restriction is about being
taken for this project, which is the only sense in which a name can be
claimed.

Also compressed: tarball dependencies, ci.md's runner-reachability section,
and providers.md's validation rules. Net -314 lines overall.
2026-08-10 20:29:00 -07:00
branderson e837634c1f docs: compress the audit, credential-proxy and notification sections
Same rule as the comment passes. The exit-time audit section was half of
security.md and had grown a second explanation of itself alongside the first:
the enumerated exec keys, the withholding rules and the multi-value git config
case all stay, since each names a decision someone would otherwise undo. The
retelling around them went.

The test-suite bullet no longer counts tests. "Over 300 bats tests across six
files" was wrong within a week of being written and drifts every time a suite
is added, which is the argument against numbers in prose that nothing checks.

Rebased onto main, taking the README tagline edit and #112's worktree
paragraph, which landed in a bullet this pass rewrote. Both are kept - the
worktree text is compressed to match the surrounding register rather than
dropped.
2026-08-10 20:28:33 -07:00
branderson 283527fc6d docs: compress the exit-time audit section 2026-08-10 20:28:33 -07:00
branderson efb92cb703 Compress another fourteen comment blocks
Second pass, same rule: the fact that stops a regression stays, the retelling
goes. Net -272 across 21 files now.

Mostly file headers this time, which had drifted into explaining the design
twice - once to justify it and once to describe it. dejima-ttyd's flag notes,
merge-guard's, gitea-actions', the config schema's, and the two Dockerfile
blocks all say the same things in half the lines.

The load-bearing detail is untouched: --check-origin's WebSocket
same-origin argument, reqOwner() being unfixable by widening a token, the
port-443 restriction being the read-only guarantee, and the ERR trap's reason
for existing all read the same.
2026-08-10 20:28:32 -07:00
branderson 77a298264c Compress the longest comment blocks, keep what they were for
Net -195 lines across 13 files. Every comment here still says the thing that
stops the next person reinstating the bug: the issue number, the failure mode,
the reason a check is written the way it is. What went is the narrative around
it - the retelling of how a bug was found, consequence chains spelled out to
their third step, restatements of what the code above already shows.

Nothing was cut because it was long. The blocks that survive at length are the
ones where the reasoning is the content: the O_NOFOLLOW open in
dejima-project-lib.sh, the id-derivation anchoring in dejima-audit, the
enumerated ~/.claude mounts.

Two operator-environment details are gone: a directory size and checkout count
from the machine this was written on, in bin/dejima and docs/security.md. They
were useful for judging severity in a handoff and have no business in a public
repository - the same distinction dejima-ci-hardener's addresses.bats exists
to keep.
2026-08-10 20:28:32 -07:00
branderson d31c707217 Merge pull request 'Say plainly that this is unreviewed early development' (#117) from maturity-disclaimer into main
Reviewed-on: #117
Reviewed-by: branderson <brad@branderson.io>
2026-08-10 20:17:19 -07:00
branderson 0e09060555 Say plainly that this is unreviewed early development
tests / confined (proxy) (pull_request_target) Successful in 2m10s
tests / runner-env (confined) (pull_request_target) Successful in 1m38s
tests / confined (static) (pull_request_target) Successful in 3m35s
tests / confined (providers) (pull_request_target) Successful in 4m2s
tests / confined (remote) (pull_request_target) Successful in 4m38s
tests / privileged (pull_request_target) Successful in 5m15s
tests / confined (project) (pull_request_target) Successful in 5m45s
tests / runner-env (privileged) (pull_request_target) Successful in 1m39s
tests / confined (config) (pull_request_target) Successful in 6m53s
tests / confined (audit) (pull_request_target) Successful in 7m56s
The no-support notice said nothing was promised, which reads as a statement
about responsiveness rather than about maturity. Someone could take it at
face value and still conclude the software itself is finished.

It is not. Nothing here has been through outside review, an external audit,
or adversarial testing by anyone but its author. The reasoning is one
person's and the tests assert what that person thought to check, which is a
weaker thing than it can look like from a repository that argues at length
for its own security properties.

In all three places a reader lands: the README opening, above the no-support
block; SECURITY.md, as its own section ahead of the support and warranty
terms, since that file is canonical for the organisation; and the top of
docs/security.md, which is a thousand lines of confident claims and the place
over-confidence is most likely to take hold.

Pinned by a test. This is the first line that gets quietly dropped as a
project starts to feel established, and dropping it should take a deliberate
edit to a test rather than an oversight.
2026-08-10 19:51:38 -07:00
branderson e7f5a9b541 Merge pull request 'Say that dejima is starting, and wear the badge' (#114) from launch-progress into main
Reviewed-on: #114
Reviewed-by: branderson <brad@branderson.io>
2026-08-10 18:28:26 -07:00
branderson 54b00d73a6 Use the wordmark's own green in the badge
tests / confined (proxy) (pull_request_target) Successful in 2m7s
tests / runner-env (confined) (pull_request_target) Successful in 1m37s
tests / confined (providers) (pull_request_target) Successful in 3m46s
tests / confined (project) (pull_request_target) Successful in 5m10s
tests / confined (config) (pull_request_target) Successful in 6m16s
tests / confined (audit) (pull_request_target) Successful in 7m24s
tests / runner-env (privileged) (pull_request_target) Successful in 1m38s
tests / confined (static) (pull_request_target) Successful in 3m27s
tests / confined (remote) (pull_request_target) Successful in 4m29s
tests / privileged (pull_request_target) Successful in 5m19s
#98971a was gruvbox's faded green, chosen from the palette rather than from
the mark. Sampling the wordmark gives #8ec07c for the island tile, so the two
now agree. Dark text on it is 7.0:1 rather than 4.8:1.
2026-08-10 14:52:43 -07:00
branderson ecb886f658 Move the badge to the end of the opening, and point it at dejima
tests / confined (remote) (pull_request_target) Failing after 1m18s
tests / runner-env (confined) (pull_request_target) Successful in 1m35s
tests / confined (proxy) (pull_request_target) Successful in 2m7s
tests / confined (static) (pull_request_target) Successful in 3m19s
tests / confined (providers) (pull_request_target) Successful in 3m44s
tests / privileged (pull_request_target) Successful in 5m3s
tests / confined (project) (pull_request_target) Successful in 5m9s
tests / runner-env (privileged) (pull_request_target) Successful in 1m34s
tests / confined (config) (pull_request_target) Successful in 6m3s
tests / confined (audit) (pull_request_target) Successful in 7m6s
It sat under the wordmark, where two marks stacked read as branding rather
than as a statement about the project. Last thing before "The name" puts it
after the reader knows what dejima is, which is where the badge means
something.

The link goes to the project, not the badge repository - what a reader wants
from it is dejima, and that is what the badge repo already tells everyone
else to link to.
2026-08-10 14:51:15 -07:00
branderson aa11bdd27b Say that dejima is starting, and wear the badge
tests / confined (proxy) (pull_request_target) Successful in 2m13s
tests / confined (static) (pull_request_target) Successful in 3m35s
tests / confined (providers) (pull_request_target) Successful in 4m6s
tests / confined (remote) (pull_request_target) Successful in 4m42s
tests / runner-env (privileged) (pull_request_target) Successful in 1m36s
tests / confined (project) (pull_request_target) Successful in 5m44s
tests / confined (config) (pull_request_target) Successful in 6m47s
tests / confined (audit) (pull_request_target) Successful in 7m48s
tests / runner-env (confined) (pull_request_target) Successful in 1m40s
tests / privileged (pull_request_target) Successful in 5m13s
Everything between the command and the container's first output is silent -
the audit snapshot, an image build that prints nothing when every layer is
cached, and Docker creating the container with a mount per allowed path. Long
enough to read as hung. Two lines to stderr now say what is happening, and
name the first-run case specifically, since building the image takes minutes
and is when someone is most likely to interrupt.

Placed after --print-config and --print-briefing exit, so machine-readable
output stays machine-readable on both streams.

The badge is dejima's own, as the first project to carry it. The SVG is
copied into assets/ rather than hotlinked or fetched from shields.io: a badge
in a README should not make every reader of it fetch from a third party, and
that goes double for a project whose argument is about not trusting reach you
did not choose.

test/static.bats' image check now covers markdown image syntax and svg, not
just <img> and png - the badge is referenced the markdown way, and would have
sat outside the check that exists for exactly this.
2026-08-10 14:41:34 -07:00
branderson 32841b4a2e Update README.md 2026-08-10 00:21:23 -07:00
branderson 760af9946f Merge pull request 'Tell the session a worktree does not isolate .git/config' (#112) from worktree-config-briefing into main
Reviewed-on: #112
Reviewed-by: branderson <brad@branderson.io>
2026-08-10 00:03:44 -07:00
branderson b7f641594b Tell the session a worktree does not isolate .git/config
tests / runner-env (confined) (pull_request_target) Successful in 1m39s
tests / confined (proxy) (pull_request_target) Successful in 2m8s
tests / confined (providers) (pull_request_target) Successful in 4m13s
tests / confined (remote) (pull_request_target) Successful in 4m51s
tests / confined (project) (pull_request_target) Successful in 5m59s
tests / confined (config) (pull_request_target) Successful in 7m2s
tests / confined (audit) (pull_request_target) Successful in 8m2s
tests / confined (static) (pull_request_target) Successful in 3m45s
tests / privileged (pull_request_target) Successful in 5m25s
tests / runner-env (privileged) (pull_request_target) Successful in 1m37s
A worktree isolates the working tree; `.git/config` is shared with the parent
checkout. So a bare `git config <key> <value>` run inside a worktree writes
the user's real repository config. That happened: an implementer set
`user.name` while reproducing a fixture by hand and misattributed three
commits, noticed only because a later subagent read an odd author off
`git log`.

The briefing tells a session to work in a worktree, and says nothing about
what a worktree does not cover, which reads as more isolation than is on
offer. It now says both, and gives the scoped form.

In `_briefing_core_worktree` rather than a workflow's own prose, so a
workflow can opt into worktrees but cannot ship them without this - same
reasoning as the containment paragraph already there.

A guard, not a boundary, and the doc says so. `.git/config` sits in the
read-write project mount and cannot be made read-only without breaking
`git push -u`, which writes `branch.<name>.merge` on every push. Detection is
what covers a session that does not care: the audit already watches that file
for `core.hooksPath`, `credential.helper` and the other exec keys the same
unscoped command reaches, and is how this was found.

Closes #32
2026-08-09 23:43:44 -07:00
branderson cd8ac7be0f Merge pull request 'Give each project its own nvim volumes' (#111) from nvim-volumes-per-project into main
Reviewed-on: #111
Reviewed-by: branderson <brad@branderson.io>
2026-08-09 23:41:42 -07:00
branderson 8438daa07e Give each project its own nvim volumes
tests / confined (proxy) (pull_request_target) Successful in 2m8s
tests / runner-env (confined) (pull_request_target) Successful in 1m37s
tests / confined (static) (pull_request_target) Successful in 3m38s
tests / confined (providers) (pull_request_target) Successful in 3m56s
tests / runner-env (privileged) (pull_request_target) Successful in 1m39s
tests / confined (audit) (pull_request_target) Successful in 8m10s
tests / confined (remote) (pull_request_target) Successful in 4m47s
tests / privileged (pull_request_target) Successful in 5m22s
tests / confined (project) (pull_request_target) Successful in 5m56s
tests / confined (config) (pull_request_target) Successful in 6m14s
The nvim data, state and cache directories are Docker volumes rather than
host paths, so nothing a session installs has ever reached the host's own
nvim. They do outlive the container, which is the point - a plugin install
should not happen on every launch - but they carried one fixed name each and
so were shared by every project on the machine.

That made them a persistence channel between sandboxes. A session writes a
Lua file under `lazy/`, or replaces a binary under `mason/bin/`, and the next
session to run nvim in the sandbox executes it, whatever project that session
is working on. `mason/bin` is the sharper half: those are invoked directly,
with no load path to arrange and no plugin to enable. It is bounded by the
container - a payload there runs in another sandbox, not on the host - but
cross-project persistence is what this repository closes everywhere else.

Scoped per project, the same way the derived image tag already is. The
service still refers to the static compose keys; only the volume Docker
creates varies, and the defaults reproduce the previous names so a bare
`docker compose` behaves as it did.

The `dejima_nvim-*` prefix is load-bearing rather than cosmetic: project_slug
maps a leading `/` to `-`, and a Docker volume name may not start with one.
Without it every launch would fail inside `compose run` rather than anywhere
that names the cause, so there is a test for it.
2026-08-09 23:27:38 -07:00
branderson 631b73ae5f Merge pull request 'Add --extra-readonly and --extra-read-write for one-off mounts' (#109) from extra-mount-flags into main
Reviewed-on: #109
Reviewed-by: branderson <brad@branderson.io>
2026-08-09 23:20:43 -07:00
branderson d0052161ef Merge pull request 'Mount ~/.claude/file-history one session at a time, not the whole tree' (#110) from file-history-scope into main
Reviewed-on: #110
Reviewed-by: branderson <brad@branderson.io>
2026-08-09 23:19:17 -07:00
branderson 8a26412716 Add --extra-readonly and --extra-read-write for one-off mounts
tests / confined (proxy) (pull_request_target) Successful in 2m10s
tests / runner-env (confined) (pull_request_target) Successful in 1m39s
tests / confined (static) (pull_request_target) Successful in 3m40s
tests / confined (providers) (pull_request_target) Successful in 3m59s
tests / confined (remote) (pull_request_target) Successful in 4m50s
tests / privileged (pull_request_target) Successful in 5m24s
tests / confined (project) (pull_request_target) Successful in 5m27s
tests / confined (config) (pull_request_target) Successful in 6m52s
tests / runner-env (privileged) (pull_request_target) Successful in 1m37s
tests / confined (audit) (pull_request_target) Successful in 8m4s
Reading another checkout while migrating code out of it, or writing to a
scratch directory for one session, meant editing the config file and then
remembering to edit it back. These do it per launch: --extra-readonly PATH
and --extra-read-write PATH, short forms --ro and --rw, both repeatable.

They append to [mounts] readonly and [mounts] read_write rather than carrying
their own mount code, which is the design rather than an implementation
detail. Everything that already applies to a configured mount then applies to
a flag with nothing duplicated and no second path to keep in step: the
existence check, the refusal when a path overlaps the approval store or the
audit stores, the refusal when one path appears in both lists, and the
read-write paths named in the session briefing and the audit report header.
A flag says where, not whether, and no flag waives a refusal.

Two differences from the config form, both because a command line is not a
file. A relative path is accepted and made absolute against the working
directory - by prefixing rather than by readlink -f, so a symlinked path is
mounted where the operator typed it rather than at its target. And a path
containing a colon is refused: the lists are colon-separated, so it cannot be
represented in one and would otherwise split into two mounts that each fail
the existence check.

The refusals name the flag rather than the config key when a flag supplied
the path, since "[mounts] readonly entry" sends someone to a file that does
not mention it. That record is kept at parse time: the lists are assembled
inside a command substitution, whose subshell would discard it and report
every flag as a config entry.
2026-08-09 23:01:20 -07:00
branderson 0a323ed230 Mount ~/.claude/file-history one session at a time, not the whole tree
tests / confined (proxy) (pull_request_target) Successful in 2m13s
tests / runner-env (confined) (pull_request_target) Successful in 1m37s
tests / confined (providers) (pull_request_target) Successful in 3m57s
tests / confined (remote) (pull_request_target) Successful in 4m52s
tests / confined (config) (pull_request_target) Successful in 6m7s
tests / privileged (pull_request_target) Successful in 9m21s
tests / confined (static) (pull_request_target) Successful in 3m40s
tests / confined (project) (pull_request_target) Successful in 5m33s
tests / confined (audit) (pull_request_target) Successful in 8m18s
tests / runner-env (privileged) (pull_request_target) Successful in 1m40s
The tree was bind-mounted read-write in full, and each
`<session>/<hash>@v1` in it is a verbatim copy of an edited file. So it is
other projects' source rather than a record about it, which makes the read
side worse than the transcripts #104 covered: on the host this was developed
on, 47M across ten checkouts, including a dotfiles repo. The write side puts
chosen content into a later restore in another project.

A launch now gets the sessions belonging to its own workspace and nothing
else - 11 directories here instead of 50.

Keyed by session id, so unlike projects/ there is no slug in these paths to
scope by. The transcripts supply it: projects/<slug>/<id>.jsonl and
file-history/<id> name the same session, so the slugs a workspace owns select
the session ids it owns. That routes through workspace_slugs_sorted, so the
sibling case is excluded here for free - a naive count of "dejima's" history
says 12, and one of those belongs to dejima-ci-hardener.

The session about to start has an id nothing can know in advance, so its own
directory is container-local and does not survive the run. That costs undo
history for that session if it is later resumed on the host. It is the one
respect in which this cannot be scoped as tightly as projects/, where the
workspace's own slug is known at launch, and it is why the image pre-creates
file-history/: without it Docker creates the parent root-owned while setting
up the mounts nested in it, and the session cannot record its own undo entry
at all.

The static test asserting that compose still mounts the tree, and the
Dockerfile mkdir test whose regex is anchored to the RUN line, both had to
move with it.
2026-08-09 22:50:05 -07:00
branderson db989b3277 Merge pull request 'Refuse a read-only mount that would expose the approval store' (#108) from approval-store-mount-guard into main
Reviewed-on: #108
Reviewed-by: branderson <brad@branderson.io>
2026-08-09 21:52:15 -07:00
branderson 3a6acf102a Refuse a read-only mount that would expose the approval store
tests / confined (proxy) (pull_request_target) Successful in 2m13s
tests / runner-env (confined) (pull_request_target) Successful in 1m35s
tests / confined (static) (pull_request_target) Successful in 3m32s
tests / confined (providers) (pull_request_target) Successful in 3m48s
tests / confined (remote) (pull_request_target) Successful in 4m37s
tests / confined (project) (pull_request_target) Successful in 5m16s
tests / runner-env (privileged) (pull_request_target) Successful in 1m38s
tests / confined (audit) (pull_request_target) Successful in 7m51s
tests / privileged (pull_request_target) Successful in 5m13s
tests / confined (config) (pull_request_target) Successful in 5m59s
Two mount paths reached `extra_mounts+=(-v ...)` with a path computed at run
time, and neither was checked: a `[mounts] readonly` entry naming the store,
and a ~/.claude symlink whose target directory sits inside it. Reproduced
directly against main - `readonly = <approval store>` produces
`-v <store>:<store>:ro` and the launch exits 0 with nothing said.

The static test guarding "the approval store is never mounted into the
container" greps bin/dejima for a literal $PROJECT_STORE or config_root, so
it could not see either one and passed throughout. That is what #15 named.

Read-only is not enough to make those mounts harmless. The approval store
discloses every project's approved domains and dependencies, and the audit
baselines say which findings were already waved through.

`[mounts] read_write` has been guarded since it was added. The guard is now a
function, and the two unguarded paths call it with the stores alone - not
read_write's wider list, which also covers $REPO_ROOT and the workspace,
because mounting those read-only is ordinary and expected.

The half of #15 that is already closed: the symlink is no longer
session-plantable. It required ~/.claude to be mounted read-write, and the
enumerated-mounts rework made the five scanned directories read-only.
Verified from inside a running container - every one of them refuses a
symlink with "Read-only file system", and ~/.claude itself is container-local.
So what is left is an operator's own symlink, and the invariant.

Closes #15
2026-08-09 21:41:58 -07:00
branderson 18cff7eece Merge pull request 'Move the devcontainer credit to License, and quieten the ssh-agent warning' (#107) from lineage-ssh-warning into main
Reviewed-on: #107
Reviewed-by: branderson <brad@branderson.io>
2026-08-09 21:12:32 -07:00
branderson da0920d13f Merge pull request 'Mount ~/.claude/projects one slug at a time, not the whole tree' (#105) from projects-slug-mount into main
Reviewed-on: #105
Reviewed-by: branderson <brad@branderson.io>
2026-08-09 21:11:57 -07:00
branderson e31c651a5e Move the devcontainer credit to License, and quieten the ssh-agent warning
tests / confined (proxy) (pull_request_target) Successful in 2m11s
tests / runner-env (confined) (pull_request_target) Successful in 1m38s
tests / confined (static) (pull_request_target) Successful in 3m40s
tests / confined (providers) (pull_request_target) Successful in 3m56s
tests / confined (remote) (pull_request_target) Successful in 4m50s
tests / privileged (pull_request_target) Successful in 5m15s
tests / confined (project) (pull_request_target) Successful in 5m27s
tests / runner-env (privileged) (pull_request_target) Successful in 1m39s
tests / confined (config) (pull_request_target) Successful in 5m56s
tests / confined (audit) (pull_request_target) Successful in 8m4s
The credit moves out of the opening and into the License section, naming the
part it applies to. docker/init-firewall.sh, docker/Dockerfile,
hooks/sandbox-context.sh and docs/security.md carry it at the point of use
and are unchanged.

bin/dejima warned that ssh-agent had no identities loaded even when
[core] ssh_key was set, and told the operator the push would fail "unless
[core] ssh_key is also set" while it was. With a key configured the premise
does not hold: the key is loaded into the container's own agent and pushes
work without the forwarded one carrying anything. The warning is now silent
in that case, and its last line points at the key rather than treating it as
an afterthought.

Two tests in config.bats, driving a real ssh-agent with nothing added to it,
since a handmade socket does not reproduce the `ssh-add -l` failure the
warning keys on. The first fails against the previous behaviour.
2026-08-09 20:03:46 -07:00
branderson 7079fca1cd Merge pull request 'Stop the remote tests from overwriting the real dejima-audit' (#106) from test-audit-stub into main
Reviewed-on: #106
Reviewed-by: branderson <brad@branderson.io>
2026-08-09 19:54:23 -07:00
branderson 4dfa6e3927 Stop the remote tests from overwriting the real dejima-audit
tests / runner-env (confined) (pull_request_target) Successful in 1m39s
tests / confined (proxy) (pull_request_target) Successful in 2m11s
tests / confined (static) (pull_request_target) Successful in 3m38s
tests / confined (providers) (pull_request_target) Successful in 3m58s
tests / confined (remote) (pull_request_target) Successful in 4m50s
tests / confined (project) (pull_request_target) Successful in 5m29s
tests / confined (config) (pull_request_target) Successful in 5m35s
tests / confined (audit) (pull_request_target) Successful in 8m5s
tests / privileged (pull_request_target) Successful in 9m19s
tests / runner-env (privileged) (pull_request_target) Successful in 1m38s
Two tests need an exit-time audit that fails, and they got one by writing a
two-line `exit 3` stub over `$REPO_ROOT/bin/dejima-audit` and restoring it
from a backup afterwards. Three things wrong with that:

A run killed partway never reaches the restore, so the checkout is left
holding the stub. Two runs of this file at once are worse: the second backs
up the first's stub as the "real" script and restores that, permanently. A
suite that passes while silently disabling the exit-time audit is the exact
failure this file exists to prevent.

And it reads like an attack. A file watcher that samples the tree during the
window sees a security tool replaced by `#!/bin/bash` / `exit 3`, with no
indication anywhere in the bytes that a test put it there. That is worth
avoiding on its own: a real compromise should not have somewhere innocent to
hide, and an alarm that fires on ordinary test runs is an alarm that stops
being read.

Tests now mutate a throwaway copy under $BATS_TEST_TMPDIR instead. bin/ is
copied rather than symlinked, which is load-bearing twice over: bin/dejima
resolves its own location with readlink -f, so a symlink would send
SCRIPT_DIR back to the repository; and `cat >` through a symlink truncates
the target, so linking would have written the real script anyway. The first
draft of this change did link, and the new teardown_file guard caught it
overwriting bin/dejima-audit on the first run - which is the argument for
having the guard.

The stub also says what it is now, so the alarming version of it explains
itself if anything ever does surface it.

Two guards, because the fixture is not the only way to get this wrong:
teardown_file hashes bin/dejima-audit around the whole file, and a static
test refuses any redirect or removal aimed at $REPO_ROOT or
$BATS_TEST_DIRNAME from any suite.
2026-08-09 19:37:26 -07:00
branderson b7880c1084 Mount ~/.claude/projects one slug at a time, not the whole tree
tests / runner-env (confined) (pull_request_target) Successful in 1m33s
tests / confined (proxy) (pull_request_target) Successful in 2m23s
tests / confined (static) (pull_request_target) Successful in 3m50s
tests / confined (providers) (pull_request_target) Successful in 4m9s
tests / confined (remote) (pull_request_target) Successful in 4m59s
tests / confined (project) (pull_request_target) Successful in 5m39s
tests / confined (config) (pull_request_target) Successful in 5m43s
tests / confined (audit) (pull_request_target) Successful in 8m14s
tests / privileged (pull_request_target) Successful in 12m32s
tests / runner-env (privileged) (pull_request_target) Successful in 1m25s
The tree holds every project's transcripts and agent memory, mounted
read-write. Memory is loaded into context at session start, so a write into
another project's memory/ and its MEMORY.md index is text a future session
there reads as established background rather than as input. No container
escape and no forge credential were needed for that; the mount was enough.

A launch now gets the workspace's own slug plus one per live git worktree
nested under it, and nothing else. On the host this repo was developed on
that is 17 directories instead of 71. Sibling projects are absent rather than
present-but-unwritable, which is the first-run state Claude Code already
handles - the risk that a read-only projects/ would hang it, the way a
blocked ~/.claude.json write did, does not arise, because projects/ itself
stays container-local and writable.

The set comes from real worktree paths, not from the slug string, and that is
load-bearing. A slug cannot tell a child from a sibling: /w/proj/wt1 and
/w/proj-wt1 produce the same one. The audit's existing "$slug"-* glob
therefore treated any project checked out beside this one whose directory
name merely extends it as part of it - on this host, a session in dejima had
dejima-ci-hardener's writes silently exempted from cross-project reporting.
Sharing that glob with the mount would have carried the hole straight into
it, so both now call one function in bin/dejima-slug-lib.sh. Hosts with
sibling-named projects will see CROSS-PROJECT findings they were not getting
before; those are real writes, not new false positives.

Detection is unaffected: dejima-audit runs host-side over the whole tree, so
narrowing the mount does not narrow what it can see. It now covers what the
mount cannot reach, which is concurrent host sessions.

The runtime assertion could not be run here - this sandbox has no Docker
daemon - so the privileged CI job is the first thing to execute it.

Closes #104
2026-08-09 13:53:22 -07:00
branderson d7900f1b03 Merge pull request 'Put the wordmark on the README, publish the org-wide policy files, and comb the docs' (#103) from branding into main
Reviewed-on: #103
Reviewed-by: branderson <brad@branderson.io>
2026-08-09 03:12:00 -07:00
branderson d234e473de Indent the wordmark and open up the gap under the rule
tests / confined (proxy) (pull_request_target) Successful in 1m56s
tests / runner-env (confined) (pull_request_target) Successful in 1m24s
tests / confined (providers) (pull_request_target) Successful in 3m29s
tests / confined (config) (pull_request_target) Successful in 4m51s
tests / confined (project) (pull_request_target) Successful in 4m51s
tests / confined (static) (pull_request_target) Successful in 3m22s
tests / confined (remote) (pull_request_target) Successful in 4m27s
tests / privileged (pull_request_target) Successful in 4m50s
tests / confined (audit) (pull_request_target) Successful in 7m19s
tests / runner-env (privileged) (pull_request_target) Successful in 1m24s
A leading &nbsp; on the same line as the tag puts the image inside a
paragraph and indents it by one space. hspace would be the obvious way to do
that and is the wrong one: it survives Gitea sanitizing and is then ignored by
every browser that dropped HTML4 presentation attributes, so it reads as
working in the source and does nothing on the page.
2026-08-09 01:43:34 -07:00
branderson 96c8906351 Space the wordmark above, rule it off below
tests / runner-env (confined) (pull_request_target) Successful in 1m24s
tests / confined (proxy) (pull_request_target) Successful in 1m59s
tests / confined (static) (pull_request_target) Successful in 3m23s
tests / confined (providers) (pull_request_target) Successful in 3m39s
tests / confined (remote) (pull_request_target) Successful in 4m23s
tests / privileged (pull_request_target) Successful in 4m49s
tests / confined (project) (pull_request_target) Successful in 4m52s
tests / confined (config) (pull_request_target) Successful in 4m58s
tests / runner-env (privileged) (pull_request_target) Successful in 1m23s
tests / confined (audit) (pull_request_target) Successful in 7m19s
The blank line moves to above the tag, and an <hr> separates the wordmark
from the tagline underneath it.
2026-08-09 01:41:49 -07:00
branderson 2c75ef0661 Use a character, not a break, for the wordmark spacing
tests / confined (proxy) (pull_request_target) Successful in 2m0s
tests / confined (providers) (pull_request_target) Successful in 3m49s
tests / runner-env (confined) (pull_request_target) Successful in 1m26s
tests / privileged (pull_request_target) Successful in 5m4s
tests / confined (project) (pull_request_target) Successful in 5m20s
tests / confined (config) (pull_request_target) Successful in 5m23s
tests / runner-env (privileged) (pull_request_target) Successful in 1m25s
tests / confined (static) (pull_request_target) Successful in 3m32s
tests / confined (audit) (pull_request_target) Successful in 7m50s
tests / confined (remote) (pull_request_target) Successful in 4m41s
The <br> did not render. A break at the end of a block ends a line that
nothing follows, and browsers give that no height, so the tagline stayed
where it was. &nbsp; survives Gitea sanitizing as a real U+00A0, which means
the paragraph holding it has content and occupies a line. Verified against
the instance markdown endpoint: the rendered output is <p>\xa0</p>, not a
collapsing space.
2026-08-09 01:34:25 -07:00
branderson 9a0c56f9a7 Give the wordmark room above the tagline
tests / confined (proxy) (pull_request_target) Successful in 1m58s
tests / runner-env (confined) (pull_request_target) Successful in 1m25s
tests / confined (static) (pull_request_target) Successful in 3m28s
tests / confined (providers) (pull_request_target) Successful in 3m45s
tests / confined (remote) (pull_request_target) Successful in 4m36s
tests / privileged (pull_request_target) Successful in 5m5s
tests / confined (project) (pull_request_target) Successful in 5m16s
tests / confined (config) (pull_request_target) Successful in 5m20s
tests / runner-env (privileged) (pull_request_target) Successful in 1m25s
tests / confined (audit) (pull_request_target) Successful in 7m50s
An image alone between two blank lines is not wrapped in a paragraph, so it
has no bottom margin and the tagline sits right under it. `style` is not the
fix: Gitea's sanitizer strips it from the tag and from any wrapper, verified
against the instance's own markdown endpoint, and `vspace` survives
sanitizing only to be ignored by every browser that dropped HTML4 presentation
attributes. A `<br>` renders on both Gitea and GitHub, so that is what this
uses, with the reasoning in the comment above the tag.
2026-08-09 01:29:37 -07:00
branderson ab8e26437c Comb the docs for the tics that mark them as agent-written
tests / confined (proxy) (pull_request_target) Successful in 2m2s
tests / confined (providers) (pull_request_target) Successful in 3m51s
tests / runner-env (confined) (pull_request_target) Successful in 1m26s
tests / confined (static) (pull_request_target) Successful in 3m31s
tests / confined (remote) (pull_request_target) Successful in 4m41s
tests / privileged (pull_request_target) Successful in 5m10s
tests / confined (project) (pull_request_target) Successful in 5m20s
tests / confined (config) (pull_request_target) Successful in 5m25s
tests / runner-env (privileged) (pull_request_target) Successful in 1m25s
tests / confined (audit) (pull_request_target) Successful in 7m56s
The prose leaned on a handful of constructions hard enough to be a tell,
mostly the "X, not Y" antithesis as a closing beat and the meta-commentary
that tells a reader how to read the sentence they just read. Where the
contrast carries information, and in a security doc it usually does, it
stays; where it was rhythm, it goes. "worth knowing" went from nine uses to
none, "which is the point" and "the whole point" from three to none, and the
filler senses of "actually", "simply" and "precisely" are gone while the
load-bearing ones (nominal versus real) are untouched.

No claim changes. The agent-voice section is deliberately untouched, since
its whole premise is that it was written by one.

Provider axes is now three bulleted lists rather than a run-on sentence
naming seven directories, and ATTRIBUTION.md credits gruvbox for the palette.
2026-08-09 00:44:43 -07:00
branderson 034913205a Put the wordmark on the README, and publish the org-wide policy files
The README points at one wordmark file unconditionally rather than switching
on `prefers-color-scheme`, which reports the browser's setting and never the
site's. A forge that renders dark whichever way the browser is set would then
show dark ink on a dark page, silently, to a reader whose browser prefers
light. Both files ship, so a mirror on a light-themed host changes one line
where someone can see whether it worked. The suffix names the mode a file is
for, not the colour of its ink, so `-darkmode` is the pale one.

ATTRIBUTION.md and SECURITY.md are canonical for the whole organisation and
live here because this is the repository the others link back to. Keeping one
copy is the point: the terms cannot drift into two divergent statements of
who holds the marks, and a reporter cannot find two different addresses to
send a vulnerability to. dejima-ci-hardener links to both by absolute URL, so
a rename here is a 404 in a repository whose tests cannot see this one, which
is why static.bats pins the two filenames.

The README's opening now says there is one maintainer and no support, where
somebody deciding whether to deploy this sees it before investing effort.
2026-08-08 23:41:45 -07:00
branderson 9cb6b7b432 Merge pull request 'docs: point at the dejima organization' (#102) from org-move into main
Reviewed-on: #102
Reviewed-by: branderson <brad@branderson.io>
2026-08-08 20:34:08 -07:00
41 changed files with 2189 additions and 1062 deletions
+19
View File
@@ -123,3 +123,22 @@ the code was being worked on:
Both are fixed by stating the precondition (`env -u`, a stubbed `ss`) rather
than assuming it. When a test fails only in one environment, suspect this before
suspecting the code — and when writing one, set what you depend on.
## What lands on `main` here is a trust anchor, not just a release
`docs/responsible-use.md` is this project's own policy, and two of its points
bind while you are working *on* dejima rather than merely with it.
**`.dejima/allowed-domains` is read from `origin/main` by
dejima-ci-hardener**, which treats whatever it finds there as already
reviewed - that is how a CI runner decides what a job may reach. Adding a
domain to that file is not a convenience for one branch; it widens what every
future CI job on that runner can talk to, from the moment it merges. Say what
a domain is for in the pull request, and expect the question.
**Security here is not a later pass.** This repository's product is an
argument that a boundary holds. A change that quietly weakens one - a mount
that widens, a check that moves after the thing it guards, a test that stops
asserting what its name claims - is a defect in the product, not a rough edge
to tidy up afterwards. If you notice one while doing something else, say so
rather than leaving it for a pass that may not come.
+66
View File
@@ -0,0 +1,66 @@
# Attribution
The code in the dejima repositories is GPL-3.0-or-later; see `LICENSE`. The
artwork is not code and is not covered by it.
This file is the canonical statement for the dejima marks. Other repositories
link here rather than restating the terms.
## Authorship
All logos, assets, and artwork are human-made. No AI tools were used in their
creation. The artist is anonymous.
The palette is based on [gruvbox](https://github.com/morhetz/gruvbox) by Pavel
Pertsev, which is MIT-licensed. The colour values are gruvbox's; the marks
drawn with them are not.
## What is covered
Both marks, in every form:
- the logo: the filled tiles and the outline variants, in both light-mode and
dark-mode renderings, at any resolution, and the favicon;
- the wordmark: the solid and outline renderings, again in both modes.
The terms attach to the marks rather than to this list, so renderings not
listed here are covered too.
## Terms
The dejima name, logo and wordmark are trademarks of Brad Anderson **as they
identify this software**. A trademark attaches to a mark as used for
particular goods and services, never to a word in the abstract, and the claim
here goes no further than that.
Dejima is a real place - the island in Nagasaki harbour this project is named
after, and rebuilt there today. Nothing in this file claims the word itself,
its historical or geographic sense, or any use unconnected with this
software. Writing about the island, naming something after it, or using the
name in any way that is not about this project is outside these terms and
always was.
The marks are not licensed under the GPL; GPL-3.0 §7(e) permits declining to
grant trademark rights. All rights not granted below are reserved.
**You may, without asking:**
- reproduce them unmodified to refer to dejima;
- redistribute them unmodified as part of an unmodified copy of a repository
that ships them.
**You may not, without written permission:**
- modify the artwork: recolour, redraw, crop, animate, or fold it into
another mark;
- use the artwork in your own work, or as the mark of any other project,
product or organisation;
- use the name for software in a way likely to be taken as referring to this
project - which is the only sense in which the name is claimed at all;
- use any of them so as to suggest that dejima endorses, sponsors or produced
your work.
A fork that remains a copy of dejima may keep the files. A fork that diverges
into a project of its own must replace them with a mark of its own.
Copyright in the image files is reserved.
+2 -3
View File
@@ -20,9 +20,8 @@ Significant change is coming in the near future. Config keys, interfaces, the
layout of the docs, and the boundaries between the CLIs are all still
settling. A fork taken today will diverge from upstream quickly.
That is worth knowing before you build anything on top of one. If you do fork,
pinning to a commit you have read is more useful here than tracking a branch
that is being rewritten under you.
If you do fork, pinning to a commit you have read is more useful here than
tracking a branch that is being rewritten under you.
## If you are running an agent against this repository
+121 -37
View File
@@ -1,7 +1,31 @@
# dejima
<!-- Fixed, not preference-aware, and that is the decision rather than an
oversight. `prefers-color-scheme` reports the browser's setting, never the
site's, and this is read on a forge that renders dark whichever way the
browser is set - so anything conditional shows dark ink on a dark page to
a reader whose browser prefers light, and does it silently.
The suffix names the mode a file is FOR, not the colour of its ink:
-darkmode is the pale wordmark that belongs on a dark page. A mirror on a
light-themed host swaps the filename below for dejima-wordmark-lightmode.png,
which is committed alongside for exactly that. One line, on the mirror,
where someone can see whether it worked.
Every &nbsp; here is spacing, and it has to be a character rather than CSS
or a presentation attribute. Gitea's sanitizer strips `style` from the tag
and from any wrapper, `hspace`/`vspace` survive sanitizing only to be
ignored by browsers that dropped HTML4 presentation attributes, and a bare
<br> ends a line nothing follows, which browsers collapse to no height.
&nbsp; survives as a real U+00A0: on its own line it fills a paragraph
that would otherwise be empty, and immediately before the tag it sits
inline and indents the image by one space. -->
&nbsp;
**Agents work best unsupervised. Bound the blast radius instead, and assume
the agent is hostile.**
&nbsp;<img src="assets/dejima-wordmark-darkmode.png" alt="dejima" height="72">
---
&nbsp;
**Agents work best without you babysitting them. Bound the blast radius, let them
work, and assume they are hostile.**
An agent with permission prompts turned off gets a lot done. It can also be
turned against you mid-session, by a prompt injection or a poisoned
@@ -13,13 +37,13 @@ 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. The agent gets
to work without stopping to ask; the container is what makes that a
reasonable trade rather than a reckless one.
to work without stopping to ask; the container is what makes that trade a
reasonable one.
Around that core is most of what running an agent unattended actually takes:
Around that core is most of what running an agent unattended 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.
list does not connect, and the agent is told so.
- **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
@@ -28,6 +52,10 @@ Around that core is most of what running an agent unattended actually takes:
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.
- **One project's session cannot reach another's.** `~/.claude` is not
mounted whole. A session gets its own project's transcripts, memory and
edit history and nothing else, so it cannot read what you did elsewhere or
write into the memory a future session in another project will believe.
- **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
@@ -35,12 +63,16 @@ Around that core is most of what running an agent unattended actually takes:
- **CI hardening, because push access is code execution.** A session opens a
pull request, the workflow fires, and your runner executes that commit
before you've read a line of it. The container bounds what a session reaches
directly, not what it can arrange to have run elsewhere. So this one is
work on the runner: [docs/ci.md](docs/ci.md) for what it takes, and
directly, not what it can arrange to have run elsewhere. Preventing this
requires hardening your CI runner: [docs/ci.md](docs/ci.md) for what it
takes, and
[dejima-ci-hardener](https://gitbox.branderson.io/dejima/dejima-ci-hardener)
for a tool that does it.
- **Logging.** Every DNS lookup and rejected connection is shipped to Loki,
along with the session transcript if you opt in.
- **Logging, off unless you turn it on.** Point `[logging] loki_url` at your
own Loki instance and every DNS lookup and rejected connection is shipped
there, with the session transcript too if you separately opt in. Nothing
leaves the machine otherwise, and there is no endpoint of ours anywhere in
this.
- **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
@@ -52,17 +84,37 @@ Around that core is most of what running an agent unattended actually takes:
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.
to, so a change to your git config or `~/.claude` gets surfaced.
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 grown well past it since.
**Early development. Not ready to be depended on.** Nothing here has been
through outside review, an external audit, or adversarial testing by anyone
but the person who wrote it. Every security property this describes is a
claim awaiting scrutiny, not one that has survived it. Do not put this
anywhere that being wrong about it would matter.
**One maintainer, no support.** Nothing about this is promised: not a reply,
not a fix, not a release. [SECURITY.md](SECURITY.md) has the detail, and is
where to report a vulnerability rather than in a public issue.
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
runtime.
A note from the developer about the voice in this repository:
> dejima is developed in dejima, and that means the words are written rapidly
> by AI agents. That applies to this README, the docs, the commit messages,
> the pull requests, and the code comments. I periodically take passes at
> smoothing out the AI voice in the docs, but it's inefficient to do with
> every pull request. I'm prioritizing thorough documentation over human
> voice. If you feel something sounds painfully "AI", it is.
>
> The artwork (logo, wordmark, etc.) is a different story. It's made by a
> human, and no AI tools were used to create it. See
> [ATTRIBUTION.md](ATTRIBUTION.md).
[![Developed in dejima](assets/dejima-badge.svg)](https://gitbox.branderson.io/dejima/dejima)
## The name
[Dejima](https://en.wikipedia.org/wiki/Dejima) was a small artificial island
@@ -112,8 +164,8 @@ road with no barrier.
## Using this responsibly
dejima bounds what a session can reach. It has nothing to say about whether
what that session wrote should exist. That part is yours.
dejima bounds what a session can reach. Whether what that session wrote
should exist is your call.
**Check whether the project wants it first.** A growing number of projects
state that they do not accept AI-assisted contributions. Fork-and-review is
@@ -194,10 +246,16 @@ every key, its default, and what it is for.
The forge integration, the push/PR workflow, and the agent harness aren't
hardcoded into dejima: each is a directory under `forges/`, `workflows/` or
`harnesses/`, selected independently by `[core] forge`, `[core] workflow` and
`[core] harness`. Ships today: `forges/gitea`, `forges/github`, `forges/none`,
`workflows/worktree-pr`, `workflows/branch-push`, `workflows/local-only`,
`harnesses/claude-code`. The defaults are `forges/none` and
`workflows/local-only`: a fresh install commits locally and pushes nowhere.
`[core] harness`.
Ships today:
- **Forges:** `none`, `gitea`, `github`
- **Workflows:** `local-only`, `branch-push`, `worktree-pr`
- **Harnesses:** `claude-code`
The defaults are `forge = none` and `workflow = local-only`: a fresh install
commits locally and pushes nowhere.
See [docs/providers.md](docs/providers.md) for the manifest contract, key by
key, and the shipped providers as worked examples.
@@ -229,8 +287,8 @@ to `worktree-pr` or `branch-push`: a pushing workflow is refused at launch
unless its forge resolves to somewhere to push to, so the forge is not
optional once you choose one of them.
A pushing workflow also needs a git push credential, and which one you give it
is a decision worth making deliberately rather than by default:
A pushing workflow also needs a git push credential. Which one you give it
matters:
- **`[core] ssh_key`, a key of the session's own**, belonging to an account
that is not you. This is the one to set up. It gives the sandbox an
@@ -306,8 +364,8 @@ merge, are what actually stop a merge from happening without you.
What holds across all three is where they stop: none of them puts work on
your default branch. `local-only` never leaves the machine, and the pushing
workflows stop at a branch or at a pull request. The last step is yours in
every case, which is a property of how the workflows are arranged rather than
something enforced against a session that does not want to cooperate.
every case. That comes from how the workflows are arranged, not from anything
enforced against a session that does not want to cooperate.
**→ [docs/configuration.md](docs/configuration.md#the-session-briefing)** -
the guards in full, and why they fail open.
@@ -315,12 +373,22 @@ the guards in full, and why they fail open.
### Configuring the session
Running a different editor or application than the default (`--cmd`,
`[core] command`, `[mounts] nvim_config`), and mounting extra writable host
directories a session should have access to (`[mounts] read_write`), are
both config-file changes rather than anything you set up per launch.
`[core] command`, `[mounts] nvim_config`) is a config-file change, as are the
extra host directories a session should reach: `[mounts] readonly` and
`[mounts] read_write`, mounted at their own path.
**→ [docs/configuration.md](docs/configuration.md)** - both, including which
host paths a writable mount is refused over and why.
For a one-off, `--extra-readonly PATH` and `--extra-read-write PATH` (short
forms `--ro` and `--rw`, both repeatable) add a path for a single launch, for
things like reading another checkout while migrating code out of it. They
append to those same lists, so a path a configured mount would be refused
over is refused here too.
```sh
dejima --ro ~/other-project ~/myproject
```
**→ [docs/configuration.md](docs/configuration.md#one-off-mounts-for-a-single-launch)** -
both forms, and which host paths a mount is refused over and why.
## Config parity with your host Claude Code setup
@@ -344,8 +412,7 @@ the project mount, for passing files in and out of a session without
touching the actual checkout. It persists across sessions since it's a real
directory, not a container volume - but treat it as a handoff point, not
general-purpose storage. **Clear it out between sessions.** Anything left
there is readable by the next session, and by every session after that, which
turns a file you forgot into something you handed over.
there is readable by the next session, and by every session after that.
`~/shared`, the project mount, and anything in `[mounts] read_write` are the
only writable host paths in the container. Everything else a session writes
@@ -456,6 +523,14 @@ sources, opt-in conversation logging, and viewing both in Grafana.
## GitHub push: blocked by default, optional opt-in
A session that is not being used to push to a forge should not be able to
push to it. Your configured forge is the one you chose; GitHub is where
everything else lives - every dependency a session reads, every repository it
clones to understand an error. The case this prevents is an eager agent, not
a compromised one: an unsolicited pull request on a stranger's project, in
your name, added to a queue its maintainers are already drowning in. See
[docs/responsible-use.md](docs/responsible-use.md) for what to do instead.
The sandbox can always read from GitHub - clone, fetch, `gh` API calls. By
default it can never push there: GitHub's IP ranges are only allowlisted on
port 443, so port 22 is physically unreachable, not merely discouraged by a
@@ -519,9 +594,8 @@ you what changed.
It is **advisory, not a block**. It fires after the session has ended, so a
finding is something to read and clean up before your next `git` or `claude`
in that repo, not something that was prevented. And its diff is time-windowed
rather than session-attributed: it reports that a path changed while the
session was running, not that the session changed it.
in that repo. Its diff is also time-windowed: it reports that a path changed
while the session was running, not that the session changed it.
`dejima-audit ack` walks the findings and shows you each one before asking.
Approving records the exact content, so any later change re-fires; ignoring
@@ -551,7 +625,7 @@ credentials never enter the container, that a push to anywhere but the
allowed remote is refused. Hundreds of tests across suites covering
static analysis, host-side behaviour, and containers started for real.
They are the reason claims here are stated flatly rather than hedged.
They are why the claims here are stated flatly.
→ **[docs/development.md](docs/development.md)**: the suites, what each
covers, and how to run them.
@@ -567,6 +641,8 @@ covers, and how to run them.
| [ci.md](docs/ci.md) | What push access grants on your CI host, and how to bound it |
| [remote-access.md](docs/remote-access.md) | Picking and verifying a bind address, and the SSH and web front doors |
| [development.md](docs/development.md) | The file map, the test suites, running them |
| [SECURITY.md](SECURITY.md) | What dejima claims, where to report a vulnerability, and what is promised in return |
| [ATTRIBUTION.md](ATTRIBUTION.md) | Who holds the dejima marks and what may be done with them |
## License
@@ -574,3 +650,11 @@ This project is licensed under the GNU General Public License v3.0 or later
(GPL-3.0-or-later); see [LICENSE](LICENSE) for the full text.
Copyright (C) 2026 Brad Anderson
The dejima wordmark and logo are not code and are not covered by that licence.
They are trademarks; who holds them and what may be done with them is stated
once for the organisation, in [ATTRIBUTION.md](ATTRIBUTION.md).
The egress firewall's original shape came from
[Anthropic's reference dev container](https://github.com/anthropics/claude-code/tree/main/.devcontainer).
`docker/init-firewall.sh` records what it kept and what it did not.
+77
View File
@@ -0,0 +1,77 @@
# Security policy
This is the security policy for every repository in the dejima organisation.
Individual repositories state what is specific to them and link here for the
rest.
## What dejima claims
It bounds what an agent session can reach. It does not make an agent safe to
run.
A session runs in a container with no permission prompts, one bind-mounted
project directory, a deny-by-default egress allowlist covering DNS as well as
IP, and no copy of the credentials it authenticates with. That allowlist
bounds where a prompt injection or a compromised dependency can send code and
where it can fetch its next instruction. It does not prevent either one.
Nothing here holds against a container escape or a kernel bug. The in-session
hooks are fast-fail guards, not a boundary. The exit-time audit runs after the
session has ended and attributes nothing to it. And none of it speaks to
whether the code a session wrote is correct, secure, or fit to merge.
[docs/security.md](docs/security.md) states the threat model and covers each
of these in detail.
## Reporting a vulnerability
Email **dejima@branderson.io**. Please do not open a public issue for a
vulnerability; the tracker is public, and a report there is a disclosure.
Useful things to include:
1. what an attacker gets;
2. the steps to reproduce it;
3. the repository and the commit you tested.
## What you can expect in return
Nothing is promised: no reply, no acknowledgement, no triage, no fix, and no
timeline for any of them. Reports are read when there is time to read them.
Nothing is asked in return either. There is no embargo period and no
coordinated disclosure window. Disclose on whatever schedule you think is
right.
## This is not proven software
These projects are in early development. Nothing in them has been through
outside review, an external audit, or adversarial testing by anyone other
than the people who wrote them. Where a design argument is stated it is one
person's reasoning; where a test asserts something it asserts what that
person thought to check.
Treat every security property described in any of these repositories as a
claim awaiting scrutiny rather than one that has survived it, and do not
deploy them where being wrong would matter.
## There is no support
These are personal projects with one maintainer.
There is no paid version, no support contract, no service level, no sponsorship
attached to them, and no commercial relationship with anyone who uses them.
Questions may go unanswered. Issues may go unread. Nothing here is developed or
supplied in the course of a commercial activity.
Whoever deploys this is responsible for the result.
## Supported versions
The tip of `main`, and nothing else. There are no maintenance branches, no
backports and no security releases for older tags or commits.
## No warranty
The software is provided without warranty of any kind. See sections 15 and 16
of each repository's `LICENSE`.
+19
View File
@@ -0,0 +1,19 @@
# assets
The dejima wordmark, in the two versions the README needs: `-darkmode` for a
page that renders dark, `-lightmode` for one that renders light. The suffix
names the mode a file is for, not the colour of its ink, so `-darkmode` is the
pale one.
The README points at `-darkmode` unconditionally. A mirror on a light-themed
host changes that one line; the comment above the tag says why it is not
conditional.
**These are trademarks and are not covered by this repository's GPL licence.**
Who holds them, and what may be done with them, is stated once for the whole
organisation, in this repository:
[ATTRIBUTION.md](../ATTRIBUTION.md)
Other repositories in the organisation link to that file by absolute URL, so
it does not move or get renamed without their links breaking with it.
+21
View File
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="122" height="20" role="img"
aria-label="Developed in dejima">
<title>Developed in dejima</title>
<linearGradient id="g" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r"><rect width="122" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="79" height="20" fill="#282828"/>
<rect x="79" width="43" height="20" fill="#8ec07c"/>
<rect width="122" height="20" fill="url(#g)"/>
</g>
<g font-family="Verdana,DejaVu Sans,Geneva,sans-serif" font-size="11"
text-anchor="middle">
<text x="39.5" y="15" fill="#010101" fill-opacity=".3">Developed in</text>
<text x="39.5" y="14" fill="#ebdbb2">Developed in</text>
<text x="100.5" y="15" fill="#010101" fill-opacity=".3">dejima</text>
<text x="100.5" y="14" fill="#282828">dejima</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 969 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

+252 -104
View File
@@ -23,6 +23,8 @@ source "$SCRIPT_DIR/dejima-project-lib.sh"
source "$SCRIPT_DIR/dejima-provider-lib.sh"
# shellcheck source=dejima-color-lib.sh
source "$SCRIPT_DIR/dejima-color-lib.sh"
# shellcheck source=dejima-slug-lib.sh
source "$SCRIPT_DIR/dejima-slug-lib.sh"
# fd 2: everything this script says for itself goes to stderr, so the
# session's own stdout stays exactly what the container wrote.
color_init 2
@@ -59,6 +61,65 @@ _need_value() {
}
}
# Per-launch additions to [mounts] readonly and [mounts] read_write, for the
# one-off cases: reading another checkout while migrating code out of it,
# writing to a scratch directory for one session. Repeatable, one path each.
#
# These append to the config's own lists rather than carrying their own mount
# code, which is the whole design. Everything that already applies to a
# configured mount then applies to a flag automatically and with nothing
# duplicated: the existence check, the refusal when a path names the approval
# store or the audit stores, the refusal when the same path appears in both
# lists, and the read-write paths the session briefing and the audit report
# header name. A flag is a way to say where, not a way around any of that.
EXTRA_RO_FLAGS=()
EXTRA_RW_FLAGS=()
FLAG_MOUNTS=""
_add_mount_flag() {
local flag="$1" value="$2"
# Both lists are colon-separated, so a path containing one cannot be
# represented in them at all - it would silently split into two mounts
# that each fail the existence check. Say so instead.
case "$value" in
*:*) echo "$flag path must not contain a colon: $value" >&2; exit 1 ;;
esac
# Same leading-~ expansion the config's list:path type does, for the
# quoted case the shell leaves alone.
value="${value/#\~/$HOME}"
# Relative paths are worth accepting here and meaningless in a config
# file, which has no working directory to be relative to. Made absolute
# by prefixing rather than by readlink -f: resolving would mount the
# symlink's target under a path the operator never typed.
case "$value" in /*) ;; *) value="$PWD/$value" ;; esac
case "$flag" in
--extra-readonly) EXTRA_RO_FLAGS+=("$value") ;;
--extra-read-write) EXTRA_RW_FLAGS+=("$value") ;;
esac
# Recorded here, at parse time, and not where the lists are assembled:
# that happens inside a command substitution, whose subshell would throw
# the assignment away and leave every flag reported as a config entry.
FLAG_MOUNTS="${FLAG_MOUNTS:+$FLAG_MOUNTS:}$value"
}
_append_mounts() {
local list="$1"; shift
local p
for p in "$@"; do
list="${list:+$list:}$p"
done
printf '%s' "$list"
}
# Which of the two the operator actually wrote, for the refusals below.
# Without this a mistyped --ro is reported as a "[mounts] readonly entry",
# sending someone to a config file that does not mention the path at all.
_mount_label() {
local path="$1" from_config="$2" from_flag="$3"
case ":$FLAG_MOUNTS:" in
*":$path:"*) printf '%s' "$from_flag" ;;
*) printf '%s' "$from_config" ;;
esac
}
PRINT_CONFIG=0
PRINT_BRIEFING=0
ALLOW_GITHUB_PUSH_FLAG=0
@@ -72,6 +133,12 @@ while [ $# -gt 0 ]; do
--cmd) _need_value --cmd "${2:-}"; _set_override core.command "$2"; shift 2 ;;
--forge) _need_value --forge "${2:-}"; _set_override core.forge "$2"; shift 2 ;;
--workflow) _need_value --workflow "${2:-}"; _set_override core.workflow "$2"; shift 2 ;;
--extra-readonly|--ro)
_need_value --extra-readonly "${2:-}"
_add_mount_flag --extra-readonly "$2"; shift 2 ;;
--extra-read-write|--rw)
_need_value --extra-read-write "${2:-}"
_add_mount_flag --extra-read-write "$2"; shift 2 ;;
--*) echo "unknown option: $1" >&2; exit 1 ;;
*) break ;;
esac
@@ -162,28 +229,18 @@ if ! config_exists; then
exit 1
fi
# A selected forge's env_export can name a variable core also fills from its
# own config. Today that is GH_TOKEN, which core sets from the read-only
# credential at [github] token_file - one every session gets regardless of
# which forge is selected - and which a forge exporting the same name takes over
# for the launch, since a single variable can only carry one token. Said out
# loud rather than left as two configured credentials with one silently
# ignored: an operator who set both is entitled to know which is in effect. A
# warning, not a refusal - the forge's value winning is exactly what
# selecting that forge asked for.
# A forge's env_export can name a variable core also fills - today GH_TOKEN,
# which core sets from [github] token_file for every session regardless of
# forge. One variable carries one token, so the forge's wins. Warned rather
# than left silent: an operator who configured both is entitled to know which
# is in effect. Not a refusal, since the forge winning is what selecting it
# asked for.
#
# Driven off the manifest's env_export rather than off the forge's name: the
# collision is a property of the variable a forge exports, identical for any
# forge that exports it, and core has no business knowing which forge that is
# today. Checked here, ahead of --print-config's own exit below (like
# _warn_legacy_env above it) rather than down by the GH_TOKEN block further
# down that --print-config never reaches, so --print-config reports it too.
#
# Both files, not just core's: a forge key resolving to nothing is the
# "wholly unconfigured" case forge_env_exports deliberately emits no pair for
# (see its own comment), leaving core's own value in effect - so claiming the
# forge's key won would be false in exactly that state. The warning only
# means something once both are actually set.
# Driven off env_export rather than the forge's name - the collision belongs
# to the variable, not to any particular forge. Checked here, ahead of
# --print-config's exit, so that reports it too. Both values must actually be
# set: a forge key resolving to nothing leaves core's in effect, and claiming
# the forge won would be false.
_warn_forge_env_collisions() {
local forge="$1" pair name ref core core_name core_section core_leaf
local core_file forge_file
@@ -241,6 +298,21 @@ DEJIMA_WORKFLOW_NAME="$(config_get core workflow)"
DEJIMA_HARNESS_NAME="$(config_get core harness)"
provider_validate_selection "$DEJIMA_FORGE_NAME" "$DEJIMA_WORKFLOW_NAME" "$DEJIMA_HARNESS_NAME" || exit 1
# Whether GitHub is a push destination this session, which the briefing tells
# the agent about. Two ways in: the per-launch flag, or a forge whose hosts
# core allowlists (github) under a workflow that pushes. Derived from the
# manifests directly rather than from DEJIMA_WORKFLOW_PUSH, which is resolved
# further down - this has to be settled before --print-briefing exits, or that
# would render a briefing a real launch does not.
DEJIMA_GITHUB_PUSH=off
if [ "$ALLOW_GITHUB_PUSH_FLAG" = 1 ]; then
DEJIMA_GITHUB_PUSH=on
elif [ "$(manifest_get forge "$DEJIMA_FORGE_NAME" hosts_allowlisted_by_core)" = "on" ] \
&& [ "$(manifest_get workflow "$DEJIMA_WORKFLOW_NAME" push)" = "on" ]; then
DEJIMA_GITHUB_PUSH=on
fi
export DEJIMA_GITHUB_PUSH
# Resolved here, ahead of --print-briefing below, because briefing_compose
# needs DEJIMA_RW_MOUNTS (the {{writable_mounts}} placeholder) to render the
# same sentence a real launch renders - see the read_write mounts block this
@@ -266,6 +338,8 @@ WORKSPACE_DIR="$(cd "$WORKSPACE_DIR" && pwd)"
AUDIT_DIR="$(config_get audit dir)"
BASELINE_DIR="$(config_get audit baselines_dir)"
SANDBOX_EXTRA_MOUNTS="$(config_get mounts readonly)"
SANDBOX_EXTRA_MOUNTS="$(_append_mounts "$SANDBOX_EXTRA_MOUNTS" \
${EXTRA_RO_FLAGS[@]+"${EXTRA_RO_FLAGS[@]}"})"
# Read-write host mounts. Distinct from [core] share_dir, which is mounted at
# ~/shared - a well-known handoff point the agent is told about by name -
@@ -286,7 +360,41 @@ _paths_overlap() {
case "$b/" in "$a"/*) return 0 ;; esac
return 1
}
# The stores that decide what a session may do next, and the record of what
# it did. Read-only is not enough to make mounting them harmless: the
# approval store discloses every project's approved domains and
# dependencies, and the audit baselines say which findings have already been
# waved through. Refusing beats mounting and hoping the mode holds (#15).
GUARDED_STORES=()
_guarded_stores() {
[ ${#GUARDED_STORES[@]} -eq 0 ] || return 0
GUARDED_STORES=("$(config_root)" "$AUDIT_DIR" "$BASELINE_DIR")
}
# Refuse $2 if it overlaps any of the paths in $3.., in either direction: an
# ancestor of a store exposes it just as surely as the store itself.
_refuse_guarded_mount() {
local kind="$1" path="$2"; shift 2
local real guarded guarded_real
# readlink -m rather than -f, same reason as the read_write guard below:
# -f gives nothing for a path that does not exist yet, and an empty
# result compares equal to nothing and would sail through.
real="$(readlink -m "$path")"
for guarded in "$@"; do
[ -n "$guarded" ] || continue
guarded_real="$(readlink -m "$guarded")"
_paths_overlap "$real" "$guarded_real" || continue
echo "Refusing $kind: $path" >&2
echo "It overlaps $guarded_real, which dejima needs out of a session's reach." >&2
echo "Even read-only, that hands the session every project's approvals or the" >&2
echo "record of what earlier sessions were caught doing." >&2
exit 1
done
}
SANDBOX_RW_MOUNTS="$(config_get mounts read_write)"
SANDBOX_RW_MOUNTS="$(_append_mounts "$SANDBOX_RW_MOUNTS" \
${EXTRA_RW_FLAGS[@]+"${EXTRA_RW_FLAGS[@]}"})"
RW_MOUNT_PATHS=()
DEJIMA_RW_MOUNTS=""
if [ -n "$SANDBOX_RW_MOUNTS" ]; then
@@ -294,7 +402,7 @@ if [ -n "$SANDBOX_RW_MOUNTS" ]; then
for path in "${rw_paths[@]}"; do
[ -n "$path" ] || continue
if [ ! -d "$path" ]; then
echo "[mounts] read_write entry is not an existing directory: $path" >&2
echo "$(_mount_label "$path" "[mounts] read_write entry" "--extra-read-write path") is not an existing directory: $path" >&2
exit 1
fi
# readlink -m, not -f: -f yields nothing unless at most the final
@@ -311,7 +419,7 @@ if [ -n "$SANDBOX_RW_MOUNTS" ]; then
"$HOME/.claude" "$HOME/.local/bin" "$REPO_ROOT" "$WORKSPACE_DIR"; do
guarded_real="$(readlink -m "$guarded")"
_paths_overlap "$rw_real" "$guarded_real" || continue
echo "Refusing [mounts] read_write entry: $path" >&2
echo "Refusing $(_mount_label "$path" "[mounts] read_write entry" "--extra-read-write path"): $path" >&2
echo "It overlaps $guarded_real, which dejima needs out of a session's reach." >&2
echo "A writable mount there would let a session approve its own domains and" >&2
echo "dependencies, delete the audit findings it is named in, or plant code" >&2
@@ -508,12 +616,20 @@ fi
# A valid forwarded socket with zero identities loaded forwards fine (the
# container sees a live, working agent) but is useless for git-over-ssh -
# warn now rather than have that surface confusingly deep into a session as
# a failed push. Non-fatal: a task might not need git-over-ssh at all, and
# SANDBOX_SSH_KEY (if set) provides its own identity independently of this.
if [ "$have_forwarded_agent" = 1 ] && ! ssh-add -l >/dev/null 2>&1; then
# a failed push. Non-fatal: a task might not need git-over-ssh at all.
#
# Silent when [core] ssh_key is set, because then the premise is false: the
# key is loaded into the container's own agent and pushes work without the
# forwarded one carrying anything. It is also the better arrangement of the
# two - a dedicated identity that works unattended, rather than the
# operator's own - so warning about the fallback being empty, while the
# recommended path is configured and working, tells someone who took the
# advice that they got it wrong.
if [ "$have_forwarded_agent" = 1 ] && [ -z "$SANDBOX_SSH_KEY" ] \
&& ! ssh-add -l >/dev/null 2>&1; then
echo "Warning: ssh-agent has no identities loaded (ssh-add -l shows none)." >&2
echo "git-over-ssh (e.g. pushing to the configured forge) will fail until you ssh-add a key," >&2
echo "unless [core] ssh_key is also set." >&2
echo "or set [core] ssh_key in $(config_path) to a dedicated key - see docs/configuration.md." >&2
fi
# Deliberately not `gh auth token` / gh's own hosts.yml: a normal `gh
@@ -570,47 +686,29 @@ if ! settings_error="$(jq . "$HOME/.claude/settings.json" 2>&1 >/dev/null)"; the
exit 1
fi
# Claude Code's own internal per-command sandboxing (~/.claude/settings.json's
# "sandbox" block) is redundant here - it can't even function inside this
# container (bwrap can't create a namespace, and getting it to would need
# privilege escalation approaching --privileged, undoing this container's own
# hardening), and this container is already a real, working sandbox on its
# own. A session can't be trusted to turn it off itself, so the override is
# generated here, on the host, and reaches Claude Code as an extra settings
# layer: docker-compose.yml mounts this file read-only at a container-local
# path and claude-wrapper.sh passes it as --settings, which loads above
# ~/.claude/settings.json in the precedence order. Everything else in the
# operator's real settings (hooks, permissions, plugins) still applies from
# there, so this file holds only what the sandbox overrides.
# Claude Code's own per-command sandboxing is redundant here and cannot work
# anyway - bwrap can't create a namespace without privileges that would undo
# this container's hardening - and a session can't be trusted to turn it off
# itself. So the override is generated host-side and reaches Claude Code as
# an extra --settings layer above ~/.claude/settings.json, which holds only
# what the sandbox overrides; the operator's hooks, permissions and plugins
# still apply from there.
#
# The earlier arrangement - a full modified copy of the operator's settings,
# mounted over ~/.claude/settings.json - detached itself whenever the host
# rewrote the real file, because that mount point sits inside the read-write
# ~/.claude mount and a rename unlinks the dentry the mount is pinned to. The
# session then silently fell back to the operator's settings, with sandboxing
# back on and the notify hooks below gone. Mounting outside ~/.claude removes
# that: nothing on the host can rename over a container-local path. See #47.
# It is mounted at a container-local path, not over ~/.claude/settings.json.
# That earlier arrangement detached whenever the host rewrote the real file
# (a rename unlinks the dentry the mount is pinned to), silently restoring
# the operator's settings with sandboxing back on. See #47.
SANDBOX_SETTINGS_FILE="$(mktemp "${TMPDIR:-/tmp}/dejima-settings.XXXXXX.json")"
jq -n '{sandbox: {enabled: false}}' > "$SANDBOX_SETTINGS_FILE"
# ~/.claude.json mixes live app state (project registry, usage stats,
# caches) with config (mcpServers) that can't be split at the mount level -
# unlike settings.json above, it can't just be made read-only either:
# Claude Code does an atomic write-temp-then-rename against this exact file
# 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 - see
# https://github.com/anthropics/claude-code/issues/29162). Generate a
# throwaway copy instead, seeded from the real file, and mount *that*
# read-write in its place - same "generate a sandbox-specific file on the
# host" pattern as SANDBOX_SETTINGS_FILE above, just solving a different
# problem: nothing a compromised session writes here (mcpServers or
# anything else) ever reaches the real file, since the copy is discarded,
# not merged back, when this session ends. This also means concurrent
# sandbox sessions never contend over the same file - each gets its own
# copy. See docs/configuration.md "Config parity with your host Claude Code
# setup" for exactly what that means
# doesn't carry over from a sandboxed session.
# ~/.claude.json mixes live app state with config (mcpServers) that cannot be
# split at the mount level, and read-only is not an option: Claude Code does
# an atomic write-and-rename against it during startup, and a read-only file
# makes that HANG rather than fail cleanly (anthropics/claude-code#29162).
# So it gets a throwaway copy, seeded from the real file and discarded rather
# than merged back - nothing a session writes here reaches the host, and
# concurrent sessions never contend. See docs/configuration.md, "Config
# parity with your host Claude Code setup".
SANDBOX_CLAUDE_JSON_FILE="$(mktemp "${TMPDIR:-/tmp}/dejima-claude-json.XXXXXX.json")"
if [ -f "$HOME/.claude.json" ]; then
cp "$HOME/.claude.json" "$SANDBOX_CLAUDE_JSON_FILE"
@@ -839,6 +937,13 @@ for mounted_dir in hooks skills commands agents output-styles; do
case " $mounted_dirs " in
*" $real_dir "*) continue ;;
esac
# The target is discovered, not configured, so nothing has vetted it
# (#15). A hook symlinked into the approval store would mount it
# here, read-only and silently. Refusing rather than skipping: a
# skipped mount leaves the symlink dangling inside the container and
# the hook failing for a reason nothing states.
_guarded_stores
_refuse_guarded_mount "~/.claude symlink target" "$real_dir" "${GUARDED_STORES[@]}"
mounted_dirs="$mounted_dirs $real_dir"
extra_mounts+=(-v "$real_dir:$real_dir:ro")
done
@@ -883,9 +988,15 @@ if [ -n "$SANDBOX_EXTRA_MOUNTS" ]; then
for path in "${extra_paths[@]}"; do
[ -n "$path" ] || continue
if [ ! -e "$path" ]; then
echo "[mounts] readonly entry does not exist: $path" >&2
echo "$(_mount_label "$path" "[mounts] readonly entry" "--extra-readonly path") does not exist: $path" >&2
exit 1
fi
# [mounts] read_write has been guarded since it was added; this one
# never was, so `readonly = ~/.config/dejima` mounted the approval
# store into the container and nothing said a word (#15).
_guarded_stores
_refuse_guarded_mount "$(_mount_label "$path" "[mounts] readonly entry" "--extra-readonly path")" \
"$path" "${GUARDED_STORES[@]}"
extra_mounts+=(-v "$path:$path:ro")
done
fi
@@ -927,36 +1038,50 @@ if [ -d "$WORKSPACE_DIR/.git/hooks" ]; then
extra_mounts+=(-v "$WORKSPACE_DIR/.git/hooks:$WORKSPACE_DIR/.git/hooks:ro")
fi
# ~/.claude/statusline.sh is the same class of file as the config staged at
# /etc/dejima-claude by docker-compose.yml: settings.json's statusLine.command
# points at it, and Claude Code executes it on the host. Staged the same way,
# for entrypoint.sh to copy into place, rather than mounted at its real path.
# ~/.claude/projects, one slug at a time: the tree is every project's
# transcripts and memory, and memory is loaded into context at session start,
# so a write into another slug is background a future session there believes
# (#104). projects/ itself stays container-local, so an unmounted slug is
# still writable and just does not survive - which is how Claude Code creates
# a slug it does not find. Existing slugs only, apart from the workspace's
# own, so a checkout full of worktrees does not litter the host with empty
# directories for worktrees nothing has run in.
mkdir -p "$HOME/.claude/projects/$(ws_slug "$WORKSPACE_DIR")"
while IFS= read -r _slug; do
[ -n "$_slug" ] || continue
[ -d "$HOME/.claude/projects/$_slug" ] || continue
extra_mounts+=(-v "$HOME/.claude/projects/$_slug:$HOME/.claude/projects/$_slug")
done < <(workspace_slugs_sorted "$WORKSPACE_DIR")
# ~/.claude/file-history, same shape, sharper reason: `<session>/<hash>@v1`
# is a verbatim copy of an edited file, so the tree is other projects' source
# rather than a record of it, and it was writable - tampering there puts
# chosen content into a later restore. Keyed by session id, so there is no
# slug to match on; workspace_session_ids resolves it through the transcript
# names. The starting session's id cannot be known yet, so its directory is
# container-local: undo history for it is lost if it is resumed on the host.
mkdir -p "$HOME/.claude/file-history"
while IFS= read -r _sid; do
[ -n "$_sid" ] || continue
[ -d "$HOME/.claude/file-history/$_sid" ] || continue
extra_mounts+=(-v "$HOME/.claude/file-history/$_sid:$HOME/.claude/file-history/$_sid")
done < <(workspace_session_ids "$HOME/.claude/projects" "$WORKSPACE_DIR")
# statusline.sh is executed on the host by Claude Code (settings.json's
# statusLine.command), so it is staged for entrypoint.sh to copy rather than
# mounted at its real path - same as the config in docker-compose.yml.
#
# It used to be mounted read-only at $HOME/.claude/statusline.sh, and that
# protected only the plain-regular-file case - confirmed against a live
# container, not just reasoned about. When statusline.sh was itself a symlink
# (the common case - typically into a dotfiles checkout), the read-write
# ~/.claude mount was established first, so the destination already had that
# symlink sitting on it, and bind-mount destination resolution (runc's
# SecureJoin) followed it: the :ro mount landed on the target directory
# instead of the symlink dentry, which stayed writable and replaceable. There
# was no mount-based fix while ~/.claude was mounted whole, and the
# compensating control was detection after the fact (bin/dejima-audit hashes
# the symlink's target string). Staging closes it outright: Docker resolves
# the symlink when it sets up the staging mount, so entrypoint.sh copies the
# real content to a path nothing on the host can reach.
# A :ro mount there protected only the plain-file case, confirmed against a
# live container. When statusline.sh is itself a symlink (the common case),
# bind-mount destination resolution follows it, so the mount lands on the
# target and the link stays replaceable. Staging closes that: Docker resolves
# the symlink setting up the staging mount, and the copy lands where the host
# cannot reach it.
#
# Conditional on existence because not every host has one, and mounting a
# nonexistent source would make Docker create a root-owned directory at the
# target instead - worse than the gap this closes.
#
# A *dangling* symlink (target doesn't exist, e.g. a dotfiles checkout that
# isn't present) is neither "exists" nor safely mountable, so it gets its own
# branch that refuses to launch rather than either silently skipping the
# mount (reopens the gap plain -e had) or attempting it anyway (Docker can't
# bind-mount a dangling symlink - it fails deep in mount setup with an opaque
# "mkdir ...: file exists", since the symlink already occupies that path,
# with nothing connecting that error back to the statusline for the user).
# Conditional on existence - mounting a missing source makes Docker create a
# root-owned directory at the target. A dangling symlink is neither
# mountable nor "exists", so it refuses rather than skipping the mount
# (reopening the gap) or failing later with an opaque "mkdir: file exists".
if [ -L "$HOME/.claude/statusline.sh" ] && [ ! -e "$HOME/.claude/statusline.sh" ]; then
echo "$HOME/.claude/statusline.sh is a symlink to a target that doesn't" >&2
echo "exist. Docker can't bind-mount a dangling symlink, and skipping the" >&2
@@ -967,16 +1092,12 @@ elif [ -e "$HOME/.claude/statusline.sh" ]; then
extra_mounts+=(-v "$HOME/.claude/statusline.sh:/etc/dejima-claude/statusline.sh:ro")
fi
# Keep the real Anthropic OAuth credential out of the container entirely:
# run a host-side proxy that injects it per-request (see
# bin/dejima-anthropic-proxy and docs/security.md). Nothing needs masking
# any more - ~/.claude isn't mounted as a whole, so the real credential (and
# the proxy's .credentials-proxy-tmp staging directory beside it) simply
# never crosses into the container. That masking is what #50 was: both masks
# were single-file/-directory mounts nested in the read-write ~/.claude
# mount, and the proxy's own write-back renamed the credential out from
# under them, detaching the mask and exposing the real token from the first
# refresh on.
# The real Anthropic OAuth credential never enters the container: a host-side
# proxy injects it per request (bin/dejima-anthropic-proxy, docs/security.md).
# Nothing needs masking now that ~/.claude isn't mounted whole - and masking
# is what #50 was, a single-file mount nested in the read-write ~/.claude
# that the proxy's own write-back renamed out from under, exposing the real
# token on the first refresh.
#
# How the container then reaches the proxy depends on the transport. In the
# default tls one, entrypoint.sh points api.anthropic.com at an in-container
@@ -1206,6 +1327,17 @@ if remote_enabled; then
mv "$SANDBOX_REMOTE_SETTINGS_TMP" "$SANDBOX_SETTINGS_FILE"
fi
# Everything from here to the container's own first output is silent: the
# audit snapshot, an image build that prints nothing when every layer is
# cached, and Docker creating the container with a mount per allowed path.
# Long enough to read as hung, so say what is happening first. A first run,
# or one after a Dockerfile change, builds the image and takes minutes rather
# than seconds - the line names that, since it is the case where someone is
# most likely to give up and interrupt.
printf '%sdejima%s is starting, please wait...\n' "$C_KEY" "$C_RESET" >&2
printf '%sA first run builds the container image, which takes several minutes.%s\n' \
"$C_DIM" "$C_RESET" >&2
# Snapshot host-persistence surfaces so the post-run audit can report
# anything that changed while this session ran (advisory - see bin/dejima-audit).
"$SCRIPT_DIR/dejima-audit" snapshot "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
@@ -1259,6 +1391,22 @@ if [ -n "$PROJECT_STORE" ] && [ -s "$PROJECT_STORE/dependencies" ]; then
fi
export SANDBOX_IMAGE
# nvim's data, state and cache are Docker volumes rather than host paths, so
# nothing a session installs reaches the host's own nvim. They did outlive
# the container under one fixed name each, though, shared by every project
# on the machine: a session that drops a file under `lazy/`, or replaces a
# binary under `mason/bin/`, is writing something a later session in an
# unrelated project executes the next time nvim runs in the sandbox. That is
# the persistence this repository closes everywhere else, so it is scoped
# the same way the derived image tag is - per project.
#
# The prefix is not decoration: project_slug maps a leading `/` to `-`, and
# a Docker volume name may not start with one.
_nvim_volume_slug="$(project_slug "$WORKSPACE_DIR")"
export SANDBOX_NVIM_DATA="dejima_nvim-data${_nvim_volume_slug}"
export SANDBOX_NVIM_STATE="dejima_nvim-state${_nvim_volume_slug}"
export SANDBOX_NVIM_CACHE="dejima_nvim-cache${_nvim_volume_slug}"
# Not exec'd (the EXIT trap cleans up the generated temp files) and not a
# bare command under set -e (a nonzero/interrupted container run - e.g.
# Ctrl-C returning 130 - would otherwise abort the script here, skipping the
@@ -1270,7 +1418,7 @@ export SANDBOX_IMAGE
# because exit-time output can be ignored exactly once, permanently. This is
# host-side and stays host-side: it must never be routed into the session's
# context, which would make it a cross-session persistence channel.
audit_slug="$(printf '%s' "$WORKSPACE_DIR" | sed 's/[^a-zA-Z0-9]/-/g')"
audit_slug="$(ws_slug "$WORKSPACE_DIR")"
audit_reports="$AUDIT_DIR/$audit_slug"
# `|| true`: a workspace with no history yet (the common case - nothing has
# ever run against it, or every past finding is already acknowledged) has no
+30 -47
View File
@@ -24,23 +24,17 @@ 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:
# gruvbox dark, every value upstream's and tagged with its color.table row:
# 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.
# The names are carried so a wrong hex is catchable by reading.
#
# 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.
# Truecolor only: a terminal without it, or tmux without `Tc`, approximates
# 38;2 to its nearest colour rather than printing the escape, so the fallback
# already exists one layer down.
#
# 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.
# Empty unless stdout is a colour-capable terminal, which keeps --list a
# machine interface (ttyd feeds run ids back in) and is also how the bats
# suite reads this script.
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
@@ -95,22 +89,16 @@ state_color() {
# 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.
# Sized against what holds a launch up: the registry entry is written before
# an unconditional `docker compose build`, plus up to two derived image
# builds, and a cold base build fetches apt and npm layers - minutes. A warm
# run 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.
# Generous because the errors are not symmetric. Too long leaves a dead
# session listed until the window passes. Too short deletes the entry of a
# session about to start: bin/dejima never rewrites it, so the session is
# permanently absent from both front doors, and the marker directory removed
# with it is a bind source the imminent `compose run` declares.
STARTUP_GRACE=900
# Parsed, never sourced: this file is host-written, but treating state files
@@ -281,26 +269,21 @@ attach() {
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.
# Both values from the same live pane, one round trip. Taking the session
# name from the recorded label would follow a name rather than the
# sandbox, and a rename is enough to point it at an unrelated session.
#
# 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.
# The name is a starting point, not a guarantee: it is often another
# remote client's throwaway group, and those carry destroy-unattached, so
# anyone with a second connection can make the base stop existing. Hence
# the verification below - `new-session -t` does not fail on a name
# matching nothing, it silently creates an 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.
# display-message prints nothing and exits 0 for a vanished target, so an
# empty result means the pane went away since the check above. The
# verification catches that too; bailing early just avoids creating a
# junk session and says what 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%:*}"
+61 -86
View File
@@ -12,6 +12,8 @@ set -uo pipefail
source "$(dirname "$(readlink -f "$0")")/dejima-provider-lib.sh"
# shellcheck source=dejima-color-lib.sh
source "$(dirname "$(readlink -f "$0")")/dejima-color-lib.sh"
# shellcheck source=dejima-slug-lib.sh
source "$(dirname "$(readlink -f "$0")")/dejima-slug-lib.sh"
# Defined empty here so every interpolation below is safe before a surface
# picks its stream; compare (stderr) and ack (stdout) each re-init for theirs.
color_init 1
@@ -53,7 +55,9 @@ AUDIT_DIR="$(config_get audit dir)"
# suggesting a restart is an entirely ordinary thing to suggest.
AUDIT_KEEP="$(config_get audit keep)"
ws_slug() { printf '%s' "$1" | sed 's/[^a-zA-Z0-9]/-/g'; }
# ws_slug and workspace_slugs come from dejima-slug-lib.sh, shared with
# bin/dejima so the set of slugs it mounts and the set treated as this
# workspace's own here cannot drift apart.
# The one report both `show` and `ack` operate on: the OLDEST unacknowledged
# CRITICAL report if there is one, else the newest report overall.
@@ -83,19 +87,16 @@ BASELINE_DIR="$(config_get audit baselines_dir)"
# should mean that repo. bin/dejima always passes an explicit path, so this
# only ever fires for a hand-run show/ack.
#
# Candidates in order, first one that already HAS reports wins - existence of
# a report directory is the only evidence available about which path
# bin/dejima was actually invoked with, and the three candidates can
# genuinely differ:
# 1. the enclosing repo's toplevel (the ordinary case, and from a subdir);
# 2. the MAIN worktree root, when cwd is a linked worktree - the sandbox's
# own .worktrees/<branch> workflow keys reports to the parent repo;
# 3. $PWD, which covers a non-git directory and the case where the path
# bin/dejima was given (logical, via `cd && pwd`) differs from git's
# physical toplevel because of a symlinked path component.
# Falling back to candidate 1 when none of them has reports is purely so the
# "no reports for X" message can name a concrete path: a wrong guess must be
# visible rather than silent.
# Candidates in order, first one that already HAS reports wins - a report
# directory is the only evidence of which path bin/dejima was invoked with,
# and the three can genuinely differ:
# 1. the enclosing repo's toplevel (ordinary case, and from a subdir);
# 2. the MAIN worktree root, when cwd is a linked worktree - the
# .worktrees/<branch> workflow keys reports to the parent repo;
# 3. $PWD, for a non-git directory, or when the logical path bin/dejima was
# given differs from git's physical toplevel through a symlink.
# Falling back to 1 when none has reports is so "no reports for X" can name a
# concrete path: a wrong guess must be visible rather than silent.
resolve_ws() {
local given="$1" top common main c
if [ -n "$given" ]; then
@@ -654,37 +655,26 @@ sanitize() {
| LC_ALL=C sed $'s/\xc2[\x80-\x9f]//g; s/\xe2\x80[\xaa-\xae]//g; s/\xe2\x81[\xa6-\xa9]//g'
}
# sanitize() is lossy, so it must never be what an ID passes through.
# sanitize() is lossy, so an ID must never pass through it. `ack` derives the
# baseline id from the report text and the next `compare` matches it against
# the RAW bytes on disk, so a stripped id cannot round-trip: the surface
# becomes permanently unsuppressible and the startup warning fires forever.
#
# `ack` derives the baseline id it records from the report's text, and the
# next `compare` matches that id against the RAW bytes on disk. An id
# containing anything sanitize() strips therefore could not round-trip at
# all: the surface stayed permanently unsuppressible and its report
# permanently unclearable, so bin/dejima's startup warning fired forever -
# the alert fatigue this whole feature exists to end, reached from the other
# side. Ids are encoded instead, and the encoded form is canonical
# everywhere it is written down: in the report AND in the baseline file.
# Ids are encoded instead, canonically, in the report AND the baseline file.
# An id sanitize() leaves alone is written verbatim (every ordinary one, so
# existing baselines are unchanged); anything else is hex-encoded behind a
# marker, which is ASCII and reverses exactly.
#
# An id that survives sanitize() unchanged is already display-safe and is
# written verbatim - that is every ordinary id, so reports and existing
# baseline files are byte-for-byte what they were. Anything else is
# hex-encoded behind a marker, which is ASCII by construction and reverses
# exactly.
# An id that merely LOOKS encoded is encoded too, so the marker's presence
# always means this encoder put it there. Otherwise a workspace could present
# an id `ack` decodes into one of the attacker's choosing - baselining
# something other than the finding while counting it acknowledged. Only the
# START matters, and no monitored path begins with the marker, but a git
# config section name is free text the session can write.
#
# An id that merely LOOKS encoded is encoded too, so that decoding stays
# unambiguous: the marker's presence always means this encoder put it there.
# Without that, a workspace could present an id that `ack` decoded into one
# of the attacker's choosing - baselining something other than the finding
# while still counting it acknowledged, the same shape as the id-derivation
# hijacks the anchored case arms in `ack` guard against. Only the START of
# an id is significant, and no monitored path can begin with the marker
# (they are all rooted at .claude/.vscode/.git/.envrc/.mcp.json), but a git
# config section name is free text in a file the session can write and the
# audit parses that file directly rather than through git.
#
# The encoded form is also the honest rendering. sanitize() DELETES those
# bytes, so a planted ".git/hooks/pre<U+202E>commit" displayed as
# "precommit" sends an operator grepping for a name no file on disk has.
# Encoding is also the honest rendering: sanitize() DELETES those bytes, so a
# planted ".git/hooks/pre<U+202E>commit" shown as "precommit" sends an
# operator grepping for a name no file has.
HEX_MARK='{hex}'
enc_id() {
local v="$1"
@@ -1192,13 +1182,17 @@ case "$cmd" in
# projects diffs: a write to another project's ~/.claude/projects
# slug during this session (transcript/memory-poisoning surface).
# "self" = current workspace's own slug or a worktree child of it.
current_slug="$(ws_slug "$ws")"
# "self" = the workspace's own slug or a live worktree nested under
# it, exactly the set bin/dejima mounts. This used to be a
# "$current_slug"-* glob, which also matched any sibling project whose
# directory name extended this one's - so on a host with `proj` and
# `proj-ci` side by side, writes to `proj-ci` were silently exempt.
self_slugs="$(workspace_slugs_sorted "$ws")"
while IFS= read -r line; do
slug="${line%%$'\t'*}"
case "$slug" in
"$current_slug"|"$current_slug"-*) continue ;;
esac
if printf '%s\n' "$self_slugs" | grep -qxF -- "$slug"; then
continue
fi
warn+=("CROSS-PROJECT history/memory write: $(sanitize "$slug")")
done < <(comm -13 <(sed -n '/^### projects/,/^### tcb/p' "$state" | grep -v '^### ') \
<(sed -n '/^### projects/,/^### tcb/p' "$new" | grep -v '^### ') )
@@ -1440,47 +1434,28 @@ case "$cmd" in
*) continue ;;
esac
[ "$tier" = CRITICAL ] && crit_seen=$((crit_seen + 1))
# Derive the namespaced id baseline_suppresses() will later
# compare against (see id_severity/current_sha_for_id above) -
# one case arm per finding-producing loop in `compare`. Every
# pattern is anchored to the fixed label text `compare` itself
# emits at the START of body, never matched as a substring
# anywhere in it: a workspace-controlled value (a git config
# value, a path) can contain text like "workspace file: " or
# "TCB (sandbox self) modified: " and reach $body unmodified, so
# an unanchored match could be hijacked into deriving the wrong
# (attacker-chosen) id for the wrong namespace - approving it
# would then baseline an id that can never match, un-baselining
# the real finding while still counting it as acknowledged. A
# finding whose body matches none of these (currently only
# CROSS-PROJECT) has no id to record, so it's shown but not
# offered a/s.
# Derive the namespaced id baseline_suppresses() compares
# against - one arm per finding-producing loop in `compare`.
#
# The id itself must then be recovered with a shortest-prefix
# strip of that same fixed label (`${body#LABEL}`), never
# `${body##*: }`: the latter strips up to the LAST ": " in the
# whole string, and a workspace-controlled path can itself
# contain a literal ": " (e.g. ".git/hooks/pre-commit: decoy"),
# which would truncate the derived id to everything after that
# planted separator - the case match being anchored doesn't
# protect the extraction that follows it.
# Both steps must resist workspace-controlled text in $body. The
# patterns anchor to the fixed label at the START of body: a git
# config value or path can contain "workspace file: ", so an
# unanchored match derives an attacker-chosen id in the wrong
# namespace, and approving it baselines an id that never matches
# while counting the real finding as acknowledged. The id is then
# recovered with `${body#LABEL}`, never `${body##*: }` - the
# latter strips to the LAST ": ", and a path can contain one.
# A body matching no arm (currently only CROSS-PROJECT) gets no
# id: shown, but not offered a/s.
#
# Two forms come out of this, because the two consumers need
# different ones (see enc_id): $id is the ENCODED id exactly as
# the report carries it, and is what gets written to the
# baseline file, which stores that form. $rid is the same id
# decoded back to the raw bytes on disk, for current_sha_for_id
# to stat and hash and for id_severity to classify. Only the
# id-bearing portion is decoded - never the namespace prefix
# this code adds itself.
#
# $lbl and $surface are for DISPLAY only: the fixed label this
# arm matched, and the workspace-named thing inside the body that
# the finding is about. Splitting them is what lets the surface
# be coloured without colouring the audit's own words around it,
# and they are recomputed here rather than derived from $id
# because a gitconfig body carries `key=value` while its id is
# the key alone.
# $id is the ENCODED form the report and baseline file carry;
# $rid is it decoded, for current_sha_for_id to stat and hash and
# id_severity to classify. Only the id-bearing portion decodes,
# never the namespace prefix added here. $lbl and $surface are
# display-only, split so the surface can be coloured without the
# audit's own words around it, and recomputed rather than derived
# from $id because a gitconfig body carries `key=value` while its
# id is the key alone.
ackable=0; id=""; rid=""; lbl=""; surface=""
case "$body" in
"HOST-CODE-EXECUTION workspace file: "*)
+11 -17
View File
@@ -22,26 +22,20 @@
# Schema
#
# Three TAB-separated columns: key, type, default. One source of truth - both
# _config_spec and _config_keys read this, so a key cannot exist for lookup
# but be missing from enumeration (which is what --print-config walks).
# _config_spec and _config_keys read it, so a key cannot be lookup-able but
# missing from --print-config's enumeration.
#
# A "?" prefix on the type means the empty string is a legal value. Without
# it, empty is rejected: the distinction is between a setting that is
# meaningfully absent (ssh_key, loki_url) and one that must always have a
# A "?" type prefix means empty is legal, distinguishing a setting that is
# meaningfully absent (ssh_key, loki_url) from one that must always have a
# value (firewall, share_dir).
#
# The three axis keys are `provider:<axis>` - validated by looking the name
# up under the provider root (see _config_extra_type_valid below and
# dejima-provider-lib.sh) rather than a fixed enum, so adding a provider is
# adding a directory, not editing this schema.
#
# [forge.<name>] and [harness.<name>] keys are absent from this table for the
# same reason: enumerating a forge's own keys here would mean a new forge
# needs a core edit, which is exactly what the provider axes are for.
# Those two sections are resolved instead through the _config_extra_*
# functions below, stubbed here and overridden by dejima-provider-lib.sh once
# it is sourced, against the type+default each provider's own `keys` manifest
# entry declares (see _provider_keyspec_parse there).
# The axis keys are `provider:<axis>`, validated against the provider root
# rather than a fixed enum, so adding a provider is adding a directory.
# [forge.<name>] and [harness.<name>] keys are absent here for the same
# reason - enumerating them would make a new forge need a core edit. They
# resolve through the _config_extra_* functions below, stubbed here and
# overridden by dejima-provider-lib.sh, against each provider's own `keys`
# manifest entry.
# ---------------------------------------------------------------------------
_config_table() {
cat <<'EOF'
+14 -20
View File
@@ -7,30 +7,24 @@
# (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:
# This script is the trust boundary: a possibly-compromised session writes
# the markers, and the phone they reach is outside the sandbox. The
# in-container hook's enum constrains nothing here - a session owns its
# marker directory and can create any name it likes. The guarantee is here:
#
# - 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
# - only the marker NAME is consulted, by iterating a literal enum, so a
# name outside it is never stat'd, read, or removed
# - marker CONTENT is never read, and names never reach a command
# - `rm -f` unlinks a symlink rather than following it
# - 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.
# The last point is what the enum is for: ntfy's X-Actions can make the phone
# issue an HTTP request or fire an Android intent, X-Attach and X-Icon fetch
# URLs. One attacker-controlled header makes the phone 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.
# One scan covers every run directory, so nothing a session does to its own
# markers can abort it and silence every other session's notifications.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
+29 -41
View File
@@ -115,24 +115,20 @@ read_declared() {
# Shared argument parsing for dejima-allow and dejima-dep, so the two CLIs
# cannot drift on what --project means.
#
# The hard part is that --project's path is OPTIONAL, which makes
# `--project X` ambiguous on its own: X could be the path, or the required
# positional. Deciding by "is X the last token" is wrong for `dejima-dep list
# --project ~/p`, which has no trailing positional to protect and would
# silently resolve to the current directory instead - the scope quietly
# becoming one the caller did not name. So the decision is made against how
# many positionals the command actually requires after the flag:
# --project's path is OPTIONAL, so `--project X` is ambiguous: X could be the
# path or the required positional. Deciding by "is X the last token" breaks
# `dejima-dep list --project ~/p`, which has no trailing positional and would
# silently scope to the current directory instead. So:
#
# --project takes the next token as its path only if doing so still leaves
# the command its required trailing positional(s).
# --project takes the next token as its path only if that still leaves the
# command its required trailing positional(s).
#
# Required counts are per-command constants declared by the caller, not
# inferred here.
# Required counts are per-command constants the caller declares.
#
# Sets dj_global, dj_project, dj_project_set, dj_container and the
# dj_positional array. dj_project_set exists because "--project with no path"
# (the current directory) and "no --project at all" are different scopes and
# an empty dj_project cannot tell them apart.
# Sets dj_global, dj_project, dj_project_set, dj_container and dj_positional.
# dj_project_set exists because "--project with no path" (the current
# directory) and "no --project at all" are different scopes that an empty
# dj_project cannot distinguish.
parse_scope_args() {
local required="$1"; shift
local -a args=("$@")
@@ -336,34 +332,26 @@ declare_entry() {
local project="$1" kind="$2" entry="$3" validator
validator="$(_approved_validator "$kind")" || return 1
"$validator" "$entry" || { echo "Invalid $kind entry: $entry" >&2; return 1; }
# This is the one write in this file that lands on the session-writable
# bind mount, so it is the one that must not follow a symlink. A
# compromised session can plant one and then ask the user - through the
# startup briefing, which tells it to hand over exactly this command - to
# run the CLI that trips it. Following it would aim a host-privileged
# append anywhere the user can write: at ~/.config/dejima/allowed-domains,
# silently turning a project approval into a global one; at
# ~/.config/dejima/dependencies, where the entry is installed as root at
# image build, on the one code path with no egress firewall in front of it;
# or at any host file at all.
# The one write here that lands on the session-writable bind mount, so
# the one that must not follow a symlink. A session can plant one and
# then ask the user - via the briefing, which hands over exactly this
# command - to run the CLI that trips it, aiming a host-privileged
# append at ~/.config/dejima/allowed-domains (project approval becomes
# global) or dependencies (installed as root at image build, the one
# path with no firewall in front of it).
#
# A test would not be enough. The briefing tells the agent to have the user
# run `dejima-allow --project` from a terminal outside the sandbox, so the
# session is normally still LIVE while this runs - and since it is the one
# asking for the command, it picks the moment and can spin a rename loop
# across any window between a check and the write. So there is no check:
# the guard is the open itself. O_NOFOLLOW makes a symlink at either path
# fail the open outright, the directory is opened O_DIRECTORY and the file
# is opened relative to that fd, so neither can be swapped for the other's
# sake afterwards, and the whole read-then-append happens through that one
# fd. A session that wins the swap gets an open of the object it replaced,
# not a redirected write.
# Checking first is not enough: the session is normally still live when
# the user runs this, and it chose the moment, so it can spin a rename
# loop across any check-to-write window. The guard is the open itself.
# O_NOFOLLOW fails outright on a symlink, the directory is opened
# O_DIRECTORY and the file relative to that fd, and the whole
# read-then-append goes through it - so winning the swap gets an open of
# the replaced object, not a redirected write.
#
# bash cannot open a path that way, so this is python3 - a hard dependency
# already (install.sh refuses to install without it). O_NONBLOCK and the
# S_ISREG check cover the non-symlink shapes: a FIFO planted here would
# otherwise block a host CLI the user was told to run for as long as the
# session declines to open the other end.
# bash cannot open a path that way, hence python3 (already a hard
# dependency). O_NONBLOCK and the S_ISREG check cover the non-symlink
# shapes: a planted FIFO would otherwise hang the host CLI for as long as
# the session declined to open the other end.
python3 - "$project/.dejima" "$kind" "$entry" "$DEJIMA_MAX_DECLARED" <<'PY'
import errno, os, stat, sys
+49 -21
View File
@@ -329,28 +329,17 @@ manifest_get() {
# directory whose manifest declares its own keys, not editing core.
# ---------------------------------------------------------------------------
# config_validate re-parses the WHOLE config file on every config_get call
# (see its own comment - "on the order of thirty reads" per launch), and each
# pass through a [forge.<name>]/[harness.<name>] line calls back into here.
# Going through manifest_get/manifest_validate here would mean thirty-odd
# full-manifest validations (every field, not just `keys`) for every key in
# those sections - so this reads and validates only the `keys` field itself,
# a plain line-scan plus the one type check _provider_valid_value already
# does for it, skipping fields (pr_command, capabilities, env_export, ...)
# that this lookup never uses. The manifest's OTHER fields still get their
# full manifest_validate elsewhere before anything consequential reads them
# (provider_validate_selection, forge_hosts, forge_env_exports,
# briefing_compose) - this function only ever feeds a config schema type
# check, never a forge action.
# config_validate re-parses the whole config on every config_get (~thirty per
# launch), and each [forge.<name>] line calls back here. Going through
# manifest_validate would mean that many full-manifest validations, so this
# line-scans the `keys` field alone. Every other field still gets a full
# manifest_validate before anything consequential reads it - this feeds a
# config schema type check, never a forge action.
#
# Cached per axis+name for the rest of THIS command substitution: a
# manifest cannot change mid-launch, so re-deriving it for every key in the
# same section within one config_validate pass is pure waste. The cache
# itself does not survive past this subshell (bash forks a new one per
# config_get call), so it only dedupes the N keys of one section against
# each other, not across a launch's ~thirty reads - a cheap, safe partial
# win rather than a cross-process cache this file has no story for
# invalidating.
# Cached per axis+name for this command substitution only. bash forks a new
# subshell per config_get, so it dedupes the keys of one section against each
# other rather than across a launch: a cheap partial win, not a cross-process
# cache this file has no way to invalidate.
declare -gA _PROVIDER_KEYS_CACHE
_provider_keys_cached() {
local axis="$1" name="$2" cache_key="$axis:$name"
@@ -726,6 +715,14 @@ What this means:
.dejima/allowed-domains, so it is shared with everyone working on it
rather than living on one machine. Quote that --container value exactly as
written here - it is this container's real ID, not something to infer.
And a domain can be an action rather than a source: a deploy hook, a
payment or production API acts on the world the moment it is called, with
no diff for anyone to review afterwards. Do not ask for one of those to
finish a task - say what you need and let the operator decide.
- The project directory is mounted whole, so it can hold things that are not
code: a .env, a database dump, a key someone left in the tree. Say so if
you find one, and do not open, copy or send it anywhere unless the task
actually requires it.
- Stay within the task you were given. Since there are no permission
prompts here, be more conservative than usual about anything destructive,
irreversible, or outside the project directory.
@@ -750,6 +747,33 @@ _briefing_core_worktree() {
`.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.
- A worktree isolates the working tree, NOT `.git/config`, which is shared
with the parent checkout. A bare `git config <key> <value>` run from
inside a worktree writes to the user's real repository config, not to
anything scoped to your branch. Use `git -C <path> config` against a
throwaway repo when setting config for a test or a repro. This matters
beyond a stray `user.name`: `core.hooksPath`, `credential.helper` and
`!`-aliases all execute on the host, and the same unscoped command
reaches them.
BRIEFING_EOF
}
# Only when GitHub is a push destination this session. The default is off for
# a reason the agent cannot infer from having the ability: docs/security.md,
# "GitHub push". An agent that finds a real bug in a dependency and helpfully
# opens a pull request on it has not made a mistake about permissions - it
# has done something the operator was supposed to decide, and added to a queue
# maintainers are already drowning in. Saying so here is cheaper than
# discovering it afterwards, and the flag is exactly when it becomes possible.
_briefing_core_github_push() {
cat <<'BRIEFING_EOF'
- GitHub push is enabled for this session, for the repository you were
pointed at and nothing else. Do NOT push a branch, open a pull request, or
file an issue on any other project, however good the change would be. An
unsolicited pull request is a cost imposed on maintainers who did not ask
for it, not a contribution, and it arrives under the operator's name. If
you find a real bug somewhere else, say so in your reply and let them
decide what to do about it.
BRIEFING_EOF
}
@@ -1013,6 +1037,10 @@ briefing_compose() {
text+=$'\n\n'
text+="$workflow_frag"
text+=$'\n\n'
if [ "${DEJIMA_GITHUB_PUSH:-off}" = "on" ]; then
text+="$(_briefing_core_github_push)"
text+=$'\n\n'
fi
text+="$(_briefing_core_closing_a)"
text+=$'\n\n'
text+="$harness_frag"
+81
View File
@@ -0,0 +1,81 @@
# Shared library: the ~/.claude/projects slug scheme, and which slugs belong
# to a given workspace. Sourced by bin/dejima (to decide what to mount) and
# bin/dejima-audit (to decide what counts as a cross-project write). Not on
# PATH and not executable.
#
# One definition, because the two callers must not disagree. A slug the mount
# exposes but the audit treats as foreign produces a finding on every ordinary
# session; a slug the audit treats as the workspace's own but the mount does
# not expose is a hole that nothing reports.
# Claude Code's own project key: the absolute cwd with every non-alphanumeric
# replaced by a dash. Matches docker/log-shipper.sh's Loki key and
# dejima-project-lib.sh's project_slug, which cannot source this file (one
# runs inside the container, the other is a separate library).
ws_slug() { printf '%s' "$1" | sed 's/[^a-zA-Z0-9]/-/g'; }
# The slugs belonging to $1: its own, plus one per live git worktree nested
# underneath it. One per line, sorted, no duplicates.
#
# Derived from real worktree paths rather than from the slug string, and that
# is the whole point of the function. Slugs cannot distinguish a child from a
# sibling: `/w/proj/wt1` and `/w/proj-wt1` both slug to `-w-proj-wt1`, so a
# `$slug-*` glob matches any sibling project whose directory name merely
# extends this one's - on a host with `proj` and `proj-ci` checked out side by
# side, `proj-ci` reads as part of `proj`. A path test cannot make that
# mistake, because a sibling is not under the workspace directory.
#
# Listing from a worktree names the main checkout and its siblings too. Those
# are not under $1, so they drop out here: launching against a worktree
# exposes that worktree and nothing else, which is correct.
workspace_slugs() {
local ws="$1" wt
ws="${ws%/}"
ws_slug "$ws"
printf '\n'
# A path containing a newline splits into two lines that name no real
# directory, so both fail the -d test below rather than being parsed as
# some other worktree. git has no unambiguous non-`-z` output here.
git -C "$ws" worktree list --porcelain 2>/dev/null \
| sed -n 's/^worktree //p' \
| while IFS= read -r wt; do
case "$wt" in
"$ws"/*) ;;
*) continue ;;
esac
[ -d "$wt" ] || continue
ws_slug "$wt"
printf '\n'
done
}
# Every slug in workspace_slugs, sorted and deduplicated.
workspace_slugs_sorted() { workspace_slugs "$1" | sort -u; }
# The Claude Code session ids belonging to workspace $2, one per line, taken
# from the transcript filenames under $1 (a ~/.claude/projects directory).
#
# ~/.claude/file-history is keyed by session id rather than by project, so
# there is no slug in its own paths to scope it by. The transcript is what
# ties the two together: projects/<slug>/<id>.jsonl and file-history/<id>
# name the same session, so the slugs this workspace owns select the ids it
# owns. A session id that has no transcript belongs to no project this can
# see, and is left out.
workspace_session_ids() {
local projects_dir="$1" ws="$2" slug f id
[ -d "$projects_dir" ] || return 0
while IFS= read -r slug; do
[ -n "$slug" ] || continue
[ -d "$projects_dir/$slug" ] || continue
for f in "$projects_dir/$slug"/*.jsonl; do
[ -f "$f" ] || continue
id="$(basename "$f" .jsonl)"
# Only a real session id, never whatever else a directory might
# hold: this value becomes a mount path.
case "$id" in
*[!a-fA-F0-9-]*) continue ;;
esac
[ -n "$id" ] && printf '%s\n' "$id"
done
done < <(workspace_slugs_sorted "$ws") | sort -u
}
+18 -28
View File
@@ -5,37 +5,27 @@
# 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.
# --interface 127.0.0.1 is a requirement, not a preference. init-firewall.sh
# accepts container OUTPUT to the bridge gateway (the host), and attaching
# tmux gives a HOST shell - the prefix key opens a window outside the
# container. A listener on any address the container can route to is a
# compromised session's escape hatch; loopback is not one of them. Put nginx
# in front, bound to one LAN address - 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.
# ttyd carries no credential of its own, so the proxy is the only thing
# authenticating and the loopback bind is what makes that safe. --writable
# makes it 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.
# --check-origin is not optional: without it ttyd does no origin validation
# at all, and browsers do not apply same-origin to WebSockets, so any page
# the operator loads could open wss://<front-door>/ws with the browser
# attaching its cached credential and be handed this shell. Proxy auth cannot
# stop that - the victim's own browser satisfies it. ttyd compares Origin
# against Host, which is why the nginx example passes Host through unchanged.
#
# --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.
# --url-arg carries ?arg=<run-id> for the notification deep link. It reaches
# dejima-attach as argv, never a shell word, and anything but --list or empty
# must match the run-id shape before it is used as a path component.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
+19 -23
View File
@@ -77,19 +77,15 @@ 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.
# Every fetch from here down retries. What matters is not the odds of one
# download failing but of ANY of them failing, and without retries a single
# blip discards 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.
# These steps sit ABOVE the SANDBOX_* ARGs on purpose: nothing here needs the
# sandbox user, so a build overriding a uid (runtime.bats, and any host not
# on 1000) still reuses every layer to this point. That ordering alone was
# most of the cost of an override rebuild, for a difference amounting to one
# useradd.
# 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
@@ -185,7 +181,11 @@ RUN mkdir /commandhistory && \
# from the host, and Docker creates the parents of a mount point root-owned
# if they don't already exist - which would leave the sandbox user unable to
# write the caches and history it keeps directly in ~/.claude.
RUN mkdir -p $SANDBOX_HOME/.ssh $SANDBOX_HOME/.claude && \
# projects/ is in that list for the same reason one level down: it is no
# longer mounted itself, only individual slug directories inside it, so
# without this Docker creates it root-owned while setting those up and the
# session cannot write a slug of its own.
RUN mkdir -p $SANDBOX_HOME/.ssh $SANDBOX_HOME/.claude $SANDBOX_HOME/.claude/projects $SANDBOX_HOME/.claude/file-history && \
chown -R $SANDBOX_USER:$SANDBOX_USER $SANDBOX_HOME/.ssh $SANDBOX_HOME/.claude && \
chmod 700 $SANDBOX_HOME/.ssh $SANDBOX_HOME/.claude
@@ -255,16 +255,12 @@ RUN npm install -g tree-sitter-cli@0.24.7
# matching Chromium build into node's own cache, both at build time so a
# session never needs network access for either.
#
# Chromium's own internal process sandbox is questionable here: it launches
# without --no-sandbox and without error, but its setuid sandbox helper
# (chrome_sandbox) isn't actually setuid-root in this image, and this host
# has the same unprivileged-userns restriction that broke bubblewrap - so
# its namespace-based isolation almost certainly isn't active either, it's
# just not hard-failing the way bubblewrap did. Not treated as a real
# security boundary either way: this container is already the boundary that
# matters, and a compromised session already has full `node`-level bash
# access, which a Chromium exploit couldn't exceed regardless of whichever
# of its own internal sandbox layers happen to be active.
# Chromium's own process sandbox is probably not active here: it launches
# without error, but chrome_sandbox is not setuid-root in this image and the
# unprivileged-userns restriction that broke bubblewrap applies, so it just
# fails softer than bubblewrap did. Not treated as a boundary either way -
# the container is the boundary, and a session already has node-level shell
# access that a Chromium exploit could not exceed.
RUN npm install -g playwright@${PLAYWRIGHT_VERSION} && \
npx playwright install chromium
+28 -25
View File
@@ -111,33 +111,24 @@ services:
# (based on cwd) lines up with the host, letting a session started
# here be resumed on the host with `claude --resume`.
- ${WORKSPACE_DIR:?set WORKSPACE_DIR to the project directory to mount}:${WORKSPACE_DIR}
# ~/.claude is deliberately not mounted as a whole. It used to be,
# read-write, with the config inside it layered back read-only on top.
# Every one of those overlays was a mount point nested in a
# host-writable mount, and a single-file bind mount is pinned to the
# dentry at that path: a host-side rename of the underlying file - a
# sync daemon, or Claude Code's own atomic writes - unlinks it, the
# mount silently detaches, and the path falls through to the real,
# writable host file (#47, #48). The same thing happened to the mask
# over .credentials.json, handing the sandbox the operator's real
# OAuth token on the first refresh (#50).
# ~/.claude is enumerated, never mounted whole. A single-file bind
# mount is pinned to the dentry at its path, so a host-side rename -
# a sync daemon, or Claude Code's own atomic writes - unlinks it, the
# mount detaches silently, and the path falls through to the real
# writable host file (#47, #48; #50 did exactly that to the
# .credentials.json mask). Anything not listed here is
# container-local, which is the safer default but means new state that
# should persist has to be added deliberately.
#
# So the contents are enumerated instead. Anything not listed below is
# container-local: writable during the session, gone with the
# container. That is the safer default - a config file a future Claude
# Code version adds simply doesn't persist, instead of sitting
# unprotected on a writable mount - but it does mean new state that
# *should* persist has to be added here deliberately.
# Read-write below, and all directories: a directory mount can't
# detach that way, because a rename replaces an entry within it rather
# than the mount point itself.
#
# Runtime state that has to survive the session, read-write. All
# directories, which is what makes them safe: a directory bind mount
# can't detach the way a file one does, because a rename replaces an
# entry *within* the mounted directory rather than the mount point.
# projects/ is what host-side `claude --resume` reads (and what
# log-shipper.sh tails); the rest is Claude Code's edit-undo record
# and the task/plan state skills write.
- ${HOME}/.claude/projects:${HOME}/.claude/projects
- ${HOME}/.claude/file-history:${HOME}/.claude/file-history
# projects/ and file-history/ are absent on purpose - the trees are
# every project's memory and every project's file contents, and a
# write into either reaches sessions elsewhere (#104). bin/dejima
# mounts this workspace's own entries, one -v each; see
# bin/dejima-slug-lib.sh for how that set is chosen.
- ${HOME}/.claude/backups:${HOME}/.claude/backups
- ${HOME}/.claude/tasks:${HOME}/.claude/tasks
- ${HOME}/.claude/plans:${HOME}/.claude/plans
@@ -233,6 +224,18 @@ services:
volumes:
claude-code-bashhistory:
# Named explicitly so bin/dejima can give each project its own set. These
# persist across containers by design (a plugin install should not have to
# happen every launch), which without the split meant one project's session
# could leave a Lua file or a mason/bin binary that another project's
# session executes the next time nvim runs. The service still refers to the
# static keys; only the volume Docker actually creates varies.
#
# The defaults reproduce the previous names exactly, so a `docker compose`
# invocation that bypasses bin/dejima behaves as it did.
nvim-data:
name: ${SANDBOX_NVIM_DATA:-dejima_nvim-data}
nvim-state:
name: ${SANDBOX_NVIM_STATE:-dejima_nvim-state}
nvim-cache:
name: ${SANDBOX_NVIM_CACHE:-dejima_nvim-cache}
+11 -20
View File
@@ -252,27 +252,18 @@ fi
[ -n "${ANTHROPIC_BASE_URL:-}" ] || unset ANTHROPIC_BASE_URL
[ -n "${ANTHROPIC_AUTH_TOKEN:-}" ] || unset ANTHROPIC_AUTH_TOKEN
# Remote access is opt-in and off by default, so this whole block is skipped
# for the sessions that never asked for it: DEJIMA_NOTIFY_DIR is set only by
# bin/dejima's remote_enabled branch. An unset value is the ordinary case and
# says nothing.
# Unset is the ordinary case (remote access is opt-in) and says nothing.
# Set-but-unusable is worth a startup line because of how it presents: the
# marker directory's source is created per launch and removed on exit, so
# restarting the container rather than relaunching `dejima` leaves it gone,
# and Docker answers a missing bind source by creating one root-owned. The
# hook's `[ -d ]` guard then passes and every notification fails EACCES,
# while the session is also missing from `dejima-attach` because its registry
# entry went with the same directory - three unrelated-looking symptoms.
#
# Set-but-unusable is different, and it is worth a line at startup because of
# how it otherwise presents. The marker directory is a bind mount whose source
# bin/dejima creates per launch and removes on exit, so restarting the
# container - `docker start`, or a restart policy - rather than relaunching
# `dejima` leaves the source gone. Docker's answer to a missing bind source is
# not to refuse: it creates one, root-owned. The directory then exists, so the
# notify hook's own `[ -d ]` guard passes and every notification fails with
# EACCES instead; the session is also absent from `dejima-attach`, because its
# registry entry went with the same directory. Three unrelated-looking symptoms
# and no obvious moment they started.
#
# Reported, not repaired. Chowning this would restore notifications while the
# session stayed missing from the registry - which bin/dejima writes host-side
# and never mounts here - so a session would look fixed while half of the
# feature was still broken. Relaunching is the actual remedy, and it is one the
# message can name.
# Reported, not repaired: chowning would restore notifications while the
# session stayed absent from the registry, looking fixed with half the
# feature broken. Relaunching is the remedy, and the message can say so.
if [ -n "${DEJIMA_NOTIFY_DIR:-}" ]; then
if [ ! -d "$DEJIMA_NOTIFY_DIR" ]; then
echo "WARNING: remote access is enabled, but $DEJIMA_NOTIFY_DIR is not a directory." >&2
+10 -15
View File
@@ -50,22 +50,17 @@ GITHUB_DNS_SUFFIXES=(
# Which ipset an allowlisted name's addresses belong in.
#
# GitHub's are restricted to port 443 by init-firewall.sh, and that
# restriction is the read-only guarantee - there is no transport left for the
# forwarded ssh-agent's key to push with. allowed-domains carries no port
# restriction at all, so an address that reaches it is reachable on 22 as
# well, and a GitHub address that reaches it silently undoes the whole
# arrangement.
# restriction IS the read-only guarantee: no transport is left for a
# forwarded agent key to push with. allowed-domains has no port restriction,
# so a GitHub address landing there silently undoes the arrangement.
#
# dns-proxy.sh gets this right for free: dnsmasq applies the longest matching
# ipset= line, so GitHub names go to github-domains however they were
# allowlisted. Every other path has to ask, and two did not - the
# extra-domains loop in init-firewall.sh and the live add in bin/dejima-allow
# both added unconditionally to allowed-domains. A project with github.com in
# its .dejima/allowed-domains therefore opened port 22 to whichever address
# GitHub returned at container start, for as long as that answer stayed
# cached. It presented as an intermittent startup failure, because the
# port-22 self-check re-resolves the name and only sees the hole when the
# second answer matches the first.
# dnsmasq applies the longest matching ipset= line, so dns-proxy.sh gets this
# right for free. Every other path has to ask, and two did not - the
# extra-domains loop and bin/dejima-allow's live add both appended
# unconditionally. A project declaring github.com therefore opened port 22 to
# whatever address was cached at container start, presenting as an
# intermittent startup failure because the self-check re-resolves and only
# sees the hole when the second answer matches the first.
ipset_for_domain() {
local domain="$1" suffix
for suffix in "${GITHUB_DNS_SUFFIXES[@]}"; do
+24 -38
View File
@@ -11,23 +11,17 @@
set -Eeuo pipefail
IFS=$'\n\t'
# Nothing in here may die quietly. Under `set -e` a failing command aborts the
# script with no output at all, and this script's failures are invisible in the
# worst possible way: the container simply never appears, which reads as the
# sandbox being broken rather than as one command in one loop having failed.
# Three CI failures were spent on two different instances of exactly that (#59)
# - one at the GitHub ranges fetch, one in the domain loop below - and neither
# left a single line to go on.
# Nothing here may die quietly. Under `set -e` a failure aborts with no output
# and the container simply never appears, which reads as the sandbox being
# broken rather than one command failing. Three CI failures went to two
# instances of exactly that (#59), neither leaving a line to go on.
#
# The explicit `exit 1` paths below say more than this can and are worth
# keeping; this is the floor under everything else. -E on the line above is
# what extends it into functions and subshells, where it would otherwise not
# fire at all.
# This is the floor; the explicit `exit 1` paths below say more. -E extends it
# into functions and subshells, where it would not fire at all.
#
# A failure inside a command substitution reports twice - once from the
# subshell, naming the command that actually failed, and once from the
# statement containing it. That is worth the noise: for `x=$(a | b)` the first
# line is the only thing that says whether it was `a` or `b`.
# A failure inside a command substitution reports twice, from the subshell and
# from the containing statement. Worth the noise: for `x=$(a | b)` the first
# line is the only thing saying whether it was `a` or `b`.
trap 'status=$?; echo "ERROR: init-firewall.sh aborted at line $LINENO (exit $status): $BASH_COMMAND" >&2' ERR
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -312,31 +306,23 @@ iptables -P OUTPUT DROP
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
# Docker's embedded resolver does not answer external names itself - it
# forwards them to the host's nameservers, and on current Docker it does that
# from inside THIS network namespace. Those queries therefore traverse the
# chain below like any other packet, and nothing here can match them: the
# host's resolver is neither in an ipset nor the bridge gateway. They reach
# the catch-all REJECT, every lookup comes back SERVFAIL, and the container
# dies in a self-check that says only that a host was unreachable.
# Docker's embedded resolver forwards external names to the host's
# nameservers, and on some versions does it from inside THIS namespace. Those
# queries then traverse the chain below and match nothing - the host's
# resolver is neither in an ipset nor the bridge gateway - so they hit the
# catch-all REJECT and every lookup SERVFAILs. Only the first hop stays on
# loopback. Two hosts with byte-identical /etc/resolv.conf behaved
# differently, so this is version-dependent and cannot be assumed either way.
#
# The comment on the loopback rule above used to claim DNS "stays on
# loopback, so this also covers DNS". Only the first hop does. Two hosts with
# byte-identical /etc/resolv.conf behaved differently here, so this is not a
# property of any host's configuration and cannot be assumed either way.
# --uid-owner 0 keeps this from being the hole the DNS allowlist closes: the
# forwarding socket is dockerd's (root), the session is `node` with no sudo,
# so it still cannot address a resolver directly. This newly permits DNS only
# from processes already root in a NET_ADMIN container, which could flush the
# ruleset anyway.
#
# --uid-owner 0 is what keeps this from being the hole the DNS allowlist
# exists to close. The forwarding socket belongs to dockerd, which is root;
# the session runs as `node` and there is no sudo in the image, so a
# compromised session still cannot address a resolver directly and still has
# no path to a name dns-proxy.sh has not allowed. What this does newly permit
# is DNS from processes that are already root in a container holding
# NET_ADMIN - which could simply flush this ruleset instead, so it grants
# nothing that was not already available.
#
# Guarded rather than asserted: xt_owner is a module, and a kernel without it
# should leave a container that says why (the DNS self-check below), not one
# that aborts here with an iptables usage error.
# Guarded, not asserted: xt_owner is a module, and a kernel without it should
# leave a container that says why (the DNS self-check below) rather than one
# that aborts here.
if iptables -A OUTPUT -p udp --dport 53 -m owner --uid-owner 0 -j ACCEPT 2>/dev/null; then
iptables -A OUTPUT -p tcp --dport 53 -m owner --uid-owner 0 -j ACCEPT 2>/dev/null \
|| echo "WARNING: could not add the TCP/53 rule for root-owned sockets - large answers may fail" >&2
+31 -38
View File
@@ -267,45 +267,39 @@ The rule underneath everything here:
> **Every runner reachable from a repository that receives agent-authored
> branches must be one you would accept an attacker running code on.**
Note what that does *not* say. It says nothing about what the repository's
workflows currently do, because a branch can add the first one. **A project
with no CI at all is still exposed**, on a forge where its branches can reach
a runner, and that is the case people do not expect, because there is nothing
in the repository to look at.
Note what that does *not* say: nothing about what the workflows currently do,
because a branch can add the first one. **A project with no CI at all is still
exposed** on a forge where its branches can reach a runner - the case people
do not expect, because there is nothing in the repository to look at.
So before pointing a sandbox at a project, ask one question: *what runners can
So ask one question before pointing a sandbox at a project: *what runners can
this repository's branches reach?*
**If the answer is "hosted runners only"** (GitHub-hosted and nothing
self-hosted attached), this is the mildest case, and it is worth saying so
rather than alarming people. The runner is disposable and is not your machine,
so "execution on a host you own" largely evaporates: there is no LAN behind
it and nothing of yours on it. What remains is the third item, credentials:
give pull-request runs no secrets they do not need and a read-only token
(`permissions: contents: read`), and do not attach a self-hosted runner to
that repository.
**"Hosted runners only"** is the mildest case. The runner is disposable and is
not your machine, so "execution on a host you own" largely evaporates - no LAN
behind it, nothing of yours on it. What remains is credentials: give
pull-request runs no secrets they do not need and a read-only token
(`permissions: contents: read`), and attach no self-hosted runner.
**If self-hosted runners are reachable**, that is the sharp case, and there are
three ways to hold the rule, none of which require moving or renaming
anything.
**If self-hosted runners are reachable**, that is the sharp case. Three ways
to hold the rule, none of which require moving or renaming anything.
**1. Turn CI off for that repository, if it does not use CI.** Disabling the
Actions unit means no workflow runs for that repository at all, so no runner is
reachable and a branch cannot add the first workflow. Changing it back needs
*admin* on the repository (Gitea's API puts repository settings behind
`reqAdmin()`), so a bot account with Write cannot undo it. For a project that
was not using CI anyway this costs nothing and closes the vector outright. It
is the first thing to reach for and the most commonly overlooked.
**1. Turn CI off for that repository, if it does not use CI.** No workflow
runs at all, so no runner is reachable and a branch cannot add the first
workflow. Turning it back on needs *admin* (Gitea puts repository settings
behind `reqAdmin()`), so a bot account with Write cannot undo it. For a
project not using CI this costs nothing, and it is the most commonly
overlooked option.
**2. Scope the dangerous runner down, rather than moving the repository out.**
The instinct is to move agent-developed repositories somewhere with only
hardened runners. For a public project that means breaking its name, its
links and its ownership, which nobody should be asked to do.
hardened runners, which for a public project breaks its name, links and
ownership.
Invert it. A runner can belong to an **owner** (a user as well as an
organization) and then serves that owner's repositories; Gitea's runner model
names the three cases: global (`OwnerID` and `RepoID` both zero), org/user
level (`OwnerID` set), repository level (`RepoID` set). So:
Invert it. A runner can belong to an **owner** - a user as well as an
organization - and then serves that owner's repositories. Gitea's model has
three cases: global (`OwnerID` and `RepoID` zero), org/user (`OwnerID` set),
repository (`RepoID` set). So:
- Make the *hardened* runner the owner-level default, so every repository gets
it.
@@ -324,9 +318,9 @@ the vector is closed completely rather than bounded. The cost is one push
after the review you were going to do anyway, and it is the only option here
that does not depend on getting a forge's configuration right.
If none of the three is available for a given project, that is worth knowing
plainly: pointing a sandbox at it means accepting that a compromised session
can run code on whatever that repository can reach.
If none of the three is available for a given project, say so plainly:
pointing a sandbox at it means accepting that a compromised session can run
code on whatever that repository can reach.
## Egress
@@ -368,7 +362,7 @@ bridge subnet is one that check silently does not cover.
With `runner.capacity: 1` one job occupies that network at a time. Above one,
concurrent jobs share a broadcast domain and can reach each other's service
containers, which is worth knowing before raising it.
containers. Check that before raising it.
The DNS and firewall shape is the same one dejima uses inside its own
container (`docker/dns-proxy.sh` and `docker/init-firewall.sh`), applied one
@@ -499,10 +493,9 @@ What still applies:
session commits locally and stops. The push grant *is* the CI
code-execution grant; declining the first declines the second.
Gitea 1.27's **scoped workflows** are worth knowing about here: a workflow
maintained in a central repository the sandbox cannot push to, registerable
instance-wide, so the workflow file itself sits outside any agent branch's
reach. Use it, but it is not a gate. Scoped workflows run *alongside* a
Gitea 1.27's **scoped workflows** apply here: a workflow maintained in a
central repository the sandbox cannot push to, registerable instance-wide,
so the workflow file itself sits outside any agent branch's reach. Use it, but it is not a gate. Scoped workflows run *alongside* a
consuming repository's own workflows rather than replacing them, so a branch
can still add a workflow of its own.
+69 -27
View File
@@ -93,32 +93,27 @@ name a release archive instead:
dejima-dep add --global tarball:go@<sha256>:https://go.dev/dl/go1.25.0.linux-amd64.tar.gz
```
To get the digest, fetch the archive on the host and hash it. Do not take a
digest from the same page that serves the download unless you are willing to
trust that page for both:
Fetch the archive on the host and hash it. Do not take the digest from the
same page that serves the download unless you trust that page for both:
```sh
curl -fLO https://go.dev/dl/go1.25.0.linux-amd64.tar.gz
sha256sum go1.25.0.linux-amd64.tar.gz
```
Many projects publish a checksums file or sign their releases. Where they do,
verifying against that is worth more than hashing whatever arrived, because it
tells you the archive is the one the project published rather than the one
your connection received.
Where a project publishes a checksums file or signs its releases, verifying
against that beats hashing whatever arrived: it says the archive is the one
the project published, not the one your connection received.
It unpacks with `--strip-components=1` into `/opt/dejima/<name>`, and
`/opt/dejima/<name>/bin` goes on `PATH`. That one convention covers the usual
shapes without the spec needing layout options: a Go archive has everything
under `go/`, an nvim release under `nvim-linux-<arch>/`, and stripping one
level puts `bin/` in the same place for both. Compression is detected from the
archive, so `.tar.gz`, `.tar.xz` and `.tar.zst` all work.
`/opt/dejima/<name>/bin` goes on `PATH` - one convention covering the usual
shapes without layout options, since a Go archive is under `go/` and an nvim
release under `nvim-linux-<arch>/`. Compression is detected, so `.tar.gz`,
`.tar.xz` and `.tar.zst` all work.
**The digest is mandatory**, and it is checked before anything is unpacked.
Without one you would be approving a *name* whose contents the far end can
change afterwards, which isn't a decision anyone can make once. With one, what
you approved is a specific archive and nothing else can take its place. Two
consequences worth knowing:
**The digest is mandatory** and is checked before anything is unpacked.
Without one you would approve a *name* whose contents the far end can change
afterwards. Two consequences:
- **A tarball dependency locks the project to one architecture**, because a
digest does. A package name resolves per-host and a digest cannot: it names
@@ -128,7 +123,7 @@ consequences worth knowing:
[#97](https://gitbox.branderson.io/dejima/dejima/issues/97).
- **Upgrading is an approval, not a version bump.** A new release is a new
digest, so it goes through `dejima-dep add` and `dejima-project approve`
again - which is the point, not friction to route around.
again. That is the mechanism working, not friction to route around.
The URL's character set is deliberately narrower than URLs allow: the spec is
interpolated into a `RUN` line that runs as root at build time, so anything
@@ -136,12 +131,11 @@ shell-active is refused outright rather than quoted around, and only `https`
is accepted.
**Declaring grants nothing.** The project directory is bind-mounted
read-write, so a compromised session could write these files - they are read
only to compare against reality and suggest a command. Approvals live
outside the container, under `~/.config/dejima/` by default
(`$DEJIMA_CONFIG_DIR` relocates the whole tree) - global at its
root, per project under `projects/<slug>/` - and are written only by tools
you run on the host:
read-write, so a session could write these files; they are read only to
compare against reality and suggest a command. Approvals live outside the
container under `~/.config/dejima/` (`$DEJIMA_CONFIG_DIR` relocates the
tree) - global at its root, per project under `projects/<slug>/` - written
only by tools you run on the host:
```sh
dejima-project status ~/myproject # declared vs approved vs missing
@@ -329,6 +323,21 @@ nvim_config = ~/.config/nvim
Set both in `~/.config/dejima/config` if you use this mode regularly, or pass
`--cmd nvim` per launch once `nvim_config` is set.
The lockfile is layered on as a **copy**, not as your file: `bin/dejima`
mounts a `mktemp` duplicate and deletes it afterwards, so a plugin sync
inside a session cannot rewrite the host's `lazy-lock.json`.
`~/.local/share/nvim` (plugin source and `mason/` binaries), `~/.local/state/
nvim` and `~/.cache/nvim` are **Docker volumes, not host directories**. Your
own nvim data is never mounted, so nothing a session installs can reach the
editor you run outside the container. They do persist across containers,
which is what stops every launch from reinstalling plugins - so they are
named per project. Under one shared name, a session that wrote a Lua file
under `lazy/`, or replaced a binary under `mason/bin/`, would leave something
the next session in an *unrelated* project executed. Nothing there is
protection against a session tampering with its own project's plugins, which
it can do freely and which only ever runs inside the sandbox.
If your config references something outside that directory by absolute host
path (a helper script in a dotfiles `bin/`, say), bring it in with
`[mounts] readonly` (colon-separated, mounted read-only at the same path):
@@ -348,9 +357,9 @@ session is meant to write to and whose contents should outlive the container:
read_write = ~/specs
```
Both lists mount at the host path they name, which is the point: a convention
that says "specs live in `~/specs`" keeps working inside the container without
being rewritten. That is the difference from `[core] share_dir`, which is
Both lists mount at the host path they name, so a convention that says
"specs live in `~/specs`" keeps working inside the container without being
rewritten. That is the difference from `[core] share_dir`, which is
mounted at `~/shared` regardless of where it lives on the host because its job
is to be a well-known handoff point.
@@ -366,6 +375,31 @@ resolving symlinks, so `read_write = ~` is caught too.
Sessions are told which directories these are, so "everything else is
disposable" stays true in the briefing rather than becoming a trap.
### One-off mounts for a single launch
`--extra-readonly PATH` and `--extra-read-write PATH` add a path for one run
without editing the config. `--ro` and `--rw` are the short forms. Both are
repeatable, one path per occurrence:
```sh
dejima --ro ~/other-project ~/myproject # read while migrating code out
dejima --rw ~/scratch --ro ~/reference ~/myproject
```
They **append** to the config's lists rather than replacing them, and they
are the same lists, so everything above applies unchanged: the existence
check, the refusal when a path overlaps something dejima keeps out of a
session's reach, the refusal when one path appears in both lists, and the
briefing sentence naming what a session can write. A flag says *where*, not
*whether*, and there is no flag that waives a refusal.
Two differences from the config form, both because a command line is not a
file. A relative path is accepted and made absolute against the working
directory, which a config file has no equivalent of. And a path containing a
colon is refused outright: both lists are colon-separated, so such a path
cannot be represented in one at all and would otherwise split into two
mounts that each fail the existence check.
## Shared scratch space
`~/shared` is a read-write bind mount of `[core] share_dir` (default
@@ -449,6 +483,14 @@ since the same session can be resumed unrestricted on the host via
`claude --resume`. No-op outside the container. `deny-dangerous-bash.sh`
(`PreToolUse`) also applies here, since `~/.claude/hooks` is mounted in.
When GitHub is a push destination this session - `--allow-github-push`, or
`forge = github` under a pushing workflow - the briefing also tells the
session not to push branches or open pull requests on any project other than
the one it was pointed at. Lifting the default gives an agent the ability
without the reason it is normally off (see
[docs/security.md](security.md#github-push-blocked-by-default-optional-opt-in)),
so the reason is supplied where it will be read.
`git-push-guard.sh` (`PreToolUse`/`Bash`, also `DEJIMA`-gated) blocks
any `git push` whose resolved remote isn't the Gitea host, and any push to
`main`/`master` even there. Like the other hooks, this is a fast-fail guard
+2 -1
View File
@@ -34,6 +34,7 @@ run them.
| `bin/dejima-notify-watch` | Host-side watcher: turns markers into ntfy pushes composed entirely from host data |
| `bin/dejima-project` | Review and approve project domains and dependencies from the host |
| `bin/dejima-project-lib.sh` | Shared library sourced by `bin/dejima-dep`, `bin/dejima-project`, `bin/dejima` and the remote-access scripts; not on PATH |
| `bin/dejima-slug-lib.sh` | The `~/.claude/projects` slug scheme and which slugs belong to a workspace; sourced by `bin/dejima` (what to mount) and `bin/dejima-audit` (what counts as cross-project), so the two cannot disagree. Not on PATH |
| `bin/dejima-ttyd` | Launches `ttyd` on loopback in front of `dejima-attach` |
| `systemd/*.service` | User units for `dejima-ttyd` and `dejima-notify-watch`; ship disabled, installed by hand |
| `docs/providers.md` | The manifest contract for the forge/workflow/harness axes, key by key, with the shipped manifests as worked examples |
@@ -95,7 +96,7 @@ 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
reaches nothing at all. That pair is the ttyd front door's whole containment
argument, so run it before trusting a front door you just set up.
Every mutation in `runtime.bats` (firewall rules, `ipset` entries) happens
+15 -24
View File
@@ -318,30 +318,21 @@ every launch:
that has config keys but none of them resolve to a host is refused with
"configure it, or select `forge = github`, or `forge = none` with a
workflow that does not push".
- A forge whose `requires` keys are **partly** set - some configured, some
not (a key written but left empty, as `install.sh`'s commented-out starter
config makes easy, counts as not set: it resolves to nothing, and every
call the forge makes with it would fail inside the container) - is refused
as a likely typo, but only when the workflow actually
pushes: a non-pushing workflow never invokes the forge, so e.g. gitea's
`ssh_host` alone (set only so the firewall allows fetches) is a legitimate
configuration, not a typo. A wholly unconfigured forge is never flagged by
this rule - that's the case above.
- A pushing workflow whose forge has **none** of its required settings
configured is refused too, even when `fixed_hosts` means the "resolves to
no host" rule above never fires. This is what catches `github`: its single
`requires` key (`push_token_file`) means "partly configured" (the rule
above) can never trigger for it either - there's nothing to be partial
between - so without this rule a pushing workflow could select `github`
with no `push_token_file` set and launch anyway, `fixed_hosts` masking the
fact that nothing was actually configured.
Two qualifications worth stating explicitly for the "partly configured" rule:
it only applies when the workflow's `push = on` (a non-pushing workflow never
touches the forge, so an incomplete `[forge.<name>]` section is harmless),
and a required key with a non-empty schema default - gitea's `token_file`,
defaulting to `~/.config/gitea/token` - never counts as missing, since its
resolved value is already correct even when the operator never wrote it.
- A forge whose `requires` keys are **partly** set is refused as a likely
typo, but only when the workflow pushes. A key written but left empty
counts as not set - it resolves to nothing, and every call made with it
would fail inside the container. A non-pushing workflow never invokes the
forge, so gitea's `ssh_host` alone (set only so the firewall allows
fetches) is legitimate. A required key with a non-empty schema default -
gitea's `token_file` - never counts as missing, since its resolved value is
correct even unwritten. A wholly unconfigured forge is the case above, not
this one.
- A pushing workflow whose forge has **none** of its required settings is
refused too, even where `fixed_hosts` stops the "resolves to no host" rule
firing. This catches `github`: with a single `requires` key there is
nothing to be partial between, so without this rule a pushing workflow
could select it with no `push_token_file` and launch anyway, `fixed_hosts`
masking that nothing was configured.
## Shipped providers
+30 -37
View File
@@ -44,7 +44,7 @@ What it does **not** add is a way *out* of a sandbox. Configured as
described below, a compromised session cannot reach `ttyd`, cannot reach
your proxy, and gains nothing from the fact that your phone can: it has a
route to exactly one host address, and neither front door is on it. That
property is worth stating precisely because it is the one a plausible-looking
property needs stating exactly, because it is the one a plausible-looking
mistake inverts - a proxy bound to `0.0.0.0` or to a bridge address is a
front door opening *inward*, and that is a full sandbox escape rather than a
degradation. [Verifying it](#verifying-it) is two commands, and worth
@@ -304,7 +304,7 @@ 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.
container reaching nothing), and it passes on a real Docker host.
## SSH front door
@@ -700,16 +700,14 @@ what the outer proxy sends.
## Notifications
`dejima` registers `hooks/dejima-notify.sh` as a `Notification` and
`Stop` hook for a remote-enabled session - into the throwaway settings
override layer it already generates for that session, never into your real
`~/.claude/settings.json`, which every sandbox and your host's own Claude
Code read directly. Your own `Notification` and `Stop` hooks keep running
alongside it: Claude Code merges hooks across settings layers rather than
letting the higher one replace the lower. That layer is mounted read-only, so a
session cannot unregister its own hook. The hook writes one empty file, named `waiting` or
`done`, into this run's own bind-mounted marker directory. No content, no
stdin, no network.
`dejima` registers `hooks/dejima-notify.sh` as a `Notification` and `Stop`
hook for a remote-enabled session, into the throwaway settings override layer
it already generates - never into your real `~/.claude/settings.json`. Your
own hooks keep running alongside it, since Claude Code merges hooks across
layers rather than replacing. The layer is mounted read-only, so a session
cannot unregister its own hook. The hook writes one empty file named
`waiting` or `done` into this run's 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:
@@ -726,14 +724,13 @@ loop plus a five-second sleep). Repeat markers for the same session and
event inside 60 seconds are debounced (`[remote-access] debounce` 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.
One gotcha, because it fails silently: both sides 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 run from a context with neither - a bare `su`, cron,
a tmux server started before login - writes to `/tmp/dejima` while the unit
watches `/run/user/<uid>/dejima`. No push is sent and nothing is logged.
Check with `systemctl --user show-environment`.
The same directory is why you probably want lingering on:
@@ -741,23 +738,19 @@ The same directory is why you probably want lingering on:
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.
Both are *user* units. Without lingering they stop at logout and
`/run/user/<uid>` is destroyed with the session, taking the registry and
every marker with it - so a sandbox left in a detached tmux session has no
front door and sends nothing until you log in again. It fails closed, but
invisibly, and on the same `XDG_RUNTIME_DIR` as the gotcha above.
**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
nothing bounds it. `/run/user/<uid>` is RAM-backed and sized at a fraction of
physical memory, so a session filling `/run/dejima-notify` degrades your
whole login session - D-Bus, the systemd user manager - not just dejima. A
session can already fill the workspace mount, so this is an increment rather
than a new exposure, but a RAM-backed filesystem fails harder and sooner.
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`
@@ -801,8 +794,8 @@ the next one is worth checking.
`grep -c dejima-notify /etc/dejima-settings.json` from inside should be 2.
That file is passed to Claude Code as `--settings` by `claude-wrapper.sh`;
if it is missing entirely, this container was not started by `dejima`.
3. **Does the marker reach the host?** This is the one worth knowing, because
it splits the chain cleanly in half. From inside the sandbox:
3. **Does the marker reach the host?** This is the one to check first: it
splits the chain cleanly in half. From inside the sandbox:
```sh
: > "$DEJIMA_NOTIFY_DIR/waiting" && sleep 2 && ls "$DEJIMA_NOTIFY_DIR"
+37 -43
View File
@@ -57,10 +57,9 @@ at all.
That second one is not a judgement about how good the containment is, and it
does not get better with a version bump. There is no containment that fully
prevents exfiltration: a determined attacker with code execution and any
outbound path will find a way, and the allowlist narrows the paths rather
than closing them. So the question is never whether you trust dejima enough.
It is whether you can accept the risk of the contents leaving. If you cannot,
do not hand them to an agent that could send them.
outbound path will find a way. So the question is not whether you trust
dejima enough, but whether you can accept the risk of the contents leaving.
If you cannot, do not hand them to an agent that could send them.
## Look at what else is in the directory.
@@ -84,11 +83,9 @@ directory not to hand over.
## An allowlist entry is a grant, not a convenience.
For every allowlist entry you give a dejima agent, consider what an attacker
could do with it. That is the whole rule. What follows is why the answer
varies so much between entries that look similar.
The variable is what happens if the call is wrong, whether wrong means a
prompt-injected instruction or an ordinary agent mistake.
could do with it. The answer varies enormously between entries that look
similar, and what it turns on is what happens if the call is wrong, whether
wrong means a prompt-injected instruction or an ordinary agent mistake.
A package registry or a docs site can be wrong without being expensive. A
session that fetches the wrong package version, or scrapes a stale or
@@ -101,10 +98,9 @@ different shape entirely. A single call against any of them is the action,
not a step toward one you'll review later: the deployment already ran, the
row is already written or gone, the charge already happened. There is no
diff downstream that catches it, because nothing about it passes through
your repository at all. Allowlisting one of these isn't giving the session a
faster way to fetch data; it's giving an unattended process standing
authority to act on the world, unsupervised, the same authority you'd think
twice about handing an unsupervised person.
your repository at all. Allowlisting one of these gives an unattended process
standing authority to act on the world, the same authority you would think
twice about handing a person.
Ask, before adding an entry: if this call happened by mistake, is the result
something I fix in the next commit, or something I have to explain to
@@ -125,15 +121,14 @@ some vaguer sense. In this repository, dejima-ci-hardener resolves the
CI-side egress
allowlist by reading `.dejima/allowed-domains` from `origin/main`, never
from a branch or a local checkout, and treats whatever it finds there as
already reviewed. That trust isn't a policy statement, it's load-bearing:
the only way content lands on `main` is through a pull request you merged,
so reading from `main` is how the tool avoids asking a branch what it's
allowed to do. Merging something onto `main` doesn't just ship it to users.
It feeds a trust anchor that other tooling, this one and potentially future
ones, is entitled to rely on without re-checking. Keeping insecure or broken
code off `main` is what makes that reliance justified in the first place,
and that job is yours; nothing about running the agent in a sandbox does it
for you.
already reviewed. That trust is load-bearing: the only way content lands on
`main` is through a pull request you merged, so reading from `main` is how
the tool avoids asking a branch what it's allowed to do. So merging onto
`main` ships it to users and feeds a trust anchor that other tooling, this
one and potentially future ones, is entitled to rely on without re-checking.
Keeping insecure or broken code off `main` is what makes that reliance
justified, and that job is yours; nothing about running the agent in a
sandbox does it for you.
## Give it its own identity, then own what you merge.
@@ -150,10 +145,10 @@ actually holds a session to a smaller set of permissions than yours.
Once the identity is separate, every commit and pull request the session
produces is visibly labeled as the agent's own work, not yours, in your
project's history. That visibility doesn't shrink your responsibility for
it; it just makes it legible. Anyone reading your `main` later sees your
name on the merge commit regardless of whose name is on the authored one,
and will hold you to it accordingly.
project's history. That visibility makes your responsibility legible without
shrinking it. Anyone reading your `main` later sees your name on the merge
commit regardless of whose name is on the authored one, and will hold you to
it accordingly.
## Understand what you ship.
@@ -162,27 +157,27 @@ cheap enough that reading every diff starts to feel like the slow part of
the loop, and skipping it works fine, repeatedly, until the one time it
doesn't. A reviewer who reads every change catches most of what's wrong with
it. A reviewer who only reads the ones that felt risky catches none of the
ones that didn't feel risky, which is precisely the shape a subtle bug, or a
ones that didn't feel risky, which is the shape a subtle bug, or a
prompt-injected change dressed up as something ordinary, takes. Nothing
about a faster loop changes what engineering judgment is for.
## This is not a blastproof box.
The useful way to read this rule is as a statement about what kind of
failure to plan for, not as a disclaimer to skim past. dejima's containment
is Linux namespaces and a small capability set around the session, the same
kind of isolation a container gives any process, not a virtual machine
boundary; it shares the host kernel with everything else on the machine.
Read this rule as a statement about what kind of failure to plan for.
dejima's containment is Linux namespaces and a small capability set around
the session, the same kind of isolation a container gives any process, not a
virtual machine boundary; it shares the host kernel with everything else on
the machine.
That buys real things (see [docs/security.md](security.md) for the specific
list: no working `sudo`, a deny-by-default firewall, read-only mounts over
the surfaces that matter most), and every one of them assumes the container
itself stays intact. A determined attacker, or a single container-escape
vulnerability, doesn't erode that protection gradually; it removes every
layer described in that document at once, because they all sit on the same
kernel. The operating assumption to hold isn't "the walls will probably
hold." It's "if this container is ever compromised at the kernel level,
treat everything reachable from this host account as compromised with it,"
which is a very different thing to plan around than an ordinary bug.
kernel. So the assumption to hold is not that the walls will probably hold.
It is that if this container is ever compromised at the kernel level,
everything reachable from this host account should be treated as compromised
with it, which is a very different thing to plan around than an ordinary bug.
## dejima's security says nothing about your software's.
@@ -195,12 +190,12 @@ SQL injection, hand-roll a broken authentication check, hardcode a secret,
or pull in a compromised dependency, because those are properties of the
code itself, decided at the moment it's written, not something a sandbox
sitting around the process that wrote it can retrofit afterward. If what got
built inside dejima turns out to be insecure, that isn't a containment
failure; containment was never the control that was supposed to catch it.
built inside dejima turns out to be insecure, containment was never the
control that was supposed to catch it.
**It is not something a future agent can retrofit either**, and that is the
more useful half of this. "We will do a security pass later" was already a
weak plan before agents made it cheap to say. Early architectural decisions
**It is not something a future agent can retrofit either.** "We will do a
security pass later" was already a weak plan before agents made it cheap to
say. Early architectural decisions
close off the fixes for whole classes of problem: where trust boundaries sit,
what is authenticated and where, what data crosses which process, what a
single compromised component reaches. A later pass can find a missing bounds
@@ -217,5 +212,4 @@ the one thing here no tooling in this repository is aiming at.
The same practices you'd apply to code from any other source, review,
linting, dependency scanning, tests aimed at the failure modes you care
about, still have to happen here. dejima doesn't stand in for any of them;
it operates on a different axis entirely.
about, still have to happen here. dejima does not stand in for any of them.
+230 -182
View File
@@ -10,6 +10,12 @@ 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.
**And none of it has been reviewed by anyone else.** This is early
development: the reasoning here is one person's, the tests assert what that
person thought to check, and no outside audit or adversarial testing has
happened. Read what follows as claims awaiting scrutiny rather than claims
that have survived it. See [SECURITY.md](../SECURITY.md).
## CI: push access is code execution elsewhere
The controls in this document bound what a session can reach *directly*. They
@@ -113,9 +119,9 @@ table goes back to being decorative. `write:repository` and `write:issue` are
the scopes its token needs (README, "Gitea/Forgejo PR workflow"), and nothing
about that changes for a collaborator.
One consequence is worth knowing before you meet it, because it presents as a
misconfiguration and the obvious fix is the wrong one. Parts of Gitea's API
are gated on *ownership* rather than on permission: `GET
One consequence presents as a misconfiguration, and the obvious fix is the
wrong one. Parts of Gitea's API are gated on *ownership* rather than on
permission: `GET
/repos/{owner}/{repo}/actions/runs` answers a non-owner with `"user should be
the owner of the repo"` no matter which scopes the token carries. Read that
way it looks like a scope error, and the reflex is to widen the token. That
@@ -213,26 +219,34 @@ hands to whichever session starts against it. See
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 `[core] share_dir` (see
[Shared scratch space](configuration.md#shared-scratch-space)) - that
hand-off path stays unaudited - and its coverage is a
fixed list of known Claude Code config surfaces, not a general integrity
check, so a future config surface this list hasn't been taught about yet
would go unreported. **Its diff is time-windowed, not session-attributed:**
a finding means one of these paths changed while the session was running,
not that the session made the change. A concurrent dejima or Claude Code
session, another host process, or you editing the same files by hand
during the run all land in the same diff and get reported identically -
the tool has no way to tell them apart.
under `~/.claude/projects` (see below). When the mounted workspace is this
repo itself, it also hashes the sandbox's own trusted computing base -
`bin/`, `hooks/`, `docker/`, `install.sh`, `test/`.
Worth knowing about that first surface, because the sandbox's own workflow
makes it easy to get wrong: **a git worktree isolates the working tree, not
`.git/config`.** Config is shared with the parent checkout, so a bare
`git config <key> <value>` inside a worktree writes the real repository's
config, including any exec key above. The briefing now says so, and gives
`git -C <path> config`. A guard against an unaware agent, not a boundary:
`.git/config` is in the read-write project mount and cannot be read-only
without breaking `git push -u`, which writes `branch.<name>.merge` on every
push. Detection covers the rest, and did - this was found by the audit
reporting a `.git/config` change.
**Advisory only.** It fires after the session ended, so a planted
`core.hooksPath` is something to clean up before the next `git` or
`claude` in that repo, not something that was blocked. Silent when nothing
changed, and never affects the session's exit code. It does not cover
`[core] share_dir` (see
[Shared scratch space](configuration.md#shared-scratch-space)), and its
coverage is a fixed list rather than a general integrity check, so a config
surface it has not been taught about goes unreported.
**Its diff is time-windowed, not session-attributed:** a finding means a
path changed while the session ran, not that the session changed it. A
concurrent session, another host process, or your own editing all land in
the same diff, reported identically.
- **Approving and ignoring are different decisions, and the difference is
what you are promising about next time.** Approving records the exact
content: `safe <id> <sha>`, so this file with these bytes is fine and any
@@ -246,18 +260,15 @@ hands to whichever session starts against it. See
[a] approve this exact content [i] ignore this key from now on [s] skip (ask again next time) [q] quit
```
Skipping is the third option on purpose: a finding you have not decided
about is asked again next session, which is the right default for anything
you are unsure of. A WARNING-tier git config change is curatable the same
way, which matters for keys some tools rewrite on every run: without a
decision recorded against one, it re-fires every session and pushes real
findings out of the capped list.
Skipping asks again next session, the right default when you are unsure.
It matters for keys some tools rewrite on every run: with no decision
recorded, one re-fires every session and pushes real findings out of the
capped list.
Severity is not negotiable by either verb: an exec-capable git config key or
a shell alias is classified CRITICAL whatever the baseline says, and an
`ignore` entry is refused against a CRITICAL surface when the baseline is
read, not just when it is written. So neither decision can quietly downgrade
something that matters.
Severity is not negotiable by either verb. An exec-capable key or shell
alias is CRITICAL whatever the baseline says, and an `ignore` entry is
refused against a CRITICAL surface when the baseline is read, not only when
written - so neither decision can quietly downgrade something that matters.
- `dejima-audit ack` shows what it is asking you to approve. For a surface
that changed during the session it prints a unified diff of the
@@ -271,21 +282,19 @@ hands to whichever session starts against it. See
live on disk); a removed one stores nothing at all, because a deletion
diff *is* the whole former file.
Both the staging directory and the retained diffs live under
`$AUDIT_DIR` (`~/.local/share/dejima/audit` by default), never under
`/tmp`. That is the boundary the whole scheme rests on:
`docker-compose.yml` mounts nothing covering it, so the container cannot
read the retained content of earlier runs - `test/static.bats` pins that
as a regression guard. If the store is configured *inside* the mounted
workspace it becomes readable and deletable from within the sandbox, so
in that case no content is retained at all - only a note saying why. A
session killed with `SIGKILL` (no EXIT trap runs, so no `compare`)
leaves its staging directory behind; the next run against that
workspace reaps it, identifying dead owners by pid and process start
time recorded in an `.owner` file, so a concurrent session against the
same workspace is never mistaken for dead and disturbed. A directory
whose owner record is missing or unreadable falls back to a 24-hour age
check instead of guessing liveness.
Staging and retained diffs live under `$AUDIT_DIR`
(`~/.local/share/dejima/audit`), never `/tmp`. That is the boundary the
scheme rests on: nothing in `docker-compose.yml` covers it, so the
container cannot read earlier runs' retained content, and `static.bats`
pins that. A store configured *inside* the workspace is readable and
deletable from the sandbox, so nothing is retained in that case - only a
note saying why.
A `SIGKILL`ed session runs no EXIT trap and leaves its staging directory;
the next run against that workspace reaps it, identifying dead owners by
pid and process start time from an `.owner` file so a concurrent session is
never mistaken for dead. A missing or unreadable owner record falls back to
a 24-hour age check rather than guessing.
Content is withheld from the store in several further cases, and in
every one `ack` prints the reason in place of the diff - so an absent
@@ -300,101 +309,114 @@ hands to whichever session starts against it. See
existed before the session but was too large to have been staged (over
1 MiB) withholds with "no pre-session copy", not a false claim that it
is new. The *current* side is bounded by that same 1 MiB cap, and - like
the staged side - the size is checked *before* any copy is taken: a file
the session grew past it withholds with "change too large to retain"
rather than being copied through `$TMPDIR` first. That ordering is the
point, not the cap: `compare` runs from the exit trap on every session
exit and the size is the session's own choice, so copying first would
let a session turn "I filled my own workspace" into that many bytes
written to `$TMPDIR` - frequently a tmpfs, i.e. host RAM - at exit, and
into a whole copy of the file left sitting there if that exit was a
`SIGKILL`. A generated diff is withheld if it is binary, over 200 lines
the staged side - checked *before* any copy is taken: a file the session
grew past it withholds with "change too large to retain" rather than
being copied through `$TMPDIR` first. The ordering is the point, not the
cap. `compare` runs from the exit trap on every exit and the size is the
session's own choice, so copying first would turn "I filled my own
workspace" into that many bytes written to `$TMPDIR` - often a tmpfs, so
host RAM - and into a whole copy left there if the exit was a `SIGKILL`.
A generated diff is withheld if it is binary, over 200 lines
or 64 KiB (and is bounded to that as it is generated, for the same
reason), or matches a best-effort credential screen - vendor prefixes
including `sk-ant-`, `sk-proj-`, `ghp_`, `gho_`, `github_pat_`,
`glpat-`, Slack's `xox[baprs]-`, `AIza`, `AKIA`, `_authToken`,
`Authorization: Bearer`, and PEM private-key headers, matched
case-insensitively (deliberately: the one high-value surface this is
the only net for, `.claude/settings.json`'s `env` block, conventionally
holds its keys upper-case). That screen is still explicitly best-effort
and has real false negatives - the denylist above is the reliable half
of the defence, this is the second net. A git config key holding more
than one value on either side of the session is not compared at all:
git resolves such a key to its *last* value in *file* order, and the
audit does not resolve it - it parses the config files and sorts, so
lexicographically-last is not the value git will run, and the
pre-session side is every value the key had rather than one. Comparing
anyway produced a diff over values git does not use: with two
pre-session values it read as a deletion that never happened, while the
value the session actually appended - the one git now executes -
appeared nowhere in it. That case withholds with "this key has multiple
values and git uses the last", and must be compared by hand;
`include.path` is legitimately multi-valued, so this is an ordinary
shape, not only a planted one. A generated diff can also still come out
empty - before and after compare equal even though the finding was
reported as a change - when the config file's *text* changed while the
parsed value did not, e.g. a value that was re-quoted; that case
withholds too, saying plainly that the comparison came out empty rather
than silently showing nothing, since an empty diff for a reported
change would otherwise read as a bug.
case-insensitively, since `.claude/settings.json`'s `env` block - the one
high-value surface this is the only net for - conventionally holds its keys
upper-case. The screen is best-effort with real false negatives: the
denylist above is the reliable half.
A per-report **retention budget** (`RETAIN_BUDGET`, 1 MiB) also bounds
the total diff bytes one report may keep. Once it is spent, remaining
findings in that report withhold with a note that the budget ran out,
and both the report and the exit-time stderr summary add an alarm line:
that many changed host-persistence surfaces in one session is treated
as a signal in its own right, not merely a limit worth raising - an
ordinary session changes few enough of these surfaces that the budget
is never in practice a constraint. `diffs = off` in the config's
`[audit]` section disables retention (and staging) entirely.
A git config key with more than one value on either side is not compared.
Git resolves such a key to its last value in FILE order; the audit parses
and sorts, so lexicographically-last is not what git runs, and the
pre-session side is every value the key had. Comparing anyway produced a
diff over values git does not use - two pre-session values read as a
deletion that never happened, while the appended value git now executes
appeared nowhere. It withholds instead, and must be compared by hand.
`include.path` is legitimately multi-valued, so this is an ordinary shape.
Two residual limits worth knowing on top of the credential screen's
false negatives above. The prompt shows at most 40 lines of a diff or
of new content before truncating, naming the file so you can read the
rest - a large planted change is truncated exactly where you would most
want it whole. And the `SIGKILL` case above is a real exposure window,
not just a cleanup mechanic: until the next run against that workspace
reaps it, the staging directory sits on disk holding whole pre-session
file copies (not diffs) of every monitored surface, for as long as the
workspace goes unused.
- Report findings name a path or config key that a session may itself have
chosen, so those names reach your terminal as untrusted text. One that
contains an ASCII control character, a C1 control, or a Unicode bidi
override - which between them can scroll, blank, or visually reorder what
you read, and can split a name across the audit's own records - is
written hex-encoded behind a `{hex}` marker instead, and decodes with
`printf '%b' "$(sed 's/../\\x&/g' <<< HEX)"`. Ordinary names are printed
as-is, so the marker appearing at all is itself worth a second look.
The encoded form is what `dejima-audit ack` records in the baseline, so
what you acknowledge is the file that is actually on disk rather than a
A diff can also come out empty when the config file's TEXT changed but the
parsed value did not, e.g. a re-quoted value. That withholds too, saying so
rather than showing nothing - an empty diff for a reported change reads as
a bug.
A per-report **retention budget** (`RETAIN_BUDGET`, 1 MiB) bounds the total
diff bytes one report keeps. Once spent, the rest withhold and both the
report and the stderr summary add an alarm line: that many changed
host-persistence surfaces in one session is a signal in itself, not a limit
worth raising. `diffs = off` in `[audit]` disables retention and staging.
Two residual limits on top of the screen's false negatives. The prompt
truncates at 40 lines, naming the file so you can read the rest - a large
planted change is cut exactly where you would want it whole. And the
`SIGKILL` case is an exposure window, not just a cleanup mechanic: until
the next run reaps it, the staging directory holds whole pre-session file
copies of every monitored surface, for as long as the workspace goes
unused.
- Findings name a path or config key the session may have chosen, so they
reach your terminal as untrusted text. One containing an ASCII or C1
control or a Unicode bidi override - which can scroll, blank or reorder
what you read, and split a name across the audit's own records - is written
hex-encoded behind a `{hex}` marker and decodes with
`printf '%b' "$(sed 's/../\\x&/g' <<< HEX)"`. Ordinary names print as-is,
so the marker appearing is itself worth a look. The encoded form is what
`ack` records, so what you acknowledge is the file on disk rather than a
readable approximation of its name.
- `~/.claude/projects` (session transcripts and per-project memory) is
**not** mounted read-only, unlike the code-carrying `~/.claude`
directories - deliberately. Scoping a read-only mount to "the
current project's slug"
would break the sandbox's own worktree workflow: a session running in
`WORKSPACE/.worktrees/<branch>` writes to a *different* projects slug
than the parent repo, so a slug-scoped overlay would either block that
legitimate write or have to be widened until it stopped protecting
anything. Instead, cross-project writes are **detected, not prevented**:
the exit-time audit above flags any write during the session to another
project's `~/.claude/projects/<slug>` directory, excluding the current
project's own slug and its worktree children. That's after-the-fact
reporting of transcript/memory poisoning in *other* projects, not a
block on it happening. Residual even within that: the current project's
own transcripts/memory stay fully writable throughout, since Claude Code
needs that for host-side `claude --resume` to work; and detection keys
off each slug's max file mtime, so an edit to a file that isn't already
that project's newest doesn't advance the max and goes unflagged. A
read-only mount for `projects/` remains a possible future option if
Claude Code is confirmed to fail cleanly (not hang, as `~/.claude.json`
did) when a write to it is blocked. This is also the sharpest case of
the time-window caveat above: `~/.claude/projects` is a single
directory shared by *every* Claude Code session on the host, not scoped
to the one being audited, so an entirely innocent concurrent session
working on some unrelated project can produce a `CROSS-PROJECT` finding
here that reads like the audited session poisoned another project's
history - it didn't, it just happened to be running at the same time.
**not** mounted as a tree. A session gets the workspace's own slug
directory plus one for each live git worktree nested under it,
read-write, and nothing else. Every other project's slug is *absent*
from the container rather than present-but-unwritable. Agent memory is
loaded into context at session start, so a write into another project's
`memory/` is text a future session there reads as established background
rather than as input - and until #104 the mount alone was enough to do
that, with no escape and no credential involved.
The set is derived from real worktree paths
(`bin/dejima-slug-lib.sh`), not from the slug string, because a slug
cannot tell a child from a sibling: `/w/proj/wt1` and `/w/proj-wt1`
produce the same one. A `<slug>-*` glob therefore also matches any
project checked out beside this one whose directory name merely extends
it, which is an ordinary layout rather than a contrived one.
`projects/` itself stays container-local, so a slug that is not mounted
is still writable during the session and just does not survive it. That
is what lets Claude Code create a slug it does not find, which is the
first-run case it already handles, and it is why an absent sibling does
not risk the hang that a blocked write to `~/.claude.json` caused.
Cross-project writes are still **detected**: the audit runs host-side over
the whole tree, so narrowing the mount does not narrow it, and it now
covers what the mount cannot reach - concurrent *host* sessions. Residual:
the workspace's own transcripts and memory stay writable, since
`claude --resume` needs that; and detection keys off each slug's max file
mtime, so an edit to a file that is not already the newest goes unflagged.
The time-window caveat applies most sharply here, because on the host
`~/.claude/projects` is a single
directory shared by *every* Claude Code session - so an innocent concurrent
session elsewhere can produce a `CROSS-PROJECT` finding that reads like
poisoning and isn't.
- `~/.claude/file-history` (Claude Code's edit-undo record) is **not**
mounted as a tree either, and the reason is sharper than for `projects/`.
Each `<session>/<hash>@v1` is a **verbatim copy of an edited file**, so the
tree is other projects' source rather than a record about it. Read access
alone is the leak; it was read-write on top of that, and tampering with
another project's undo record puts chosen content into a later restore.
It is keyed by session id, so unlike `projects/` there is no slug in its
own paths to scope it by. The transcript names supply it: `projects/<slug>/
<id>.jsonl` and `file-history/<id>` name the same session, so the slugs the
workspace owns select the session ids it owns
(`workspace_session_ids` in `bin/dejima-slug-lib.sh`). A directory whose
session has no transcript belongs to no project dejima can identify, and is
left out.
The session about to start has an id nothing can know in advance, so its
own directory is created container-local and does not survive the run.
That costs undo history for that session if it is later resumed on the
host, and it is the reason this cannot be scoped as completely as
`projects/`, where the workspace's own slug is known at launch.
- 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
@@ -408,51 +430,45 @@ hands to whichever session starts against it. See
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.
every change, and `runtime.bats` asserts against a real running container
rather than a mock: IPv6 blocked, a non-allowlisted domain refused and a
non-allowlisted destination unreachable, `node` unable to re-run
`init-firewall.sh`, no working `sudo`. Coverage is not completeness: the
reasoning here is judgement, nothing tests it, and a property nobody wrote
a test for can regress quietly. A missing `ipset` dedup flag broke firewall
startup twice before anyone caught it; there is a test for that now, which
is the pattern to expect - each of these exists because something was got
wrong first.
- The Anthropic OAuth credential (`~/.claude/.credentials.json`) never
enters the container. It simply isn't mounted: `~/.claude` is not shared
as a whole (see the mount inventory below), so there is no path from
enters the container. It isn't mounted: `~/.claude` is not shared as a
whole (see the mount inventory below), so there is no path from
inside the container to that file. `dejima` starts a host-side proxy
(`bin/dejima-anthropic-proxy`) on a per-session unix socket and mounts a
fake credential in place of the real one - the two secrets are
placeholders, while `scopes`/`subscriptionType`/`rateLimitTier`/`clientId`
are copied from the real file so the session's own UI stays accurate. Its
expiry is set a year out rather than mirrored, deliberately: a near-term
one would make Claude Code refresh on its own, with a refresh token that
isn't real, and log the session out. Inside the container,
fake credential in place of the real one - the secrets are placeholders,
while `scopes`/`subscriptionType`/`rateLimitTier`/`clientId` are copied so
the session's UI stays accurate. Its expiry is a year out rather than
mirrored: a near-term one would make Claude Code refresh on its own with a
refresh token that isn't real, logging the session out. Inside the
container,
`entrypoint.sh` points `api.anthropic.com` at a loopback TLS terminator
(`anthropic-proxy-forwarder.js`) whose certificate is generated at startup
and trusted only there, and which pipes raw bytes into the proxy's socket.
The proxy injects the real access token per request and owns OAuth refresh
- rotated tokens are written back to the real host file atomically, so
host-side sessions stay logged in. Claude Code itself is configured
exactly as it would be on the host: no `ANTHROPIC_BASE_URL`, no
`ANTHROPIC_AUTH_TOKEN`, nothing that marks the session as unusual to the
client. The proxy then forwards the client's header block verbatim -
same names, same casing, same order, repeats intact, and no
`Accept-Encoding` it didn't send - changing only the `Authorization`
value, and `anthropic-beta` if the OAuth value isn't already there. Left
to itself, Python's `http.client` would have rewritten all of that into
its own house style and moved the fingerprint from the client to the
wire. What it cannot disguise is that the connection is made by CPython's
`ssl` rather than by Node: the TLS handshake (JA3/JA4) and the absence of
connection reuse still distinguish it at that layer. What remains is
session-scoped: a
compromised session can still *use* the model API as you while it runs
(that's inherent to the sandbox working at all), but it can no longer read
a durable credential and exfiltrate it for offline use via the other
allowed egress destinations (npm, GitHub, your Gitea host). Caveats:
exactly as on the host: no `ANTHROPIC_BASE_URL`, no
`ANTHROPIC_AUTH_TOKEN`, nothing marking the session as unusual. The proxy
forwards the client's header block verbatim - same names, casing, order and
repeats, and no `Accept-Encoding` it didn't send - changing only
`Authorization`, and `anthropic-beta` if the OAuth value isn't there. Left
alone, Python's `http.client` would rewrite all of that into its own style
and move the fingerprint from the client to the wire. It cannot disguise
that the connection is CPython's `ssl` rather than Node: the TLS handshake
(JA3/JA4) and the absence of connection reuse still distinguish it.
What remains is session-scoped: a compromised session can still *use* the
API as you while it runs, which is inherent to the sandbox working at all,
but it cannot read a durable credential and exfiltrate it for offline use.
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
@@ -506,13 +522,21 @@ hands to whichever session starts against it. See
are enumerated in `docker-compose.yml`, in three classes, and anything not
listed is container-local: writable during the session and gone with the
container.
- **Read-write, because it has to survive the session**: `projects/`,
`file-history/`, `backups/`, `tasks/`, `plans/`, `sessions/`. All
directories, which is what makes them safe (see the detachment note
below).
- **Read-write, because it has to survive the session**: `backups/`,
`tasks/`, `plans/`, `sessions/`, plus the entries of `projects/` and
`file-history/` that belong to this workspace. All directories, which is
what makes them safe (see the detachment note below). The two trees are
not mounted whole: see the `projects/` note further down, and the
`file-history/` note below it.
- **Read-only**: `plugins/`, `hooks/`, `skills/`, `commands/`, `agents/`,
`output-styles/`, plus the symlink-target directories `dejima`
discovers at launch. Hooks execute automatically with no separate
discovers at launch. Those targets are discovered rather than
configured, so they are checked against the approval store and the
audit stores and refused if they overlap either - a hook symlinked into
the approval store used to mount it, read-only and silently, and
read-only is no protection when the content is every project's
approvals. The same check now covers `[mounts] readonly`, which had
none at all. 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
@@ -531,8 +555,7 @@ hands to whichever session starts against it. See
`statusline.sh` are mounted read-only at `/etc/dejima-claude/` and
installed into `~/.claude` by `entrypoint.sh` as root-owned, unwritable
files. A session can still unlink and replace them - `~/.claude` belongs
to it - but only its own copy, which was the whole point of making them
read-only.
to it - but only its own copy, which is why they are read-only.
The reason for the split is that a **single-file bind mount silently
detaches**. It is pinned to the dentry at its mount point, so when the
@@ -930,10 +953,9 @@ sessions you have ever run, which is how a Loki instance stops being usable.
Filter on it with `| json | session=` as above rather than expecting a
selector.
Two things are absent by design and worth knowing before you go looking for
them. **Tool results are never shipped**, only invocations, so a query will
show you that a session ran `Bash` with certain arguments and never what came
back. **Thinking blocks are never shipped either.** Both dominate the volume
Two things are absent by design. **Tool results are never shipped**, only
invocations, so a query will show you that a session ran `Bash` with certain
arguments and never what came back. **Thinking blocks are never shipped either.** Both dominate the volume
and neither was what opting in asked for. Long values are clipped, so a very
large prompt or tool input ends in `...[truncated]` rather than being dropped.
@@ -962,6 +984,32 @@ want GitHub push and PRs as a standing choice instead, set
`ssh.github.com:22`, but persistently rather than per launch, and uses its
own `[forge.github] push_token_file` rather than a loaded SSH key.
**Why the default is off.** The principle is narrower than CI and holds
whether or not CI exists anywhere: *a session that is not being used to push
to a forge should not be able to push to it.* Your configured forge is the
one you chose to push to. GitHub is where everything else lives - every
dependency a session reads, every repository it clones to understand an
error - so that is where the rule bites by default.
The case is an eager agent, not a compromised one: it finds a real bug in an
unrelated open-source project and pushes a fix branch to somebody else's
repository. That this is not an attack does not make it harmless. Maintainers
are already triaging a flood of unsolicited AI-generated pull requests, and
one more arriving in your name, unreviewed, is a cost imposed on someone who
did not ask for it. The block exists so that it cannot happen, not so that
you get to approve it first.
[Don't point it at repositories you don't own](responsible-use.md#dont-point-it-at-repositories-you-dont-own)
is the rule this enforces, and gives the fork-and-review workflow to use
instead: work against your own fork, review the result, and open the upstream
pull request by hand if you still want to.
Closing the CI chain follows from this - a push is code execution on whatever
runners that repository can reach, per the section above - but it is not the
reason for it. Reading it the other way round gets the order backwards, and
makes the default look like a CI workaround to be turned off once CI is
handled.
The sandbox can always read from GitHub (clone, fetch, `gh` API calls). By
**default** it can never push there: enforced at the network layer, not
just by `git-push-guard.sh`. GitHub's IP ranges are only allowlisted on port
+12 -16
View File
@@ -1,23 +1,19 @@
#!/bin/bash
# Read Gitea Actions runs and job logs from a shell - the CI-side counterpart
# to gitea-pr and gitea-issue, and deliberately their twin rather than their
# sibling: all three 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 all three by
# test/static.bats instead.
# to gitea-pr and gitea-issue. Self-contained like both of them rather than
# sharing a library: all three are copied into the image as single files, so
# a library would be another thing to keep in step. test/static.bats pins the
# duplicated config/token/curl preamble instead.
#
# Its reason to exist is a sandboxed agent's: CI is where the tests that
# cannot run in the sandbox actually run, and without this the only way to
# see why one failed is for the operator to paste the log in by hand.
# It exists for the agent: CI is where the tests the sandbox cannot run
# actually run, and without this the only way to see a failure is for the
# operator to paste the log in.
#
# Nothing here touches /repos/{owner}/{repo}/actions/runs, or the job and
# log endpoints reached through it, however natural they look. Gitea gates
# that group behind reqOwner(): a token belonging to a dedicated non-owner
# account - which is what a sandbox should be given - is refused with "user
# should be the owner of the repo", and no scope grants ownership, so
# widening the token cannot fix it. The three sources used instead need only
# read access to the repo:
# Nothing here touches /repos/{owner}/{repo}/actions/runs or the endpoints
# reached through it, however natural they look. Gitea gates that group
# behind reqOwner(), so a dedicated non-owner account - what a sandbox should
# have - is refused, and no scope grants ownership. The three sources used
# instead need only read access:
#
# actions/tasks one record per job, carrying its run number,
# head_sha, event and result
+15 -22
View File
@@ -1,30 +1,23 @@
#!/usr/bin/env bash
# PreToolUse hook: inside dejima, block a merge command matching
# DEJIMA_MERGE_BLOCK - a literal command prefix (the forge's PR command
# followed by its merge subcommand) resolved host-side by bin/dejima from
# the configured forge and workflow
# (see dejima-provider-lib.sh), not hardcoded to any one forge. The sandbox
# workflow is feature branch -> open a PR/MR -> a human merges; what lands
# on the default branch is an operator decision, exactly as it already is
# for `git push` (see git-push-guard.sh, whose no-direct-push-to-a-blocked-
# branch rule this is the API-side counterpart to - a merge reaches the
# branch without a push, so that guard never sees it).
# DEJIMA_MERGE_BLOCK - a literal command prefix resolved host-side by
# bin/dejima from the configured forge and workflow, never hardcoded to one
# forge. What lands on the default branch is an operator decision, as it
# already is for `git push`; this is the API-side counterpart to
# git-push-guard.sh, since a merge reaches the branch without a push.
#
# 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.
# 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 line is
# drawn by context instead, and the context that needs it is this one -
# unattended runs under --dangerously-skip-permissions. On the host it is a
# no-op.
#
# Like its sibling this is a fast-fail guard against an overeager agent, not
# a hard security boundary: a sandbox session has full shell access and the
# forge token in its environment, so it could call the merge endpoint with
# curl directly. It stops the mistake, not the attack.
# A fast-fail guard against an overeager agent, not a boundary: a session has
# shell access and the forge token, so it could call the merge endpoint with
# curl. It stops the mistake, not the attack.
#
# DEJIMA_MERGE_BLOCK fails OPEN: empty means no command is blocked, so the
# hook is inert rather than conditionally installed - a workflow with no
# merge-block policy (or a forge with nothing to block) genuinely has none.
# Fails OPEN - empty means nothing is blocked, so a workflow with no
# merge-block policy genuinely has none rather than the hook guessing.
if [ -z "${DEJIMA:-}" ]; then
exit 0
+14 -19
View File
@@ -66,29 +66,24 @@ fi
requirements=""
decl_dir="$project_dir/.dejima"
# Everything below reads attacker-controllable text on its way into an
# agent's context window, so all three costs it can impose are bounded:
# Everything below reads attacker-controllable text on its way into a context
# window, so each cost it can impose is bounded:
#
# MAX_DECLARED entries considered - the same cap read_declared() applies
# host-side, so this reports on the same window
# `dejima-project approve` will act on.
# host-side, so this reports the window `approve` acts on.
# MAX_ENTRY_LEN characters per entry.
# MAX_LISTED entries named; the rest are counted only. Without it, 200
# entries of 200 chars is 40 KB of attacker-chosen tokens per
# section, and a large enough $requirements pushed the jq call
# past MAX_ARG_STRLEN and emitted nothing - costing every
# later session its whole briefing.
#
# Within that window the report is a strict subset of what `approve` accepts,
# never a superset: it never names something approve would reject, but it can
# stay silent about something approve would take - a dotless domain (the
# regex below requires a label separator, deliberately, so a bare
# IGNORE-PREVIOUS-INSTRUCTIONS can't be echoed into the context) and a valid
# domain longer than MAX_ENTRY_LEN are both approvable host-side and both
# silently skipped here. Fail-closed in the direction that matters: the worst
# case is a missing line of advice, not an unapproved entry presented as fine.
# MAX_LISTED entries actually named in the report; the rest are only
# counted. Without it 200 entries of 200 chars would be 40 KB
# of attacker-chosen tokens per section, and a big enough
# $requirements used to push the jq call at the end past
# Linux's 128 KiB MAX_ARG_STRLEN and emit nothing at all -
# silently costing every later session in the project its
# entire briefing.
# The report is a strict subset of what `approve` accepts, never a superset:
# it can stay silent about something approvable (a dotless domain, which the
# regex refuses so a bare IGNORE-PREVIOUS-INSTRUCTIONS cannot be echoed into
# context; or one longer than MAX_ENTRY_LEN) but never names something
# `approve` would reject. The worst case is a missing line of advice, not an
# unapproved entry presented as fine.
MAX_DECLARED=200
MAX_ENTRY_LEN=200
MAX_LISTED=20
+19
View File
@@ -162,6 +162,10 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
}
@test "does not flag a worktree child's projects slug" {
# A real worktree, not just a directory named like one. "Self" is derived
# from live worktree paths now, so the fixture has to produce one.
git -C "$WS" commit -q --allow-empty -m base
git -C "$WS" worktree add -q "$WS/.worktrees/featurex" -b featurex
mkdir -p "$PROJDIR/${CURRENT_SLUG}--worktrees-featurex"
echo x > "$PROJDIR/${CURRENT_SLUG}--worktrees-featurex/file1"
snap
@@ -170,6 +174,21 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
[[ "$output" != *"CROSS-PROJECT"* ]]
}
# The regression guard for #104's sibling case. The old test was a
# "$CURRENT_SLUG"-* glob, which this slug matches: a project checked out
# beside this one, whose directory name merely extends it, was silently
# exempt from cross-project reporting. `proj` and `proj-ci-hardener` side by
# side is an ordinary layout, not a contrived one.
@test "flags a sibling project whose directory name extends this one's" {
mkdir -p "$PROJDIR/${CURRENT_SLUG}-ci-hardener"
echo x > "$PROJDIR/${CURRENT_SLUG}-ci-hardener/file1"
snap
echo y > "$PROJDIR/${CURRENT_SLUG}-ci-hardener/file2"
touch -d '+1 hour' "$PROJDIR/${CURRENT_SLUG}-ci-hardener/file2"
run cmp_
[[ "$output" == *"CROSS-PROJECT"* ]]
}
@test "silent when no projects changed" {
mkdir -p "$PROJDIR/-some-other-project"
echo x > "$PROJDIR/-some-other-project/file1"
+237 -18
View File
@@ -1045,24 +1045,16 @@ run_install() {
local name out hit=0
# docs/ and systemd/ are pure operator-facing prose - nothing there is
# live shell plumbing, so any mention of a retired name is either the
# deliberate "no longer read" notice (install.sh's remote.conf one lives
# outside these two directories, but the exclusion is kept here too in
# case a future doc adds one), a stale instruction, or one of the two
# cases below that are neither:
# docs/ and systemd/ are operator-facing prose, no live plumbing, so a
# retired name there is either the deliberate "no longer read" notice, a
# stale instruction, or one of two cases that are neither:
#
# - A manifest-field line (`env_export = GITEA_API=api ...`): this is
# docs/providers.md quoting a forge manifest's `env_export` value
# verbatim, which *declares* a container-side environment variable -
# the exact thing env_export is for - not an instruction to an operator
# to set one. `GITEA_API` and friends are retired only as host-side,
# operator-set variables (what _warn_legacy_env's table is about); they
# are not retired as the container-side names env_export produces, and
# docs/providers.md documenting that mapping is not a regression.
# - A backtick-quoted mention (`` `GITEA_API` ``): prose naming a
# variable - "the container gets `GITEA_API`" - reads a value, it
# doesn't tell anyone to set one.
# - A manifest-field line (`env_export = GITEA_API=api ...`), which is
# providers.md quoting a manifest verbatim. These names are retired as
# host-side operator-set variables, not as the container-side names
# env_export produces.
# - A backtick-quoted mention - prose naming a variable reads a value, it
# does not tell anyone to set one.
while IFS= read -r name; do
out="$(grep -rnE "\b${name}\b" "$REPO_ROOT/docs" "$REPO_ROOT/systemd" 2>/dev/null \
| grep -v 'no longer read' \
@@ -1134,11 +1126,238 @@ exit 0
EOF
chmod +x "$STUB_DIR/docker"
docker_log="$(mktemp)"
HOME="$RW_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
HOME="$RW_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK="${RW_AGENT_SOCK-}" \
SANDBOX_TEST_DOCKER_LOG="$docker_log" \
run "$REPO_ROOT/bin/dejima" "$1"
}
# A real agent, carrying nothing. `ssh-add -l` against it exits nonzero,
# which is the condition the "no identities loaded" warning keys on, and a
# handmade socket file would not reproduce it.
_empty_agent() {
eval "$(ssh-agent -s)" >/dev/null
RW_AGENT_SOCK="$SSH_AUTH_SOCK"
RW_AGENT_PID="$SSH_AGENT_PID"
}
_kill_agent() { [ -n "${RW_AGENT_PID:-}" ] && kill "$RW_AGENT_PID" 2>/dev/null; return 0; }
# The forwarded agent is the fallback; [core] ssh_key is the arrangement the
# docs recommend, because it is a dedicated identity and works unattended.
# So an empty agent is only worth mentioning when nothing else can push -
# otherwise the warning fires at exactly the operators who took the advice.
# ~/.claude/file-history is keyed by session id, so nothing in its own paths
# says which project a directory belongs to. The transcript names are what
# resolve it, and this is the assertion that the resolution is right: a
# session of this workspace is mounted, one belonging to another project is
# not, and neither is one whose slug merely extends this workspace's - the
# sibling case that #104 turned on.
@test "file-history is mounted for this workspace's sessions and no others" {
_rw_setup
workspace="$(mktemp -d)"
local slug sib own foreign sibling
slug="$(printf '%s' "$workspace" | sed 's/[^a-zA-Z0-9]/-/g')"
sib="$slug-extra"
own=11111111-1111-1111-1111-111111111111
foreign=22222222-2222-2222-2222-222222222222
sibling=33333333-3333-3333-3333-333333333333
mkdir -p "$RW_HOME/.claude/projects/$slug" \
"$RW_HOME/.claude/projects/-some-other-project" \
"$RW_HOME/.claude/projects/$sib"
: > "$RW_HOME/.claude/projects/$slug/$own.jsonl"
: > "$RW_HOME/.claude/projects/-some-other-project/$foreign.jsonl"
: > "$RW_HOME/.claude/projects/$sib/$sibling.jsonl"
mkdir -p "$RW_HOME/.claude/file-history/$own" \
"$RW_HOME/.claude/file-history/$foreign" \
"$RW_HOME/.claude/file-history/$sibling"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
_rw_launch "$workspace"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qF "file-history/$own" "$docker_log" \
|| { echo "own session not mounted:"; grep file-history "$docker_log"; return 1; }
run grep -F "file-history/$foreign" "$docker_log"
[ "$status" -ne 0 ] || { echo "another project's file-history was mounted"; return 1; }
run grep -F "file-history/$sibling" "$docker_log"
[ "$status" -ne 0 ] || { echo "a sibling project's file-history was mounted"; return 1; }
rm -rf "$workspace"
}
@test "an empty ssh-agent is not warned about when [core] ssh_key is set" {
_rw_setup
workspace="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
_empty_agent
_rw_launch "$workspace"
_kill_agent
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" != *"no identities loaded"* ]] || { echo "$output"; return 1; }
rm -rf "$workspace"
}
# The positive control for the test above: with no key configured the
# forwarded agent really is the only way to push, and an empty one is worth
# saying so before it fails deep into a session.
# --extra-readonly / --extra-read-write append to the config's own lists
# rather than carrying their own mount code, so these assert the mount lands
# and then that every refusal the configured form gets applies to the flag
# too. If they ever stop sharing a path, the guard tests below pass while the
# flags quietly stop being covered by them.
_rw_launch_flags() {
local ws="$1"; shift
STUB_DIR="$(mktemp -d)"
cat > "$STUB_DIR/docker" <<'EOF'
#!/bin/sh
for a in "$@"; do printf '%s\n' "$a" >> "$SANDBOX_TEST_DOCKER_LOG"; done
exit 0
EOF
chmod +x "$STUB_DIR/docker"
docker_log="$(mktemp)"
HOME="$RW_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
SANDBOX_TEST_DOCKER_LOG="$docker_log" \
run "$REPO_ROOT/bin/dejima" "$@" "$ws"
}
@test "--extra-readonly and --extra-read-write mount for one launch" {
_rw_setup
workspace="$(mktemp -d)"; ro="$(mktemp -d)"; rw="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
_rw_launch_flags "$workspace" --extra-readonly "$ro" --extra-read-write "$rw"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qxF "$ro:$ro:ro" "$docker_log" || { cat "$docker_log"; return 1; }
grep -qxF "$rw:$rw" "$docker_log" || { cat "$docker_log"; return 1; }
rm -rf "$workspace" "$ro" "$rw"
}
@test "the short forms are the same flags, and both are repeatable" {
_rw_setup
workspace="$(mktemp -d)"; a="$(mktemp -d)"; b="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
_rw_launch_flags "$workspace" --ro "$a" --ro "$b"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qxF "$a:$a:ro" "$docker_log" || { cat "$docker_log"; return 1; }
grep -qxF "$b:$b:ro" "$docker_log" || { cat "$docker_log"; return 1; }
rm -rf "$workspace" "$a" "$b"
}
# The flags add to the configured lists; they do not replace them. A launch
# that dropped the config's own entries would still pass the two tests above.
@test "a flag adds to [mounts] readonly rather than replacing it" {
_rw_setup
workspace="$(mktemp -d)"; configured="$(mktemp -d)"; extra="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' "readonly = $configured"
_rw_launch_flags "$workspace" --ro "$extra"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qxF "$configured:$configured:ro" "$docker_log" || { cat "$docker_log"; return 1; }
grep -qxF "$extra:$extra:ro" "$docker_log" || { cat "$docker_log"; return 1; }
rm -rf "$workspace" "$configured" "$extra"
}
# The point of appending to the same list: a flag is a way to say where, not
# a way past the refusals. Both directions, since the two lists are guarded
# by different code.
@test "--extra-readonly is refused when it names the approval store" {
_rw_setup
workspace="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
_rw_launch_flags "$workspace" --ro "$CONFIG_DIR"
[ "$status" -ne 0 ] || { echo "launched anyway:"; echo "$output"; return 1; }
[[ "$output" == *"Refusing --extra-readonly path"* ]] || { echo "$output"; return 1; }
rm -rf "$workspace"
}
@test "--extra-read-write is refused when it names the approval store" {
_rw_setup
workspace="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
_rw_launch_flags "$workspace" --rw "$CONFIG_DIR"
[ "$status" -ne 0 ] || { echo "launched anyway:"; echo "$output"; return 1; }
[[ "$output" == *"Refusing --extra-read-write path"* ]] || { echo "$output"; return 1; }
rm -rf "$workspace"
}
# A refusal that named "[mounts] readonly" would send someone to a config
# file that does not mention the path.
@test "a rejected flag path is reported as a flag, not as a config entry" {
_rw_setup
workspace="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
_rw_launch_flags "$workspace" --ro /nonexistent-flag-path
[ "$status" -ne 0 ]
[[ "$output" == *"--extra-readonly path does not exist"* ]] || { echo "$output"; return 1; }
rm -rf "$workspace"
}
# Both lists are colon-separated, so such a path cannot be represented in one
# and would split into two mounts that each fail the existence check.
@test "a flag path containing a colon is refused" {
_rw_setup
workspace="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
_rw_launch_flags "$workspace" --ro /tmp/has:colon
[ "$status" -ne 0 ]
[[ "$output" == *"must not contain a colon"* ]] || { echo "$output"; return 1; }
rm -rf "$workspace"
}
# #15. Both of these reach `extra_mounts+=(-v ...)` with a path computed at
# run time, so the static grep for a literal $PROJECT_STORE / config_root
# cannot see either one - it passed throughout. [mounts] read_write has been
# guarded since it was added; these two were not.
@test "[mounts] readonly is refused when it names the approval store" {
_rw_setup
workspace="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' "readonly = $CONFIG_DIR"
_rw_launch "$workspace"
[ "$status" -ne 0 ] || { echo "launched anyway:"; echo "$output"; return 1; }
[[ "$output" == *"Refusing [mounts] readonly entry"* ]] || { echo "$output"; return 1; }
rm -rf "$workspace"
}
# The target is discovered from a symlink rather than configured, so nothing
# had vetted it: a hook pointing into the approval store mounted the store.
@test "a ~/.claude symlink is refused when its target sits in the approval store" {
_rw_setup
workspace="$(mktemp -d)"
mkdir -p "$RW_HOME/.claude/hooks" "$CONFIG_DIR/sub"
: > "$CONFIG_DIR/sub/hook.sh"
ln -s "$CONFIG_DIR/sub/hook.sh" "$RW_HOME/.claude/hooks/hook.sh"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
_rw_launch "$workspace"
[ "$status" -ne 0 ] || { echo "launched anyway:"; echo "$output"; return 1; }
[[ "$output" == *"Refusing ~/.claude symlink target"* ]] || { echo "$output"; return 1; }
rm -rf "$workspace"
}
# The ordinary case the symlink scan exists for: this repo's own hooks/ are
# symlinked into ~/.claude and must still be mounted. Without this, refusing
# everything would pass the two tests above.
@test "a ~/.claude symlink to an ordinary directory is still mounted" {
_rw_setup
workspace="$(mktemp -d)"
hookdir="$(mktemp -d)"
: > "$hookdir/hook.sh"
mkdir -p "$RW_HOME/.claude/hooks"
ln -s "$hookdir/hook.sh" "$RW_HOME/.claude/hooks/hook.sh"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
_rw_launch "$workspace"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qxF "$hookdir:$hookdir:ro" "$docker_log" \
|| { echo "hook target not mounted:"; cat "$docker_log"; return 1; }
rm -rf "$workspace" "$hookdir"
}
@test "an empty ssh-agent is warned about when [core] ssh_key is not set" {
_rw_setup
workspace="$(mktemp -d)"
write_config '[core]' 'forge = none' 'workflow = local-only'
_empty_agent
_rw_launch "$workspace"
_kill_agent
[[ "$output" == *"no identities loaded"* ]] || { echo "$output"; return 1; }
rm -rf "$workspace"
}
@test "a read_write entry is mounted without :ro, unlike a readonly one" {
_rw_setup
rw="$(mktemp -d)"
+36
View File
@@ -908,6 +908,42 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
[ "$(launched_env SANDBOX_IMAGE)" = "dejima:$(id -un)-$slug" ]
}
# nvim's data/state/cache are Docker volumes, not host paths, so nothing a
# session installs reaches the host's own nvim. They do outlive the container
# though, and under one fixed name each they were shared by every project on
# the machine: a Lua file dropped under lazy/, or a binary replaced under
# mason/bin/, is executed by whatever project's session next runs nvim in the
# sandbox. Scoped per project, the same way the derived image tag is.
@test "the nvim volumes are per project, not one set shared by every project" {
launch_setup
run launch "$PROJ"
[ "$status" -eq 0 ]
slug="$(bash -c 'source "$1"; project_slug "$2"' _ "$LIB" "$PROJ")"
[ "$(launched_env SANDBOX_NVIM_DATA)" = "dejima_nvim-data$slug" ]
[ "$(launched_env SANDBOX_NVIM_STATE)" = "dejima_nvim-state$slug" ]
[ "$(launched_env SANDBOX_NVIM_CACHE)" = "dejima_nvim-cache$slug" ]
local other; other="$(mktemp -d)"
run launch "$other"
[ "$status" -eq 0 ]
[ "$(launched_env SANDBOX_NVIM_DATA)" != "dejima_nvim-data$slug" ]
rm -rf "$other"
}
# project_slug turns a leading / into -, and a Docker volume name may not
# start with one: without the prefix every launch would fail at `compose run`
# rather than at anything that names the cause.
@test "the derived nvim volume names are valid Docker volume names" {
launch_setup
run launch "$PROJ"
[ "$status" -eq 0 ]
local v
for v in SANDBOX_NVIM_DATA SANDBOX_NVIM_STATE SANDBOX_NVIM_CACHE; do
[[ "$(launched_env "$v")" =~ ^[a-zA-Z0-9][a-zA-Z0-9_.-]*$ ]] \
|| { echo "$v=$(launched_env "$v") is not a valid volume name"; return 1; }
done
}
@test "the launcher builds and runs the global layer when only global deps are approved" {
"$REPO_ROOT/bin/dejima-dep" add --global apt:libpq-dev >/dev/null
launch_setup
+74
View File
@@ -591,6 +591,80 @@ assert_remedy_forges_exist() {
[[ "$output" != *"create"*"PR"* ]] || { echo "branch-push briefing mentions opening a PR"; return 1; }
}
# #32: an implementer ran a bare `git config user.name ...` inside a
# worktree and wrote the parent checkout's real config, misattributing three
# commits. A worktree isolates the working tree, not `.git/config`, and the
# briefing told the agent to use worktrees while saying nothing about that -
# which reads as more isolation than is on offer. The same unscoped command
# reaches core.hooksPath and credential.helper, which execute on the host.
#
# In the core paragraph rather than a workflow's own prose, so a workflow can
# opt into worktrees but cannot ship them without this.
@test "every worktree briefing says a worktree does not isolate .git/config" {
local wf
for wf in worktree-pr branch-push; do
[ "$(lib manifest_get workflow "$wf" requires_worktree)" = "on" ]
run lib briefing_compose gitea "$wf" claude-code
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *".git/config"* ]] \
|| { echo "$wf briefing does not mention .git/config"; return 1; }
[[ "$output" == *"git -C"* ]] \
|| { echo "$wf briefing does not give the scoped form"; return 1; }
done
}
# The negative half: a workflow that does not use worktrees has no reason to
# carry the paragraph, and its presence there would mean the splice is
# unconditional rather than opt-in.
@test "a workflow without worktrees does not carry the worktree paragraph" {
[ "$(lib manifest_get workflow local-only requires_worktree)" = "off" ]
run lib briefing_compose none local-only claude-code
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" != *"does not isolate"* ]]
[[ "$output" != *"worktree MUST live"* ]]
}
# The default that blocks GitHub push exists to stop an eager agent pushing to
# somebody else's project. When the operator lifts it, the agent gains the
# ability without the reason, so the briefing supplies the reason.
@test "the briefing warns against pushing elsewhere when GitHub push is on" {
DEJIMA_GITHUB_PUSH=on run lib briefing_compose gitea worktree-pr claude-code
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"GitHub push is enabled for this session"* ]]
[[ "$output" == *"pointed at and nothing else"* ]]
[[ "$output" == *"not a contribution"* ]]
# and it says what to do instead of nothing at all
[[ "$output" == *"say so in your reply"* ]]
}
# Two points from docs/responsible-use.md that an agent can act on, as
# opposed to the majority which are the operator's to follow. Kept short on
# purpose: this text is in every session's context, for every user.
@test "the briefing names the two responsible-use points an agent can act on" {
run lib briefing_compose gitea worktree-pr claude-code
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
# a domain that acts on the world rather than serving data
[[ "$output" == *"action rather than a source"* ]]
[[ "$output" == *"let the operator decide"* ]]
# and things in the working tree that are not code
[[ "$output" == *"not"*"code"* ]]
[[ "$output" == *".env"* ]]
}
@test "the briefing says nothing about GitHub push when it is off" {
run lib briefing_compose gitea worktree-pr claude-code
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" != *"GitHub push is enabled"* ]]
}
@test "bin/dejima turns the GitHub push briefing on for both routes to it" {
# --allow-github-push, and forge = github under a pushing workflow.
grep -q 'DEJIMA_GITHUB_PUSH=on' "$REPO_ROOT/bin/dejima"
grep -q 'ALLOW_GITHUB_PUSH_FLAG" = 1 \]; then' "$REPO_ROOT/bin/dejima"
grep -q 'hosts_allowlisted_by_core" ) \?= "on"\|hosts_allowlisted_by_core)" = "on"' "$REPO_ROOT/bin/dejima"
grep -q 'export DEJIMA_GITHUB_PUSH' "$REPO_ROOT/bin/dejima"
}
@test "local-only pushes nothing and blocks no branches" {
run lib manifest_validate workflow local-only
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
+9 -14
View File
@@ -409,21 +409,16 @@ make_cert() {
# shell *function* persists in the calling shell rather than scoping to
# the call, so it would leak into every later test in this file.
#
# The -u pair is what makes this test about the code rather than about
# where it runs (#56). "No cert configured" was expressed by not setting
# the variables, which only holds where nothing else set them -
# entrypoint.sh:211 exports both into every tls-transport session, so a
# suite run from inside a dejima container inherited real cert paths, the
# guard below never fired, and execution fell through to the listen. What
# failed then was an EADDRINUSE against the session's own forwarder on
# :443, reported as this assertion failing - the code was fine and the
# test was reading its own environment. Unsetting them states the
# precondition instead of assuming it.
# The -u pair makes this about the code rather than where it runs (#56).
# "No cert configured" used to be expressed by not setting the variables,
# which only holds where nothing else did - entrypoint.sh exports both
# into every tls-transport session, so a suite run inside a dejima
# container inherited real paths, the guard never fired, and the
# fall-through hit EADDRINUSE on :443 and was reported as this assertion
# failing. Unsetting states the precondition instead of assuming it.
#
# The port override is belt-and-braces: the cert check runs before the
# listen today, so a passing test never binds anything, but if that order
# ever changes the fall-through lands on an unused port rather than on
# whatever holds :443.
# The port override is belt-and-braces: the cert check precedes the listen
# today, so a fall-through lands on an unused port if that ever changes.
run env -u SANDBOX_ANTHROPIC_TLS_CERT -u SANDBOX_ANTHROPIC_TLS_KEY \
SANDBOX_ANTHROPIC_PROXY_SOCKET="$SOCK" SANDBOX_ANTHROPIC_TRANSPORT=tls \
SANDBOX_ANTHROPIC_PROXY_PORT=18398 \
+72 -25
View File
@@ -5,6 +5,25 @@
# compromised session writes into - so most of what's here is about what it
# refuses to do with that input.
# No test in this file may modify the repository's own CLIs. Two of them need
# an exit-time audit that fails, and they get it from a temp shadow of bin/
# (see shadow_repo) rather than by overwriting the real one. This is what
# proves it stayed that way even if a test aborts partway through, and it is
# checked against a hash rather than against `git status` so that it holds
# while someone has uncommitted edits to that file.
setup_file() {
REMOTE_AUDIT_SUM="$(sha256sum "$BATS_TEST_DIRNAME/../bin/dejima-audit" | cut -d' ' -f1)"
export REMOTE_AUDIT_SUM
}
teardown_file() {
local now
now="$(sha256sum "$BATS_TEST_DIRNAME/../bin/dejima-audit" | cut -d' ' -f1)"
[ "$now" = "$REMOTE_AUDIT_SUM" ] || {
echo "test/remote.bats modified bin/dejima-audit in the repository" >&2
return 1
}
}
setup() {
REPO_ROOT="$BATS_TEST_DIRNAME/.."
LIB="$REPO_ROOT/bin/dejima-project-lib.sh"
@@ -19,15 +38,6 @@ teardown() {
if [ -n "${TMUX_DIR:-}" ]; then
TMUX_TMPDIR="$TMUX_DIR" tmux kill-server 2>/dev/null || true
fi
# Safety net for tests that temporarily swap out the real bin/dejima-
# audit: each such test restores it itself right after `run`, before any
# assertion that could short-circuit the test body, but this catches the
# case anyway if something in between still managed to abort first.
if [ -n "${AUDIT_BACKUP:-}" ] && [ -f "$AUDIT_BACKUP" ]; then
cp "$AUDIT_BACKUP" "$REPO_ROOT/bin/dejima-audit"
chmod +x "$REPO_ROOT/bin/dejima-audit"
rm -f "$AUDIT_BACKUP"
fi
rm -rf "$CONFIG_DIR" "$RUNTIME_DIR" ${STUB_DIR:+"$STUB_DIR"} ${LAUNCH_DIR:+"$LAUNCH_DIR"} \
${TMUX_DIR:+"$TMUX_DIR"} ${FAKE_HOME:+"$FAKE_HOME"}
}
@@ -70,6 +80,54 @@ write_remote_config() {
[ "$output" = "$RUNTIME_DIR/dejima" ]
}
# A throwaway copy of the repository whose bin/dejima-audit a test may
# replace or delete. Everything else is a symlink to the real thing, so this
# costs one directory of links.
#
# bin/ is copied rather than linked, and both reasons matter. bin/dejima
# resolves its own location with `readlink -f`, so a symlinked copy would
# point SCRIPT_DIR back at the repository and the stub would never be the one
# it calls. And a symlink is actively dangerous here: `cat > $SHADOW/bin/
# dejima-audit` follows it and truncates the real script, which is precisely
# what this helper exists to stop. Copying removes the whole class rather
# than one instance of it.
#
# Two tests here need an audit binary that fails, and they used to get one by
# overwriting `$REPO_ROOT/bin/dejima-audit` and restoring it afterwards. That
# left the working tree holding a two-line stub for the length of the test,
# which is a poor thing to do to a security tool: a run killed partway, or
# two runs of this file at once, restores the stub permanently instead of the
# script, and a suite that passes while silently disabling the exit-time
# audit is the exact failure this file exists to prevent. It also, reasonably
# enough, reads like an attack to anything watching the repository for
# changes.
shadow_repo() {
local root entry
root="$(cd "$REPO_ROOT" && pwd)"
SHADOW="$BATS_TEST_TMPDIR/shadow"
mkdir -p "$SHADOW"
for entry in "$root"/*; do
[ "$(basename "$entry")" = bin ] && continue
ln -sfn "$entry" "$SHADOW/$(basename "$entry")"
done
cp -a "$root/bin" "$SHADOW/bin"
DEJIMA_BIN="$SHADOW/bin/dejima"
}
# Stands in for the real audit and does nothing but fail. Self-describing
# because a file watcher can surface it mid-test, and a bare `exit 3` sitting
# where a security tool belongs is alarming without this comment on it.
shadow_failing_audit() {
cat > "$SHADOW/bin/dejima-audit" <<'EOF'
#!/bin/bash
# Test fixture for test/remote.bats, living in that test's temp directory
# and never in the repository. Fails on purpose: bin/dejima must still
# report the container's exit code and not this one.
exit 3
EOF
chmod +x "$SHADOW/bin/dejima-audit"
}
# A fake `docker` that records its argv, so bin/dejima's launch path can be
# exercised with no daemon. Same approach as test/project.bats.
stub_launch() {
@@ -124,7 +182,7 @@ launch() {
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
SANDBOX_TEST_SETTINGS_COPY="$LAUNCH_DIR/settings-copy.json" \
SSH_AUTH_SOCK= ALLOWED_DOMAINS= \
"$REPO_ROOT/bin/dejima" "$@"
"${DEJIMA_BIN:-$REPO_ROOT/bin/dejima}" "$@"
}
run_line() { grep -- ' run --rm ' "$LAUNCH_DIR/docker-log" | tail -1; }
@@ -280,15 +338,9 @@ done
exit 0
EOF
chmod +x "$LAUNCH_DIR/docker"
AUDIT_REAL="$REPO_ROOT/bin/dejima-audit"
AUDIT_BACKUP="$(mktemp)"
cp "$AUDIT_REAL" "$AUDIT_BACKUP"
printf '#!/bin/bash\nexit 3\n' > "$AUDIT_REAL"
chmod +x "$AUDIT_REAL"
shadow_repo
shadow_failing_audit
run launch "$PROJ"
cp "$AUDIT_BACKUP" "$AUDIT_REAL"
chmod +x "$AUDIT_REAL"
rm -f "$AUDIT_BACKUP"
[ "$status" -eq 5 ]
rm -rf "$PROJ"
}
@@ -309,14 +361,9 @@ done
exit 0
EOF
chmod +x "$LAUNCH_DIR/docker"
AUDIT_REAL="$REPO_ROOT/bin/dejima-audit"
AUDIT_BACKUP="$(mktemp)"
cp "$AUDIT_REAL" "$AUDIT_BACKUP"
rm -f "$AUDIT_REAL"
shadow_repo
rm -f "$SHADOW/bin/dejima-audit"
run launch "$PROJ"
cp "$AUDIT_BACKUP" "$AUDIT_REAL"
chmod +x "$AUDIT_REAL"
rm -f "$AUDIT_BACKUP"
[ "$status" -eq 5 ]
rm -rf "$PROJ"
}
+113 -35
View File
@@ -29,42 +29,31 @@ IMAGE=dejima:latest
container_name() { echo "dejima-test-$(basename "$BATS_FILE_TMPDIR")"; }
network_name() { echo "dejima-test-net-$(basename "$BATS_FILE_TMPDIR")"; }
# Every container this file starts must go on network_name(), never the
# default bridge: dns-proxy.sh forwards allowlisted names to Docker's
# embedded resolver at 127.0.0.11, which only exists on a user-defined
# network (compose gives the real sandbox one). On the default bridge the
# container gets the host's nameservers instead, dnsmasq has nowhere to
# forward, and startup dies in dns-proxy.sh - see its own check for this.
# Every container here must go on network_name(), never the default bridge:
# dns-proxy.sh forwards to Docker's embedded resolver at 127.0.0.11, which
# only exists on a user-defined network. On the default bridge dnsmasq has
# nowhere to forward and startup dies in dns-proxy.sh.
#
# Readiness is the *final* `sleep infinity` (entrypoint.sh's
# SANDBOX_TEST_MODE exec), matched exactly. A bare `pgrep -x sleep` also
# matches the short-lived sleeps of the startup sequence itself
# (dns-proxy.sh's dig-retry loop, entrypoint.sh's forwarder-bind wait), and
# a test that proceeds on one of those asserts against a container whose
# firewall hasn't been installed yet - which silently turns egress
# assertions into vacuous passes, since everything is still reachable at
# that point. Waiting for `sleep infinity` means the whole startup sequence
# (DNS proxy, firewall, ipsets, logging, forwarder bind) has completed.
# Readiness is the FINAL `sleep infinity` (entrypoint.sh's SANDBOX_TEST_MODE
# exec), matched exactly. A bare `pgrep -x sleep` also matches the startup
# sequence's own short sleeps, and proceeding on one of those asserts against
# a container with no firewall yet - turning egress assertions into vacuous
# passes, since everything is still reachable.
#
# 120 rather than 60: init-firewall.sh's GitHub ranges fetch now retries, and a
# run that loses two attempts before succeeding spends ~26s of the budget on
# that call alone (#59) - which on a runner that has just spent three minutes
# unpacking an image is enough to turn a recovery into a spurious failure. A
# longer budget only delays reporting a genuinely stuck startup; it cannot hide
# one, since nothing here passes on a timeout.
# `docker wait` returns the moment the container exits, but the json-file log
# driver writes asynchronously - so reading the log right then can return one
# that stops mid-stream, with everything after some flush boundary missing.
# 120s rather than 60: the GitHub ranges fetch retries, and losing two
# attempts costs ~26s (#59), which on a runner that just spent minutes
# unpacking an image turns a recovery into a spurious failure. A longer
# budget only delays reporting a stuck startup; nothing here passes on a
# timeout.
# `docker wait` returns on exit, but the json-file log driver writes
# asynchronously, so reading right then can return a log that stops
# mid-stream. That presented as a container "aborting" partway through
# init-firewall.sh's domain loop at a different domain each run, and survived
# three fixes before the giveaway: no message from an ERR trap that cannot be
# bypassed. Nothing had aborted; the read was short.
#
# That is not hypothetical. It presented as a container "aborting" partway
# through init-firewall.sh's domain loop, at a slightly different domain each
# run, and survived three separate fixes to that loop before the giveaway: the
# log ended with no message from an ERR trap that cannot be bypassed and no
# output from a retry that prints on every path. Nothing had aborted. The read
# was short.
#
# Two identical consecutive reads means the driver has caught up. Deliberately
# content-neutral - waiting for an expected string would make the wait pass or
# Two identical consecutive reads means the driver has caught up.
# Content-neutral on purpose - waiting for an expected string would pass or
# fail on the very thing the caller is about to assert.
settled_logs() {
local container="$1" current previous=""
@@ -408,8 +397,30 @@ dexec_node() {
}
@test "GitHub is reachable on port 443" {
run dexec curl --connect-timeout 5 -s -o /dev/null -w "%{http_code}" https://api.github.com/zen
[ "$status" -eq 0 ]
# Retried, and only because this is a POSITIVE assertion: more attempts
# can only turn a pass into a pass. The unreachability assertions below
# are never retried - there, a retry could hide a hole that opens once.
#
# It failed once on CI with nothing to go on: no curl exit code, no HTTP
# status, no way to tell DNS from the ipset from the far end. Everything
# else in that run said the firewall was right - the port-22 push grant
# reached GitHub, so github-domains held real addresses - which left one
# five-second connect against a live third-party service as the only
# explanation, and no evidence to confirm it with. Hence the diagnostics
# as much as the retries.
local attempt code rc=0
for attempt in 1 2 3; do
code="$(dexec curl --connect-timeout 5 -s -o /dev/null \
-w '%{http_code}' https://api.github.com/zen 2>&1)" && return 0
rc=$?
sleep 2
done
echo "api.github.com:443 unreachable after 3 attempts." >&2
echo "last curl exit: $rc, last output: '$code'" >&2
echo "exit 6 is DNS, 7 or 28 is the route or the firewall; an HTTP code" >&2
echo "with a non-zero exit means the connection worked. Check with" >&2
echo "'ipset list github-domains' inside the container." >&2
false
}
@test "GitHub is NOT reachable on port 22 (push transport is physically absent)" {
@@ -594,6 +605,73 @@ dexec_node() {
rm -rf "$src"
}
# #104: the whole ~/.claude/projects tree used to be mounted read-write, so a
# session could write another project's transcripts and agent memory. Memory
# is loaded into context at session start, which makes that a write into a
# future session's premises rather than its input.
#
# Three properties, and the second is the one that decides whether scoping is
# viable at all: a slug that is absent has to be creatable, because that is
# what Claude Code does on a first run against a path with no history. If the
# parent were read-only instead, this is where it would hang the way a
# blocked ~/.claude.json write did.
@test "only the mounted project slugs exist in the container, and an unmounted one is still creatable" {
local test_container="dejima-test-projects-$(basename "$BATS_FILE_TMPDIR")"
local src; src="$(mktemp -d)"
mkdir -p "$src/own" "$src/sibling" "$src/fh-own"
echo host > "$src/own/existing.jsonl"
echo secret > "$src/sibling/other.jsonl"
# file-history holds verbatim copies of edited files, so an unmounted
# session directory being absent is the whole point of scoping it.
echo 'other project source' > "$src/fh-own/aaaa@v1"
chmod -R 777 "$src"
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 "$src/own:/home/node/.claude/projects/own" \
-v "$src/fh-own:/home/node/.claude/file-history/own-session" \
--entrypoint /usr/local/bin/entrypoint.sh \
"$IMAGE" >/dev/null
wait_ready "$test_container"
# the sibling project is absent, not merely unwritable
run docker exec -u node "$test_container" test -e /home/node/.claude/projects/sibling
[ "$status" -ne 0 ]
run docker exec -u node "$test_container" ls /home/node/.claude/projects
[[ "$output" != *sibling* ]]
# a slug with no history yet can still be created, and stays in the
# container: it is not on the host afterwards
run docker exec -u node "$test_container" \
bash -c 'mkdir -p /home/node/.claude/projects/fresh && echo x > /home/node/.claude/projects/fresh/a.jsonl'
[ "$status" -eq 0 ]
[ ! -e "$src/fresh" ]
# and the mounted slug is the one that does reach the host
docker exec -u node "$test_container" \
bash -c 'echo written > /home/node/.claude/projects/own/new.jsonl'
[ "$(cat "$src/own/new.jsonl")" = written ]
# Same three properties for file-history. The middle one is what the
# image's pre-created directory buys: without it Docker creates
# file-history/ root-owned while setting up the mount nested in it, and
# the session cannot record an undo entry for its own id at all.
run docker exec -u node "$test_container" ls /home/node/.claude/file-history
[[ "$output" != *other-session* ]]
run docker exec -u node "$test_container" \
bash -c 'mkdir -p /home/node/.claude/file-history/fresh-session && echo x > /home/node/.claude/file-history/fresh-session/b@v1'
[ "$status" -eq 0 ]
[ ! -e "$src/fresh-session" ]
docker exec -u node "$test_container" \
bash -c 'echo written > /home/node/.claude/file-history/own-session/c@v1'
[ "$(cat "$src/fh-own/c@v1")" = written ]
docker rm -f "$test_container" >/dev/null 2>&1 || true
rm -rf "$src"
}
@test "a staged statusline.sh is copied in root-owned, and replacing it never reaches the host" {
# Companion to the overlay-dirs test above, but for the config
# bin/dejima stages at /etc/dejima-claude for entrypoint.sh to copy into
+212 -36
View File
@@ -449,16 +449,93 @@ EOF
# Directories, which is what makes them safe: a directory bind mount
# can't detach the way a file one does, because a rename replaces an
# entry *within* the mounted directory rather than the mount point.
# projects/ is what host-side `claude --resume` reads; the rest is the
# edit-undo record and the task/plan state skills write.
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/projects:\$\{HOME\}/\.claude/projects\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/file-history:\$\{HOME\}/\.claude/file-history\s*$' "$SANDBOX_DIR/docker-compose.yml"
# projects/ and file-history/ are not in this list: both are mounted an
# entry at a time by bin/dejima, see the tests below. The rest is the
# task/plan state skills write and the ~/.claude.json backups.
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/backups:\$\{HOME\}/\.claude/backups\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/tasks:\$\{HOME\}/\.claude/tasks\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/plans:\$\{HOME\}/\.claude/plans\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/sessions:\$\{HOME\}/\.claude/sessions\s*$' "$SANDBOX_DIR/docker-compose.yml"
}
# The whole tree carries every project's transcripts and agent memory, and
# memory is loaded into context at session start - so write access to it is
# enough to plant text a future session in another project reads as
# background (#104). This is the assertion that the tree itself never comes
# back; the per-slug mounts that replace it are covered below.
# The service still refers to the static keys; only the volume Docker
# actually creates varies, so a hardcoded name here would silently put every
# project back on one shared set.
@test "the nvim volumes take their names from the environment" {
grep -qE '^\s*name:\s*\$\{SANDBOX_NVIM_DATA:-' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*name:\s*\$\{SANDBOX_NVIM_STATE:-' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*name:\s*\$\{SANDBOX_NVIM_CACHE:-' "$SANDBOX_DIR/docker-compose.yml"
grep -q 'export SANDBOX_NVIM_DATA=' "$REPO_ROOT/bin/dejima"
}
@test "docker-compose.yml never mounts the whole ~/.claude/projects tree" {
run grep -nE '^\s*-\s*\$\{HOME\}/\.claude/projects:' "$SANDBOX_DIR/docker-compose.yml"
[ "$status" -ne 0 ] || {
echo "the projects tree is mounted whole again:" >&2
echo "$output" >&2
false
}
}
@test "bin/dejima mounts ~/.claude/projects one slug at a time" {
grep -q 'workspace_slugs_sorted "$WORKSPACE_DIR"' "$REPO_ROOT/bin/dejima"
grep -q 'extra_mounts+=(-v "$HOME/.claude/projects/$_slug:$HOME/.claude/projects/$_slug")' \
"$REPO_ROOT/bin/dejima"
}
# Docker creates the parents of a mount point root-owned when they do not
# already exist. projects/ is now such a parent, so without this the session
# could not create a slug of its own - which is exactly what it must do when
# the workspace has no history yet.
@test "the image pre-creates ~/.claude/projects so the slug mounts have a writable parent" {
grep -qE '^RUN mkdir -p .*\$SANDBOX_HOME/\.claude/projects' "$SANDBOX_DIR/Dockerfile"
}
# Each file-history/<session>/<hash>@v1 is a verbatim copy of an edited file,
# so mounting the tree hands a session every project's source outright -
# worse than the transcripts, which are a record about the work rather than
# the work. Writable too, so tampering with another project's undo record
# puts chosen content into a later restore there.
@test "docker-compose.yml never mounts the whole ~/.claude/file-history tree" {
run grep -nE '^\s*-\s*\$\{HOME\}/\.claude/file-history:' "$SANDBOX_DIR/docker-compose.yml"
[ "$status" -ne 0 ] || {
echo "the file-history tree is mounted whole again:" >&2
echo "$output" >&2
false
}
}
@test "bin/dejima mounts ~/.claude/file-history one session at a time" {
grep -q 'workspace_session_ids "$HOME/.claude/projects" "$WORKSPACE_DIR"' "$REPO_ROOT/bin/dejima"
grep -q 'extra_mounts+=(-v "$HOME/.claude/file-history/$_sid:$HOME/.claude/file-history/$_sid")' \
"$REPO_ROOT/bin/dejima"
}
@test "the image pre-creates ~/.claude/file-history so the session mounts have a writable parent" {
grep -qE '\$SANDBOX_HOME/\.claude/file-history' "$SANDBOX_DIR/Dockerfile"
}
# One definition of "this workspace's slugs", shared by the mount and the
# audit. If they drift, a slug the mount exposes but the audit calls foreign
# fires a finding every session, and a slug the audit calls its own but the
# mount does not expose is a hole nothing reports.
@test "bin/dejima and bin/dejima-audit share one slug definition" {
grep -q 'source "$SCRIPT_DIR/dejima-slug-lib.sh"' "$REPO_ROOT/bin/dejima"
grep -q 'dejima-slug-lib.sh"$' "$REPO_ROOT/bin/dejima-audit"
# and neither keeps a private copy of the sed scheme
run grep -n "s/\[^a-zA-Z0-9\]/-/g" "$REPO_ROOT/bin/dejima" "$REPO_ROOT/bin/dejima-audit"
[ "$status" -ne 0 ] || {
echo "an inline copy of the slug scheme is back:" >&2
echo "$output" >&2
false
}
}
@test "~/.claude config files are staged for copying, not mounted at their real paths" {
# A copy can't be detached by a host-side rename at all. entrypoint.sh
# installs them root-owned and read-only; see its comment and the mount
@@ -1674,6 +1751,13 @@ this line is not json'
[ "$status" -ne 0 ]
run grep -nE '^\s*extra_mounts\+=.*(PROJECT_STORE|config_root)' "$REPO_ROOT/bin/dejima"
[ "$status" -ne 0 ]
# Both greps are literal, so neither can see a mount whose path is
# computed at run time - and two of those reached the store (#15): a
# [mounts] readonly entry naming it, and a ~/.claude symlink pointing
# into it. What refuses them is asserted by running the launcher, in
# config.bats ("refused when it names the approval store"). This test
# is the cheap half and does not stand in for that one.
grep -q '_refuse_guarded_mount' "$REPO_ROOT/bin/dejima"
}
@test "sandbox-context escapes a declared domain's dots before matching the servers file" {
@@ -1943,6 +2027,65 @@ EOF
# likely to skip: a project with no CI is exposed too, because a branch adds
# the first workflow. If that goes missing, the document reads as being about
# people who already run CI, which is the smaller group.
# Who wrote the prose. The repository argues at length in a voice that is not
# its maintainer's, and saying so is the difference between disclosure and
# letting a reader assume. Pinned because it is the kind of line that goes
# quietly in a tidy-up, and its absence is not visible from anything else.
@test "the README says the prose is written by agents" {
grep -q 'A note from the developer' "$REPO_ROOT/README.md"
grep -q 'the words are written rapidly' "$REPO_ROOT/README.md"
# the contrast that makes both claims credible: ATTRIBUTION.md says the
# artwork is the other way round, and the README points at it
grep -q 'no AI tools were used to create it' "$REPO_ROOT/README.md"
grep -q 'No AI tools were used' "$REPO_ROOT/ATTRIBUTION.md"
}
# The maturity disclaimer, pinned in all three places a reader can land. A
# project that argues at length for its own security properties is exactly the
# one that has to say those properties are unreviewed - and it is the first
# thing that gets quietly dropped as a project starts to feel established.
@test "every entry point says this is unreviewed early development" {
local f
for f in README.md SECURITY.md docs/security.md; do
grep -q 'reviewed by anyone else\|outside review' "$REPO_ROOT/$f" || {
echo "$f does not say the work is unreviewed" >&2
false
}
done
grep -q 'Not ready to be depended on' "$REPO_ROOT/README.md"
grep -q 'not proven software' "$REPO_ROOT/SECURITY.md"
}
# The GitHub push default was documented as mechanism only - port 443, port 22
# unreachable - and a reader who needed the reason inferred the wrong one: that
# it is a containment measure against a hostile session. It is not. The
# repositories on the other side are other people's, and the failure it
# prevents takes no compromise, only an agent being helpful at a stranger's
# expense. Pinned in both places a reader lands, because getting this wrong
# leads someone to turn the flag on believing it costs them nothing.
@test "the GitHub push default is documented with its reason, not just its mechanism" {
# Matched on a fragment rather than the whole sentence: it wraps
# differently in each file, and a line-based grep cannot span that.
local f
for f in README.md docs/security.md; do
grep -q 'not being used to push' "$REPO_ROOT/$f" || {
echo "$f gives the mechanism without the principle" >&2
false
}
done
# the eager-agent case, which is what makes the principle concrete - and
# that it is not softened into a well-meaning near-miss. The unsolicited
# pull request is the thing being prevented, not a good deed with bad
# timing; docs/responsible-use.md is where the cost to maintainers is set
# out, and this must not read as disagreeing with it.
grep -q 'an eager agent, not a compromised one' "$REPO_ROOT/docs/security.md"
grep -q 'does not make it harmless' "$REPO_ROOT/docs/security.md"
# and the ordering a reader got backwards: CI follows from the principle
# rather than being the reason for it
grep -q 'not the$\|reason for it' "$REPO_ROOT/docs/security.md"
grep -q 'dont-point-it-at-repositories-you-dont-own' "$REPO_ROOT/docs/security.md"
}
@test "docs/ci.md says a project with no CI is exposed too" {
grep -q 'applies to projects with no CI at all' "$REPO_ROOT/docs/ci.md"
grep -q 'no workflows is not therefore safe' "$REPO_ROOT/docs/ci.md"
@@ -2009,6 +2152,31 @@ EOF
[ "$missing" -eq 0 ]
}
# Other repositories in the organisation link to these two by absolute URL
# rather than keeping copies, so the terms cannot drift into two divergent
# statements. A rename here is a 404 in a repository whose tests cannot see
# this one.
@test "the org-wide policy files other repositories link to keep their names" {
[ -f "$REPO_ROOT/SECURITY.md" ]
[ -f "$REPO_ROOT/ATTRIBUTION.md" ]
}
# The wordmark is referenced by a fixed filename on purpose - see the comment
# above the tag - which means a rename is caught here or not at all.
# Markdown image syntax as well as <img>, and svg as well as png: the badge
# is referenced the markdown way and is the case this exists for.
@test "every image the README points at is committed" {
local src missing=0
for src in $(grep -ohE 'src="[^"]+\.(png|svg)"|\]\([^)]+\.(png|svg)\)' "$REPO_ROOT/README.md" \
| sed 's/^src="//; s/"$//; s/^](//; s/)$//'); do
if [ ! -f "$REPO_ROOT/$src" ]; then
echo "README points at $src, which does not exist" >&2
missing=1
fi
done
[ "$missing" -eq 0 ]
}
# --- the labels this repo's own workflow asks for ---
#
# Whether a job container gets the Docker socket is settled by the runner's
@@ -2125,7 +2293,7 @@ EOF
# 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 forges workflows harnesses hooks install.sh systemd .gitea)
SCANNED_PATHS=(README.md SECURITY.md ATTRIBUTION.md docs docker bin forges workflows harnesses 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' \
@@ -2468,39 +2636,26 @@ q
}
@test "core names no forge" {
# The single assertion that keeps this separation from decaying: without
# it, the first convenient edit puts a forge name back into core. A
# whole-file skip or a substring loose enough to stop catching a real
# prose leak would both defeat the point, so each file gets its own
# narrow, justified exclusion for the specific lines that are not a
# forge coupling - everything else in scope must still match nothing.
# What keeps core free of forge names. Each file gets a narrow, justified
# exclusion rather than a whole-file skip; everything else must match
# nothing. Two scans, different subjects:
#
# Two scans, because they have different subjects:
# (1) `gitea` and `github.com` as case-insensitive substrings, which
# catches a PROSE leak ("push to the Gitea remote") as much as a
# coupling.
# (2) Every name `provider_list forge` reports, in literal position -
# derived from the shipped directories, so a new forge is covered the
# day it lands. Scan 1 missed exactly that: bare `github` matches
# neither alternative, so core could compare a forge name with this
# test green.
#
# (1) `gitea` and `github.com` as substrings, case-insensitively. This is
# the original scan, and it is what catches a PROSE leak ("push to
# the Gitea remote") as much as a coupling.
#
# (2) Every name `provider_list forge` reports, in literal position.
# Derived from the shipped directories rather than written out here,
# so a forge is covered the day it lands - which is exactly what
# scan 1 missed: `github` became a shipped FORGE name, and a bare
# `github` matches neither of scan 1's alternatives, so core could
# (and did) compare a forge name to a literal with this test green.
#
# "Literal position" means quoted, backticked, bracketed as a config
# section, prefixed `forge.`, under `forges/`, or a `case` arm -
# every shape a lookup or comparison actually takes here. It is not a
# bare-word scan because two shipped names cannot survive one: `none`
# is an ordinary English word this repo's prose uses constantly, and
# `github` is also the name of a CORE feature that predates the forge
# axis (GitHub read-only: `[github] token_file`, the `github.com`
# credential helper, `--allow-github-push`). Operator-facing remedy
# text naming a config VALUE (`set [core] forge = none`, `or [core]
# forge = github`) is deliberately outside these shapes too: a rule
# that refuses a combination has to be able to say which one works,
# and "pick a different forge" without saying which is a worse
# message, not a purer one.
# "Literal position" means quoted, backticked, a config section,
# `forge.`-prefixed, under `forges/`, or a case arm - not a bare-word
# scan, because `none` is an ordinary English word and `github` also
# names a core feature predating the forge axis (`[github]
# token_file`, `--allow-github-push`). Remedy text naming a config
# VALUE is outside these shapes too: a rule that refuses a
# combination has to say which one works.
local f matches fail=0 name literals="" body
while IFS= read -r name; do
[ -n "$name" ] || continue
@@ -2634,6 +2789,27 @@ q
grep -q 'DEJIMA_CI_TIER: privileged' "$wf"
}
# test/remote.bats used to stand in a failing exit-time audit by overwriting
# `$REPO_ROOT/bin/dejima-audit` and restoring it afterwards. The restore does
# not happen if the run is killed partway, and two runs of that file at once
# restore each other's stub, so a suite could pass while leaving the real
# audit disabled - and a file watcher looking at the tree during the window
# sees a security tool replaced by a two-line `exit 3`, which reads exactly
# like an attack.
#
# Tests get a throwaway copy to mutate instead. Redirects and removals are
# what actually went wrong, so those are what this pins; a test that finds
# some third way to write into the checkout is not covered.
@test "no test suite writes into the repository it is testing" {
run grep -rnE '(>>?|rm +(-[a-zA-Z]+ +)*)[[:space:]]*"?\$\{?(REPO_ROOT|BATS_TEST_DIRNAME)' \
"$REPO_ROOT"/test/*.bats
[ "$status" -ne 0 ] || {
echo "a test writes into the checkout; give it a copy instead:" >&2
echo "$output" >&2
false
}
}
@test "the agent briefing teaches the project scope, not just global" {
# The observed failure: an agent asked for a project-specific domain to be
# added container-wide and globally, because --project was never named.