Compare commits

...
Author SHA1 Message Date
branderson 247dbf73dd Describe dejima as a development environment, not only a sandbox
tests / confined (proxy) (pull_request_target) Successful in 1m3s
tests / runner-env (confined) (pull_request_target) Successful in 30s
tests / confined (static) (pull_request_target) Successful in 2m24s
tests / confined (providers) (pull_request_target) Successful in 2m58s
tests / confined (remote) (pull_request_target) Successful in 3m20s
tests / confined (project) (pull_request_target) Successful in 4m22s
tests / confined (config) (pull_request_target) Successful in 5m36s
tests / confined (audit) (pull_request_target) Successful in 6m23s
tests / privileged (pull_request_target) Successful in 3m54s
tests / runner-env (privileged) (pull_request_target) Successful in 33s
The README opened on containment and stayed there, so a reader learned
what dejima stops and never what it is for. The container is half of it;
the other half is a forge workflow the agent drives itself, context
written for the agent rather than for the operator, and enough of a
trail that a session can be left alone and picked up afterwards.

The feature list is split along that line rather than rewritten, and two
things it never mentioned are added to the second half: `.dejima.md`,
and the compaction summaries kept host-side.

Also drops "Claude Code by default" from the opening paragraph.
`[core] harness` has had no default since it became required, and that
sentence was the promise the requirement exists to not make.
2026-08-12 19:16:07 -07:00
branderson d7d808425c Merge remote-tracking branch 'origin/main' into development
# Conflicts:
#	docs/development.md
2026-08-12 18:57:56 -07:00
branderson 5b8f6e71be Merge pull request 'Add epitaph: keep what a session leaves when its context dies' (#123) from compaction-summaries into development
Reviewed-on: #123
Reviewed-by: branderson <brad@branderson.io>
2026-08-12 17:19:45 -07:00
branderson ef8cc12d9f Add epitaph: keep what a session leaves when its context dies
tests / runner-env (confined) (pull_request_target) Successful in 32s
tests / confined (proxy) (pull_request_target) Successful in 1m8s
tests / confined (static) (pull_request_target) Successful in 2m45s
tests / confined (providers) (pull_request_target) Successful in 3m30s
tests / confined (remote) (pull_request_target) Successful in 3m59s
tests / privileged (pull_request_target) Successful in 4m18s
tests / runner-env (privileged) (pull_request_target) Successful in 31s
tests / confined (project) (pull_request_target) Successful in 5m8s
tests / confined (config) (pull_request_target) Successful in 6m28s
tests / confined (audit) (pull_request_target) Successful in 6m58s
Claude Code writes a summary each time a session is compacted. It is the only
durable account of what a long session worked out, and until now it went with
the container.

bin/dejima-epitaph runs host-side from the exit trap, reads the workspace's
own transcripts, and writes each summary to [epitaph] dir with its trigger
and token counts. Nothing in the container takes part, and the store is never
mounted - so a session can neither read what earlier sessions concluded nor
edit what this one is about to record. That is the same arrangement as the
audit store, for the reason #104 established.

Nothing loads an epitaph back into a later session, deliberately. A file a
session writes and a later session reads is the channel #104 closed; this
one goes to the operator, who can promote anything worth keeping into
.dejima.md, where it is reviewed like code.

The transcript it reads IS session-writable, so an epitaph records what was
there rather than vouching for it. The header says so.

[epitaph] loki, off by default, also pushes each one to [logging] loki_url -
host-side rather than through the container's log shipper, so the shipping
path keeps the same property.
2026-08-12 11:25:26 -07:00
branderson d5b9830631 Merge pull request 'Open branches against development, not main' (#124) from development-branch-process into development
Reviewed-on: #124
Reviewed-by: branderson <brad@branderson.io>
2026-08-12 01:43:17 -07:00
branderson bd0f5ddab6 Merge pull request 'Make the exit-time audit clearable in one sitting' (#115) from audit-usability into main
Reviewed-on: #115
Reviewed-by: branderson <brad@branderson.io>
2026-08-12 01:41:21 -07:00
branderson d4a45cc3f7 Open branches against development, not main
tests / runner-env (confined) (pull_request_target) Successful in 34s
tests / confined (proxy) (pull_request_target) Successful in 1m5s
tests / confined (static) (pull_request_target) Successful in 2m42s
tests / confined (providers) (pull_request_target) Successful in 3m31s
tests / confined (remote) (pull_request_target) Successful in 3m52s
tests / privileged (pull_request_target) Successful in 4m23s
tests / runner-env (privileged) (pull_request_target) Successful in 32s
tests / confined (project) (pull_request_target) Successful in 5m6s
tests / confined (config) (pull_request_target) Successful in 6m24s
tests / confined (audit) (pull_request_target) Successful in 7m8s
main stays releasable while work lands quickly. development merges to main
periodically; nothing else about the workflow changes.

Stated in the three places it gets looked for - .dejima.md for an agent
working here, CONTRIBUTING.md for a reader, docs/development.md for the
mechanics - with a test that all three say it, because a convention with one
stale copy is a convention someone follows wrongly.

The mistake worth naming is gitea-pr's base: it defaults to the repository's
default branch, so --base development has to be passed. It is recoverable, a
pull request's base can be changed afterwards, so the instruction is to read
what gitea-pr printed rather than to be careful.

Nothing enforces the target. git-push-guard blocks the branches a workflow
names, and that list is workflow-wide rather than per-project, so adding
development would impose it on every dejima user - most of whom have no such
branch. Said plainly in docs/development.md rather than left to be discovered.

CI needed no change: tests.yml triggers on pull_request_target with no branch
filter, so it already runs whatever the base is.
2026-08-12 00:34:32 -07:00
branderson 54962bb165 Keep the audit edge case out of the README
tests / runner-env (confined) (pull_request_target) Successful in 33s
tests / confined (proxy) (pull_request_target) Successful in 1m7s
tests / confined (static) (pull_request_target) Successful in 2m44s
tests / confined (providers) (pull_request_target) Successful in 3m33s
tests / confined (remote) (pull_request_target) Successful in 3m55s
tests / confined (config) (pull_request_target) Successful in 6m27s
tests / confined (audit) (pull_request_target) Successful in 7m20s
tests / privileged (pull_request_target) Successful in 4m24s
tests / runner-env (privileged) (pull_request_target) Successful in 34s
tests / confined (project) (pull_request_target) Successful in 5m9s
The deleted-surface behaviour is audit mechanics, not something a reader
needs on first contact with the project. It moves to docs/security.md beside
the rest of the approve/ignore/skip rules, which is where someone asking that
question already is.

The README keeps one line: show indexes, ack walks it.
2026-08-12 00:06:32 -07:00
branderson 1efcc42294 Make the exit-time audit clearable in one sitting
Five problems, one of which caused another.

A finding whose file had since been deleted could not be approved - there is
no content to pin a `safe` entry to - so `ack` refused. But refusing meant
crit_acked never reached crit_seen, the report kept -CRITICAL.txt, and
select_report prefers CRITICAL: one deleted file pinned the audit to a report
no decision could resolve and hid every other report behind it, permanently.
It is cleared now. Nothing is recorded, because nothing unmatchable should
reach the baseline, but the finding is done with - a surface that does not
exist does not run, and if it comes back it comes back as new.

`ack` walks every report in one run, asking after each whether to continue,
and says up front how many are waiting. It used to walk one and exit with no
indication there were more, so clearing a backlog took as many runs as there
were reports and read as the tool being stuck on the first.

The cross-project finding is gone. It reported a poisoning route a session
could once take, when the whole ~/.claude/projects tree was mounted; since
belongs to another session doing its own work - and with several running it
reported all of them, on every report. Detection that cannot observe what it
was built for, and fires constantly on what it was not, trains an operator to
skim.

TCB findings now split on whether git can see the change. tcb_snapshot only
populates when the workspace IS dejima's own repo, so an ordinary change is
already in the working tree about to be read in `git status` and in the pull
request - one CRITICAL per file duplicated a stronger control and made
developing dejima unusable. Those collapse to one INFO line. A change git
CANNOT see stays CRITICAL, because nothing else catches it: .gitignore,
.git/info/exclude, assume-unchanged and skip-worktree all hide live code that
runs on the next launch.

`show` indexes the reports - how many, which are CRITICAL, which `ack` will
open - instead of dumping one report's contents including findings already
acknowledged. `show --full` prints one.

Two bugs found while doing this, both mine, both fixed with the tests that
caught them: the `reviewed` marker recorded a filename before the report shed
its -CRITICAL suffix, so it never matched afterwards; and a cleared finding
was neither baseline-suppressed nor un-ackable, so it re-presented on every
later run - the same loop the clear was meant to end.
2026-08-12 00:06:16 -07:00
branderson 13e8c39d0f Merge pull request 'Require [core] harness rather than defaulting to claude-code' (#121) from require-harness into main
Reviewed-on: #121
Reviewed-by: branderson <brad@branderson.io>
2026-08-11 23:48:01 -07:00
branderson 1ba13df4df Merge pull request 'Tell agents here what the compression passes keep cutting' (#122) from dejima-md-brevity into main
Reviewed-on: #122
Reviewed-by: branderson <brad@branderson.io>
2026-08-11 23:21:43 -07:00
branderson 779a644e63 Tell agents here what the compression passes keep cutting
tests / runner-env (confined) (pull_request_target) Successful in 33s
tests / confined (proxy) (pull_request_target) Successful in 1m7s
tests / confined (static) (pull_request_target) Successful in 2m42s
tests / confined (providers) (pull_request_target) Successful in 3m32s
tests / confined (remote) (pull_request_target) Successful in 3m54s
tests / privileged (pull_request_target) Successful in 4m28s
tests / runner-env (privileged) (pull_request_target) Successful in 32s
tests / confined (project) (pull_request_target) Successful in 5m2s
tests / confined (config) (pull_request_target) Successful in 6m18s
tests / confined (audit) (pull_request_target) Successful in 7m13s
The prose in this repository has been through two compression passes, and
both cut the same things: how a bug was found, consequence chains followed
past the first step, restatements of the code below, meta-commentary, and
numbers that were wrong within a month. Writing that guidance down is
cheaper than running the pass again.

Framed as keep/cut rather than "be concise", which is not actionable, and
explicitly not an argument for thinner documentation - the README's note on
voice prefers thorough over polished, and this has to not contradict it. The
distinction is padding, not length: the sections named at the end are long
because the argument is the content, and they survive every pass.
2026-08-11 22:49:07 -07:00
branderson 799dd3b6eb Require [core] harness rather than defaulting to claude-code
tests / runner-env (confined) (pull_request_target) Successful in 32s
tests / confined (proxy) (pull_request_target) Successful in 1m4s
tests / confined (static) (pull_request_target) Successful in 2m38s
tests / confined (providers) (pull_request_target) Successful in 3m27s
tests / confined (remote) (pull_request_target) Successful in 3m49s
tests / privileged (pull_request_target) Successful in 4m19s
tests / runner-env (privileged) (pull_request_target) Successful in 32s
tests / confined (project) (pull_request_target) Successful in 5m0s
tests / confined (config) (pull_request_target) Successful in 6m17s
tests / confined (audit) (pull_request_target) Successful in 7m4s
The default was fine while claude-code was the only harness and stops being
fine the moment there is a second: an operator who never wrote the key made
no choice, and a release changing the default would move them silently on
upgrade.

dejima now refuses, naming the file, the line to add, and what is available.
install.sh writes it uncommented in the starter config, the same treatment
forge and workflow already get, for the reason already written there.

--print-config keeps working with it unset, which took one fix: the schema
enumeration resolved [harness.<name>] keys through the selected name and
printed "invalid harness name:" for an empty one. An unselected axis
contributes no keys.

Every fixture that launches now selects a harness, as a real config must.
config.bats' write_config seeds one only when the caller has not, matched as
a key assignment rather than as the word anywhere in the arguments - a
[mounts] path containing "harness" otherwise suppressed it.
2026-08-11 20:38:28 -07:00
branderson 26bc014b3f Merge pull request 'Make the GitHub reachability probe retry, and say why when it fails' (#119) from runtime-github-flake into main
Reviewed-on: #119
Reviewed-by: branderson <brad@branderson.io>
2026-08-11 18:36:50 -07:00
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
39 changed files with 2537 additions and 1179 deletions
+68
View File
@@ -123,3 +123,71 @@ 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.
## Write the comment that prevents the bug, not the one that shows your work
The prose here gets a compression pass periodically, and the same things get
cut every time. Save that pass the work:
- **Keep** the issue number, the failure mode, and why a check is shaped the
way it is. Someone has to be able to not reintroduce the bug.
- **Cut how you found it.** Which CI run failed, how many attempts it took,
what you suspected first: that is your story, not the reader's.
- **Cut the second and third consequence.** State the first. A reader who
needs the chain can follow it.
- **Cut anything the code directly below already says.**
- **Cut meta-commentary** - "the useful way to read this is", "what follows
is why". Say the thing instead.
- **No numbers that drift.** Test counts, directory sizes, how many files a
sweep found: wrong within a month, and nothing checks them. This applies to
pull request descriptions too, which are read once and then stop being
true.
- **One place.** An edge case belongs in `docs/`, not also in the README -
the README is what someone needs on first contact.
This is not an argument for thinner documentation: the note on voice in the
README prefers thorough over polished, deliberately. It is an argument
against padding. A paragraph carrying one fact should be one sentence.
The blocks that survive every pass are the ones where the argument *is* the
content: the `O_NOFOLLOW` open in `dejima-project-lib.sh`, the id-derivation
anchoring in `dejima-audit`'s `ack`, `--check-origin` in `dejima-ttyd`.
Length is not the problem; length carrying no fact is.
## Target `development`, not `main`
Branches here are opened against `development`, which merges to `main`
periodically. `main` stays releasable while work lands quickly; nothing else
about the workflow changes.
`gitea-pr` defaults its base to the repository's default branch, so say so:
```sh
gitea-pr create --base development --title ... --body-file -
```
Forgetting is the likely mistake, and it is recoverable - a pull request's
base can be changed after the fact, in the web UI or through the API. Check
what you opened rather than assuming.
Rebase onto `origin/development` too, for the same reason: rebasing onto
`main` puts anything already merged to `development` back in your diff.
+23 -10
View File
@@ -28,9 +28,20 @@ listed here are covered too.
## Terms
The dejima name, logo and wordmark are trademarks of Brad Anderson. All rights
are reserved. They are not licensed under the GPL; GPL-3.0 §7(e) permits
declining to grant trademark rights.
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:**
@@ -40,14 +51,16 @@ declining to grant trademark rights.
**You may not, without written permission:**
- modify them: recolour, redraw, crop, animate, or fold them into another mark;
- use them in your own work, or as the mark of any other project, product or
organisation;
- use them so as to suggest that dejima endorses, sponsors or produced your
work.
- 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. All rights not granted above are
withheld.
Copyright in the image files is reserved.
+6
View File
@@ -14,6 +14,12 @@ different, **fork it and make the change in your fork**. Nothing here depends
on an upstream maintainer accepting a patch. The fork is yours to run, and
running it is the point.
## Branches
Work is opened against `development`, which merges to `main` periodically.
`main` is what a reader should expect to be releasable; `development` is
where it happens first.
## Expect it to move
Significant change is coming in the near future. Config keys, interfaces, the
+100 -35
View File
@@ -24,8 +24,8 @@
&nbsp;
**Agents work best unsupervised. Bound the blast radius instead, and assume
the agent is hostile.**
**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
@@ -33,18 +33,19 @@ dependency, and it will not announce that it has been. dejima is built for
that case: the agent works without asking, and what it can reach, take and
break was decided before it started.
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 trade a
reasonable one.
dejima is a **sandboxed development environment for unsupervised agentic
work.** It runs a coding agent (Claude Code, 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 trade a reasonable one.
**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.
A container on its own is only confinement. What makes this a development
environment is the rest: a forge workflow the agent drives itself, project
context written for the agent rather than for you, and enough of a trail left
behind that you can walk away from a session and still pick up what it did.
Around that core is most of what running an agent unattended takes:
**What bounds it:**
- **An egress allowlist.** DNS and IP, IPv4 and IPv6. Anything not on the
list does not connect, and the agent is told so.
@@ -56,40 +57,81 @@ Around that core is most of what running an agent unattended 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.
- **A PR and issue workflow.** `gitea-pr` and `gitea-issue` let a session
open pull requests and issues against Gitea or Forgejo and pull review
feedback back in, on a token scoped for exactly that. Merging stays a
human decision, made outside the sandbox.
- **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.
- **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.
- **Remote access, opt-in and off by default.** Attach to a live session
from a terminal or a browser, with a push notification when it wants you.
- **Guard rails inside the session.** Hooks refuse obviously destructive
commands, pushes to anywhere but the allowed remote, and merging a PR from
inside the sandbox. These are
fast-fail guards against an overeager agent, not a boundary against a
hostile one: the container is the boundary.
- **Config parity with your host.** `CLAUDE.md`, settings, hooks, plugins
and git identity carry over; uid, home directory and project path match on
both sides, so a session's transcript is readable from the host afterward.
- **An exit-time audit** of the host surfaces a session could have written
to, so a change to your git config or `~/.claude` gets surfaced.
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.
**What it gives an agent to work with:**
- **A PR and issue workflow the agent drives itself.** `gitea-pr` and
`gitea-issue` let a session open pull requests and issues against Gitea or
Forgejo and pull review feedback back in, on a token scoped for exactly
that. Merging stays a human decision, made outside the sandbox.
- **Project context written for the agent.** A project ships a `.dejima.md`
saying how work is done in it - `CLAUDE.md`'s job under a harness-agnostic
name - and dejima loads it alongside a briefing on where the session is,
what it can reach, and why the limits are there. An agent that knows it is
in a sandbox asks for a domain instead of working around the firewall.
- **Config parity with your host.** `CLAUDE.md`, settings, hooks, plugins
and git identity carry over; uid, home directory and project path match on
both sides, so a session's transcript is readable from the host afterward.
- **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.
- **What a long session worked out, kept.** Each time the agent compacts, the
summary it writes is saved host-side, into a store no container mounts. It
is the only durable account of a session's reasoning, and it otherwise goes
when the container does.
- **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.
**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
@@ -221,7 +263,8 @@ 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`.
`[core] harness`. `harness` has no default and must be set, so a later
release cannot change which one you get.
Ships today:
@@ -348,12 +391,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
@@ -488,6 +541,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
@@ -554,7 +615,7 @@ finding is something to read and clean up before your next `git` or `claude`
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.
`dejima-audit show` indexes what is waiting; `dejima-audit ack` walks it.
Approving records the exact content, so any later change re-fires; ignoring
records the surface, so that path is fine whatever it later contains; and
skipping decides nothing, which means you are asked again next session.
@@ -611,3 +672,7 @@ 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.
+12
View File
@@ -43,6 +43,18 @@ 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.
+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

+258 -121
View File
@@ -61,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
@@ -74,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
@@ -164,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,8 +296,35 @@ fi
DEJIMA_FORGE_NAME="$(config_get core forge)"
DEJIMA_WORKFLOW_NAME="$(config_get core workflow)"
DEJIMA_HARNESS_NAME="$(config_get core harness)"
# No default: claude-code was one, and a release changing it would move an
# operator who never chose to a different harness on upgrade.
if [ -z "$DEJIMA_HARNESS_NAME" ]; then
echo "[core] harness is not set in $(config_path)." >&2
echo "There is no default, so a later release cannot change your choice." >&2
echo "" >&2
echo " [core]" >&2
echo " harness = claude-code" >&2
echo "" >&2
echo "Available: $(provider_list harness | tr '\n' ' ')" >&2
exit 1
fi
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
@@ -268,6 +350,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 -
@@ -288,7 +372,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
@@ -296,7 +414,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
@@ -313,7 +431,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
@@ -510,12 +628,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
@@ -572,47 +698,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"
@@ -697,6 +805,10 @@ cleanup() {
# read it, so this function's own cleanup can't race the audit that
# needs it - and its removal is guarded too, so it stays truly last.
DEJIMA_RW_MOUNTS="$DEJIMA_RW_MOUNTS" "$SCRIPT_DIR/dejima-audit" compare "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
# Keep whatever the session compacted away, into a store nothing mounts.
# `|| true` for the same reason every other line in this trap has it: a
# failure here must not replace the container's exit status.
"$SCRIPT_DIR/dejima-epitaph" save "$WORKSPACE_DIR" || true
rm -f "$SANDBOX_AUDIT_STATE" || true
}
trap cleanup EXIT
@@ -841,6 +953,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
@@ -885,9 +1004,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
@@ -929,24 +1054,14 @@ if [ -d "$WORKSPACE_DIR/.git/hooks" ]; then
extra_mounts+=(-v "$WORKSPACE_DIR/.git/hooks:$WORKSPACE_DIR/.git/hooks:ro")
fi
# ~/.claude/projects, one slug at a time rather than the whole tree.
#
# The tree holds every project's transcripts and agent memory. Memory is
# loaded into context at session start, so a write into another project's
# slug is text a future session there reads as established background - and
# the mount alone was enough for that, no escape and no credential (#104).
# Mounting per slug leaves the others absent rather than present-but-
# unwritable, which is the state Claude Code already handles on a first run.
#
# The directory itself stays container-local, so a slug that is not mounted
# is still writable during the session and simply does not survive it. That
# is what lets Claude Code create a slug it does not find, which it does
# whenever a session runs against a path with no history yet.
#
# Only slugs that already exist on the host are mounted, apart from the
# workspace's own. A checkout with forty worktrees would otherwise have forty
# empty directories created under ~/.claude/projects on its first launch, all
# of them litter for the worktrees no session has ever run in.
# ~/.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
@@ -954,36 +1069,35 @@ while IFS= read -r _slug; do
extra_mounts+=(-v "$HOME/.claude/projects/$_slug:$HOME/.claude/projects/$_slug")
done < <(workspace_slugs_sorted "$WORKSPACE_DIR")
# ~/.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/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
@@ -994,16 +1108,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
@@ -1233,6 +1343,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
@@ -1286,6 +1407,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
+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%:*}"
+235 -138
View File
@@ -73,6 +73,24 @@ AUDIT_KEEP="$(config_get audit keep)"
# startup warning may well reach for `show` first, and showing them a
# different report than the one being warned about sends them looking for a
# finding that is not in it.
# How many reports are waiting, and how many of those are CRITICAL. `ack`
# walks one report at a time by design - each is a separate session's worth of
# findings - but saying nothing about the rest is what made clearing a backlog
# feel like the tool was stuck on one.
# A report's stable identity: its basename without the -CRITICAL marker, which
# `ack` removes once every CRITICAL in it has been dealt with.
report_key() {
local b; b="$(basename "$1")"
printf '%s' "${b%-CRITICAL.txt}.txt" | sed 's/\.txt\.txt$/.txt/'
}
count_reports() {
local dir="$1"
printf '%s %s\n' \
"$(find "$dir" -maxdepth 1 -name '*.txt' 2>/dev/null | wc -l)" \
"$(find "$dir" -maxdepth 1 -name '*-CRITICAL.txt' 2>/dev/null | wc -l)"
}
select_report() {
local dir="$1" r
r="$(find "$dir" -maxdepth 1 -name '*-CRITICAL.txt' 2>/dev/null | sort | head -1)"
@@ -87,19 +105,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
@@ -572,6 +587,11 @@ baseline_suppresses() {
}
# Whether `ack` has already walked this report to its end. Recorded in the
# Recorded against the report's name with any -CRITICAL suffix stripped,
# because a report that sheds that suffix later in the same run would
# otherwise carry a marker naming a file that no longer exists - and re-prompt
# forever, which is the failure the marker exists to prevent.
#
# baseline file, alongside the approvals, as `reviewed <report-basename>`:
# the file is already the per-workspace record of what the operator has
# decided, already parsed line-by-line, and already tolerant of verbs it does
@@ -658,37 +678,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"
@@ -791,6 +800,22 @@ hash_paths() {
} | sort
}
# Whether `git status` would show a change to $2 inside repo $1. One call per
# candidate path rather than one listing compared as a set: the candidate list
# is what changed this session, which is small, and --porcelain on a path is
# cheap. A path git does not know about at all (never added, and ignored)
# prints nothing, which is exactly the invisible case.
#
# A repo git refuses to read (not a repository, or a dubious-ownership
# refusal) makes this return false, so every change is reported rather than
# silently downgraded - failing towards the noisier answer, not the quieter
# one.
git_shows_change() {
local repo="$1" path="$2" out
out="$(git -C "$repo" status --porcelain --untracked-files=all -- "$path" 2>/dev/null)" || return 1
[ -n "$out" ]
}
tcb_snapshot() {
# encoded-path<TAB>sha for the sandbox's own trusted computing base, but
# only when $ws IS the sandbox's repo (self-mount) - see AUDIT_REPO_ROOT.
@@ -1025,20 +1050,6 @@ render_finding() {
return 0
}
projects_snapshot() {
# slug<TAB>maxmtime for each immediate subdir of PROJECTS_DIR, sorted by
# slug; maxmtime is 0 if the slug dir has no files.
local dir="$1" d slug mtime
[ -d "$dir" ] || return 0
for d in "$dir"/*/; do
[ -d "$d" ] || continue
slug="$(basename "$d")"
mtime="$(find "$d" -type f -printf '%T@\n' 2>/dev/null | sort -n | tail -1)"
mtime="${mtime%%.*}"
printf '%s\t%s\n' "$slug" "${mtime:-0}"
done | sort
}
statusline_snapshot() {
# path<TAB>sha for $HOME/.claude/statusline.sh, or nothing if this host
# has none - see STATUSLINE_PATH above for why this one path is
@@ -1064,7 +1075,11 @@ case "$cmd" in
{ echo "### staging $STAGE_DIR";
echo "### gitconfig"; git_config_snapshot "$ws";
echo "### paths"; hash_paths "$ws";
echo "### projects"; projects_snapshot "$PROJECTS_DIR";
# Empty since the cross-project finding was retired, and kept
# rather than dropped: it is a range boundary for the paths section
# above, and removing it would make a state file written by one
# version unparseable by the other across an upgrade mid-session.
echo "### projects";
echo "### tcb"; tcb_snapshot "$ws";
echo "### statusline"; statusline_snapshot "$STATUSLINE_PATH"; } > "$state" 2>/dev/null || true
exit 0
@@ -1103,7 +1118,7 @@ case "$cmd" in
new="$(mktemp)"; { echo "### gitconfig"; git_config_snapshot "$ws";
echo "### paths"; hash_paths "$ws";
echo "### projects"; projects_snapshot "$PROJECTS_DIR";
echo "### projects";
echo "### tcb"; tcb_snapshot "$ws";
echo "### statusline"; statusline_snapshot "$STATUSLINE_PATH"; } > "$new" 2>/dev/null || {
echo "dejima audit: could not complete, skipping." >&2; rm -f "$new"; exit 0; }
@@ -1194,38 +1209,55 @@ case "$cmd" in
<(sed -n '/^### paths/,/^### projects/p' "$new" | grep -v '^### ') \
| sed 's/^\t//' | cut -f1 | sort -u )
# projects diffs: a write to another project's ~/.claude/projects
# slug during this session (transcript/memory-poisoning surface).
# "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'*}"
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 '^### ') )
# There is deliberately no cross-project finding here any more. It
# reported a write to another project's ~/.claude/projects slug, and
# a session could once make one: the whole tree was mounted. Since
# #104 it gets its own slugs and nothing else, so the only writes this
# could still see are other host or dejima sessions doing their own
# legitimate work - and with several sessions running it reported all
# of them, on every report, drowning the findings that mattered.
#
# Detection that cannot observe the thing it was built for, and fires
# constantly on the thing it was not, is worse than no detection: it
# trains the operator to skim. The mount is the control now, and
# static.bats pins it.
# tcb diffs: any change under the sandbox's own trusted computing
# base, only populated when this session's workspace IS the
# sandbox's own repo (see tcb_snapshot) - empty/no-op otherwise.
# Bounded at ### statusline (not $p, the last section now) so this
# doesn't also swallow that section's lines.
# Split on whether git can see the change, because that decides
# whether this finding is telling the operator anything.
#
# tcb_snapshot only populates when the workspace IS dejima's own repo,
# so every change here is also a change to a git working tree the
# operator is about to read in `git status` and in the pull request.
# Reporting each one as a CRITICAL to acknowledge duplicated a
# stronger control and made ordinary development unusable.
#
# What git cannot show is the case worth keeping: a file listed in
# .gitignore or .git/info/exclude, or marked assume-unchanged or
# skip-worktree, is live code that runs on the next launch and appears
# nowhere in `git status`. Nothing but this hash sees it, so it stays
# a CRITICAL finding.
tcb_visible=0
while IFS= read -r line; do
# Encoded on arrival, same as the path loop above.
rline="$(dec_id "$line")"
tid="tcb:$rline"; tsha=""; [ -e "$ws/$rline" ] && tsha="$(hash_entry "$ws/$rline")"
baseline_suppresses "$tid" "$tsha" && continue
crit+=("TCB (sandbox self) modified: $line")
if git_shows_change "$ws" "$rline"; then
tcb_visible=$((tcb_visible + 1))
continue
fi
crit+=("TCB (sandbox self) modified, INVISIBLE to git: $line")
finding_ids+=("$tid")
done < <(comm -3 <(sed -n '/^### tcb/,/^### statusline/p' "$state" | grep -v '^### ') \
<(sed -n '/^### tcb/,/^### statusline/p' "$new" | grep -v '^### ') \
| sed 's/^\t//' | cut -f1 | sort -u )
if [ "$tcb_visible" -gt 0 ]; then
info+=("$tcb_visible change(s) under the sandbox's own bin/, hooks/, docker/, test/ - this workspace IS dejima; review them with git")
fi
# statusline diff: ~/.claude/statusline.sh added/changed/removed/
# repointed - see STATUSLINE_PATH above for why this gets its own
@@ -1376,15 +1408,41 @@ case "$cmd" in
exit 0
;;
show)
color_init 1
report_dir="$AUDIT_DIR/$(ws_slug "$ws")"
report="$(select_report "$report_dir")" \
|| { echo "dejima audit: no reports for $ws" >&2; exit 0; }
# Which report, on stderr so stdout stays the report content alone:
# `show` no longer necessarily prints the newest one, and an operator
# cross-referencing the run timestamps in the startup warning needs to
# know which of them they are looking at.
echo "dejima audit: $report" >&2
cat "$report"
# Default is an index, not a dump. `cat` of one report was a wall of
# text that answered neither question an operator actually has - how
# much is waiting, and which of it still needs a decision - and it
# printed findings already acknowledged, so the wall did not even
# shrink as they worked through it.
if [ "${2:-}" = --full ] || [ "${3:-}" = --full ] || [ "${4:-}" = --full ]; then
echo "dejima audit: $report" >&2
cat "$report"
exit 0
fi
read -r n_all n_crit <<< "$(count_reports "$report_dir")"
printf '%s report(s) for %s' "$n_all" "$ws"
[ "$n_crit" -gt 0 ] && printf ', %s%s with unacknowledged CRITICAL%s' \
"$C_CRIT" "$n_crit" "$C_RESET"
printf '\n\n'
# Newest last, so the one `ack` will offer first is the one nearest
# the prompt - and mark it, since select_report prefers CRITICAL over
# recency and "the newest" is the wrong guess.
r=""; line=""; pending=""
while IFS= read -r r; do
pending=$(grep -cE '^(CRITICAL|WARNING|INFO)' "$r" 2>/dev/null || true)
line="$(printf ' %-44s %s finding(s)' "$(basename "$r")" "${pending:-0}")"
case "$r" in
*-CRITICAL.txt) printf '%s%s%s' "$C_CRIT" "$line" "$C_RESET" ;;
*) printf '%s' "$line" ;;
esac
[ "$r" = "$report" ] && printf ' %s<- ack starts here%s' "$C_KEY" "$C_RESET"
printf '\n'
done < <(find "$report_dir" -maxdepth 1 -name '*.txt' 2>/dev/null | sort)
printf '\n%sdejima-audit ack%s walks them; %sdejima-audit show --full%s prints one.\n' \
"$C_KEY" "$C_RESET" "$C_KEY" "$C_RESET"
exit 0
;;
ack)
@@ -1393,6 +1451,17 @@ case "$cmd" in
bf="$BASELINE_DIR/$(ws_slug "$ws")"
report="$(select_report "$report_dir")" \
|| { echo "dejima audit: no reports for $ws" >&2; exit 0; }
read -r n_all n_crit <<< "$(count_reports "$report_dir")"
if [ "$n_all" -gt 1 ]; then
printf '%s reports waiting' "$n_all"
[ "$n_crit" -gt 0 ] && printf ', %s%s with unacknowledged CRITICAL%s' \
"$C_CRIT" "$n_crit" "$C_RESET"
printf '.\n\n'
fi
# One report per pass, every report in one invocation. Walking one and
# exiting meant clearing a backlog took as many runs as there were
# reports, with nothing on screen to say another was behind it.
while :; do
sc="$(sidecar_dir "$report")"
# Removed only at the end, and only for findings actually
# acknowledged - an abort must leave the operator exactly where they
@@ -1411,6 +1480,10 @@ case "$cmd" in
# (or a non-path CRITICAL, which can never be "approved" - see
# ackable below) must keep its suffix and stay eviction-protected.
crit_seen=0; crit_acked=0
# Set when a finding was cleared because its surface no longer exists.
# Like unackable_shown, it means the report has been dealt with as far
# as it can be, so it earns a `reviewed` marker.
removed_cleared=0
# Findings passed over because the baseline already covers them, or
# because this report has been walked before - counted so a run that
# displays nothing can say why instead of looking broken.
@@ -1420,7 +1493,7 @@ case "$cmd" in
# end. Together they decide if a `reviewed` line gets written below.
unackable_shown=0
report_reviewed=0
if baseline_reviewed "$(basename "$report")"; then report_reviewed=1; fi
if baseline_reviewed "$(report_key "$report")"; then report_reviewed=1; fi
# The loop below reads report lines on its own stdin (`< "$report"`),
# so operator keystrokes are read from fd 3 - a copy of the script's
# original stdin taken before that redirection - rather than
@@ -1448,47 +1521,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: "*)
@@ -1522,9 +1576,9 @@ case "$cmd" in
esl="${body#HOST-CODE-EXECUTION statusline.sh changed (runs UNSANDBOXED on the host via settings.json statusLine.command): }"
surface="$esl"
id="statusline:$esl"; rid="statusline:$(dec_id "$esl")" ;;
"TCB (sandbox self) modified: "*)
ackable=1; lbl="TCB (sandbox self) modified: "
etcb="${body#TCB (sandbox self) modified: }"
"TCB (sandbox self) modified, INVISIBLE to git: "*)
ackable=1; lbl="TCB (sandbox self) modified, INVISIBLE to git: "
etcb="${body#TCB (sandbox self) modified, INVISIBLE to git: }"
surface="$etcb"
id="tcb:$etcb"; rid="tcb:$(dec_id "$etcb")" ;;
*) ackable=0 ;;
@@ -1553,6 +1607,18 @@ case "$cmd" in
if [ "$tier" = CRITICAL ]; then crit_acked=$((crit_acked + 1)); fi
continue
fi
# A finding cleared on an earlier pass because its surface was
# gone. Nothing was recorded for it - there is no content to pin -
# so baseline_suppresses cannot cover it, and it is ackable, so
# the un-ackable branch below does not either. Without this it
# re-presents on every run: the same forever-loop the clear was
# meant to end, one step further along.
if [ "$ackable" -eq 1 ] && [ "$report_reviewed" -eq 1 ] \
&& [ -z "$(current_sha_for_id "$rid")" ]; then
already=$((already + 1))
if [ "$tier" = CRITICAL ]; then crit_acked=$((crit_acked + 1)); fi
continue
fi
# An un-ackable finding in a report already walked to the end.
# There is nothing to record for one, so without this it would
# re-prompt on every run forever, gating the exit from `ack` on a
@@ -1619,18 +1685,25 @@ case "$cmd" in
else
sha="$(current_sha_for_id "$rid")"
if [ -z "$sha" ]; then
# Nothing left to pin the approval to: current_sha_for_id
# returning empty means $id no longer exists (deleted
# between compare and ack, or - for gitconfig - the key
# was unset). Writing `safe <id> ` anyway would record a
# baseline entry baseline_suppresses() can never match
# (its own empty-sha guard rejects it as malformed), so
# the surface would stay permanently unsuppressible -
# while still advancing crit_acked and letting a CRITICAL
# report shed its -CRITICAL.txt protection. Refuse
# instead: record nothing, and leave the report's
# protection exactly as it was.
echo " cannot approve: $id no longer exists"
# The surface is gone - deleted since compare, or a
# gitconfig key unset. There is nothing to pin an
# approval to, and `safe <id> ` with an empty sha would
# record a baseline line baseline_suppresses() can never
# match, leaving the surface permanently unsuppressible.
#
# So nothing is recorded - but the finding is CLEARED,
# because a surface that does not exist does not run.
# Refusing outright is what made one deleted file pin
# the audit forever: crit_acked never reached crit_seen,
# the report kept -CRITICAL.txt, and select_report picks
# CRITICAL first, so every other report stayed hidden
# behind a finding that could not be resolved by any
# means. If the surface comes back, it comes back as a
# new finding, which is the honest outcome.
echo " ${C_OK}cleared${C_RESET}: $(sanitize "$id") no longer exists"
removed_cleared=1
[ "$tier" = CRITICAL ] && crit_acked=$((crit_acked + 1))
sc_done+=("$rid")
else
pending+=("safe $id $sha")
sc_done+=("$rid")
@@ -1651,8 +1724,9 @@ case "$cmd" in
# Written once the report has been walked to its end, and only when
# it actually showed something that cannot be acknowledged - there is
# nothing else to stop those from re-prompting forever.
if [ "$unackable_shown" -eq 1 ] && [ "$report_reviewed" -eq 0 ]; then
pending+=("reviewed $(basename "$report")")
if { [ "$unackable_shown" -eq 1 ] || [ "$removed_cleared" -eq 1 ]; } \
&& [ "$report_reviewed" -eq 0 ]; then
pending+=("reviewed $(report_key "$report")")
fi
if [ "${#pending[@]}" -gt 0 ]; then
mkdir -p "$BASELINE_DIR"
@@ -1690,10 +1764,33 @@ case "$cmd" in
else
echo "dejima audit: nothing recorded."
fi
# Next report, if there is one. Asked rather than assumed: an operator
# who has decided enough for now should not have to abort out of a
# report they have not read to stop.
next_report="$(select_report "$report_dir")" || next_report=""
if [ -n "$next_report" ] && [ "$next_report" != "$report" ]; then
read -r n_all n_crit <<< "$(count_reports "$report_dir")"
printf '\n%s report(s) still waiting' "$n_all"
[ "$n_crit" -gt 0 ] && printf ', %s%s with unacknowledged CRITICAL%s' \
"$C_CRIT" "$n_crit" "$C_RESET"
printf '. Continue? [%sy%s/%sn%s] ' "$C_KEY" "$C_RESET" "$C_KEY" "$C_RESET"
# fd 3, the same duplicate of the original stdin the finding
# prompt reads: the report is this loop's stdin, and /dev/tty is
# not the caller's input when `ack` is driven by a pipe.
{ read -r answer <&3; } 2>/dev/null || answer=n
printf '\n'
case "$answer" in
y|Y|"") report="$next_report"; sc="$(sidecar_dir "$report")"; continue ;;
esac
fi
break
done
exit 0
;;
*)
echo "usage: dejima-audit {snapshot|compare|show|ack} [workspace] [state-file]" >&2
echo " show [--full] index of waiting reports; --full prints one" >&2
echo " workspace defaults to the enclosing git repository, else the current directory" >&2
exit 2
;;
+16 -19
View File
@@ -22,30 +22,24 @@
# 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'
core.harness provider:harness claude-code
core.harness provider:harness
core.forge provider:forge none
core.workflow provider:workflow local-only
core.command identifier claude
@@ -61,6 +55,9 @@ credentials.proxy bool on
credentials.transport enum:tls|loopback tls
logging.loki_url ?url
logging.conversation bool off
epitaph.enabled bool on
epitaph.dir path ~/.local/share/dejima/epitaphs
epitaph.loki bool off
audit.dir path ~/.local/share/dejima/audit
audit.baselines_dir path ~/.local/share/dejima/baselines
audit.keep int:1:1000 10
@@ -313,7 +310,7 @@ _config_extra_section_known() { return 1; }
_config_section_known() {
case "$1" in
core|github|mounts|credentials|logging|audit|remote-access) return 0 ;;
core|github|mounts|credentials|logging|audit|remote-access|epitaph) return 0 ;;
*) _config_extra_section_known "$1" ;;
esac
}
+164
View File
@@ -0,0 +1,164 @@
#!/bin/bash
# Epitaph: what a session leaves behind when its context dies.
#
# Claude Code writes a summary each time a session is compacted. It is the
# only durable account of what a long session worked out, and it goes with
# the container. This keeps it.
#
# Runs entirely on the host, from bin/dejima's exit trap. Nothing in the
# container writes here and the store is never mounted, so a session cannot
# read what earlier sessions concluded or edit what this one is about to
# record - the same arrangement as the audit store, and for the same reason
# (#104: anything a session writes and a later one reads is a channel into
# that later session's premises).
#
# The transcript IS session-writable, so a summary is only as honest as the
# file it came from. This preserves what was recorded, it does not attest to
# it.
#
# Usage: dejima-epitaph save <workspace> [transcripts-dir]
set -uo pipefail
# shellcheck source=dejima-provider-lib.sh
source "$(dirname "$(readlink -f "$0")")/dejima-provider-lib.sh"
# shellcheck source=dejima-slug-lib.sh
source "$(dirname "$(readlink -f "$0")")/dejima-slug-lib.sh"
config_validate || exit 1
cmd="${1:-}"; ws_arg="${2:-}"; transcripts="${3:-}"
[ "$(config_get epitaph enabled)" = "on" ] || exit 0
STORE="$(config_get epitaph dir)"
[ -n "$transcripts" ] || transcripts="$(config_get harness.claude-code projects_dir)"
ws="$ws_arg"
[ -n "$ws" ] || ws="$(pwd)"
ws="$(cd "$ws" 2>/dev/null && pwd)" || exit 0
case "$cmd" in
save) ;;
*) echo "usage: dejima-epitaph save <workspace> [transcripts-dir]" >&2; exit 2 ;;
esac
# One file per summary, named for the moment it was written, so a directory
# listing is a timeline. Keyed on the summary record's own uuid: `save` runs
# on every exit, and a session compacted three times has three epitaphs, two
# of which an earlier run already wrote.
mkdir -p "$STORE" 2>/dev/null || exit 0
chmod 700 "$STORE" 2>/dev/null || true
saved=0
while IFS= read -r slug; do
[ -n "$slug" ] || continue
dir="$transcripts/$slug"
[ -d "$dir" ] || continue
out="$STORE/$slug"
mkdir -p "$out" 2>/dev/null || continue
for f in "$dir"/*.jsonl; do
[ -f "$f" ] || continue
# python3 is already a hard dependency (install.sh refuses without
# it), and this is line-delimited JSON with embedded newlines in the
# payload - not something to take apart with sed.
n="$(python3 - "$f" "$out" <<'PY'
import json, os, sys
path, out = sys.argv[1], sys.argv[2]
pending = None # metadata from the compact_boundary that precedes a summary
written = 0
for line in open(path, errors="replace"):
line = line.strip()
if not line or not line.startswith("{"):
continue
try:
rec = json.loads(line)
except ValueError:
continue
if rec.get("subtype") == "compact_boundary":
pending = rec.get("compactMetadata") or {}
continue
if not rec.get("isCompactSummary"):
continue
body = rec.get("message", {}).get("content")
if isinstance(body, list):
body = "\n".join(p.get("text", "") for p in body if isinstance(p, dict))
if not isinstance(body, str) or not body.strip():
pending = None
continue
uid = rec.get("uuid") or ""
stamp = (rec.get("timestamp") or "").replace(":", "").replace("-", "")[:15]
name = "%s-%s.md" % (stamp or "unknown", uid[:8] or "nouuid")
dest = os.path.join(out, name)
if os.path.exists(dest):
pending = None
continue
meta = pending or {}
head = [
"# Epitaph",
"",
"- session: %s" % (rec.get("sessionId") or "unknown"),
"- at: %s" % (rec.get("timestamp") or "unknown"),
"- trigger: %s" % (meta.get("trigger") or "unknown"),
]
if meta.get("preTokens") is not None:
head.append("- tokens: %s before, %s after"
% (meta.get("preTokens"), meta.get("postTokens")))
head += [
"",
"Written by the session and preserved verbatim. The transcript it came",
"from is writable from inside the container, so this records what was",
"there rather than vouching for it.",
"", "---", "",
]
tmp = dest + ".part"
with open(tmp, "w") as fh:
fh.write("\n".join(head) + body.rstrip() + "\n")
os.chmod(tmp, 0o600)
os.rename(tmp, dest)
written += 1
pending = None
print(written)
PY
)" || n=0
saved=$((saved + ${n:-0}))
done
done < <(workspace_slugs_sorted "$ws")
[ "$saved" -gt 0 ] || exit 0
echo "dejima: wrote $saved epitaph(s) to $STORE" >&2
# Optional, and off by default: the store is the durable copy, this is for
# operators who already centralise session logs. Host-side rather than
# through the container's log-shipper, so the same "never in the session's
# reach" property holds for the shipping path too.
[ "$(config_get epitaph loki)" = "on" ] || exit 0
loki="$(config_get logging loki_url)"
[ -n "$loki" ] || exit 0
while IFS= read -r slug; do
[ -n "$slug" ] || continue
for f in "$STORE/$slug"/*.md; do
[ -f "$f" ] && [ ! -f "$f.shipped" ] || continue
if python3 - "$loki" "$slug" "$f" <<'PY'
import json, sys, time, urllib.request
loki, slug, path = sys.argv[1], sys.argv[2], sys.argv[3]
body = json.dumps({"streams": [{
"stream": {"job": "dejima-epitaph", "project": slug},
"values": [[str(time.time_ns()), open(path, errors="replace").read()]],
}]}).encode()
req = urllib.request.Request(loki.rstrip("/") + "/loki/api/v1/push", data=body,
headers={"Content-Type": "application/json"})
urllib.request.urlopen(req, timeout=10).read()
PY
then
: > "$f.shipped"
fi
done
done < <(workspace_slugs_sorted "$ws")
exit 0
+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
+53 -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"
@@ -371,6 +360,10 @@ _provider_keys_cached() {
# here is a per-line fork thirty times over.
_provider_keys_field() {
local axis="$1" name="$2" dir file line key value="" seen=0 type n=0
# An unselected axis contributes no keys - a different question from
# whether a selection is valid. Without this, --print-config prints
# "invalid harness name:" and fails at the thing it is for.
[ -n "$name" ] || return 1
dir="$(provider_dir "$axis" "$name")" || return 1
file="$dir/$(_provider_manifest_name "$axis")"
[ -f "$file" ] || return 1
@@ -726,6 +719,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 +751,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 +1041,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"
+28
View File
@@ -51,3 +51,31 @@ workspace_slugs() {
# 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)"
+15 -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
@@ -189,7 +185,7 @@ RUN mkdir /commandhistory && \
# 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 && \
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
@@ -259,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 -31
View File
@@ -111,39 +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 deliberately absent here. It holds every project's
# transcripts and agent memory, and mounting the tree gave a session
# write access to all of it - enough on its own to plant text a future
# session in another project reads as established background (#104).
# bin/dejima mounts the launching workspace's own slug and its live
# worktree children instead, one -v each, so the rest are absent rather
# than merely unwritable. The directory holding them stays
# container-local, which is what lets Claude Code create a slug it does
# not find. See bin/dejima-slug-lib.sh for which slugs those are and
# why the set comes from real worktree paths.
- ${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
@@ -239,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
+24 -30
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.
+69 -24
View File
@@ -18,7 +18,7 @@ what a given launch will actually use.
| Key | Default | Purpose |
| --- | --- | --- |
| `[core] harness` | `claude-code` | agent harness the container runs |
| `[core] harness` | *(none - must be set)* | agent harness the container runs; refused if unset, so a later default cannot change your selection |
| `[core] forge` | `none` | forge integration: `gitea` (`gitea-pr`/`gitea-issue`), `github` (`gh`, opens `ssh.github.com:22` for push), or `none` (no forge at all) |
| `[core] workflow` | `local-only` | workflow `sandbox-context.sh` tells the agent to follow: `worktree-pr` (push + open a PR), `branch-push` (push, stop at the branch), or `local-only` (commit locally, push nothing) |
| `[core] command` | `claude` | container's main process (e.g. `nvim`), also settable per launch with `--cmd` |
@@ -39,6 +39,9 @@ what a given launch will actually use.
| `[credentials] proxy` | `on` | host-side credential proxy; `off` mounts the real OAuth credential into the container directly, as before this feature |
| `[credentials] transport` | `tls` | set to `loopback` to fall back to a plaintext forwarder with an explicit `ANTHROPIC_BASE_URL`, keeping the credential out of the container but reinstating the base-URL fingerprint: for when certificate pinning breaks the default TLS path (see [security notes](docs/security.md)) |
| `[logging] loki_url` | unset | ship DNS/firewall logs to a Loki endpoint |
| `[epitaph] enabled` | `on` | keep the summary Claude Code writes when a session compacts |
| `[epitaph] dir` | `~/.local/share/dejima/epitaphs` | where they are kept; never mounted into a container |
| `[epitaph] loki` | `off` | also push each epitaph to `[logging] loki_url`, host-side |
| `[logging] conversation` | `off` | opt-in: also ship session transcripts to Loki (needs `loki_url`); see "Conversation logging (opt-in)" in docs/security.md |
| `[audit] dir` | `~/.local/share/dejima/audit` | where exit-time audit findings are stored |
| `[audit] baselines_dir` | `~/.local/share/dejima/baselines` | where audit baselines are stored |
@@ -93,32 +96,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. That
has two consequences:
**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
@@ -136,12 +134,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 +326,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):
@@ -366,6 +378,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 +486,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
+18 -1
View File
@@ -34,7 +34,8 @@ 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-epitaph` | Host-side: keeps the summary a session leaves when its context is compacted away, in a store nothing mounts. Run from `bin/dejima`'s exit trap |
| `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-epitaph` (whose transcripts to keep), 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 |
@@ -111,6 +112,22 @@ socket withheld from the confined job, granted to the privileged job, and
the egress each is allowed) - see [docs/ci.md](ci.md). It skips outright
unless `DEJIMA_CI_TIER` is set, so it has nothing to say when run locally.
### Branches
Pull requests target `development`, which merges to `main` periodically, so
`main` stays releasable while work lands quickly. `gitea-pr` defaults its
base to the repository's default branch, so pass `--base development`.
CI needs no change for this: `.gitea/workflows/tests.yml` triggers on
`pull_request_target` with no branch filter, so it already runs whatever the
base is.
Nothing *enforces* the target. `git-push-guard.sh` blocks direct pushes to
the branches a workflow names, and that list is workflow-wide rather than
per-project - adding `development` would impose it on every dejima user,
most of whom have no such branch. So this is a convention with a check
attached: read what `gitea-pr` printed.
### Running the tests
```sh
+16 -25
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
@@ -353,7 +344,7 @@ resolved value is already correct even when the operator never wrote it.
| workflow | `worktree-pr` | worktree → push → PR, stops at the PR |
| workflow | `branch-push` | worktree → push, stops at the branch |
| workflow | `local-only` | **default** - commits locally, pushes nothing |
| harness | `claude-code` | **default**, and the only harness today |
| harness | `claude-code` | the only harness today, and **not a default** - `[core] harness` must be set |
**The shipped defaults are deliberately the non-pushing pair**
(`forge = none`, `workflow = local-only`). A fresh install has no forge host
+26 -33
View File
@@ -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`
+225 -160
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
@@ -212,27 +218,34 @@ hands to whichever session starts against it. See
appears in `.git/config` directly), `!`-shell aliases - plus content
changes under the workspace's `.claude/`, `.mcp.json`, `.envrc`,
`.vscode/`, and `.git/hooks/` (symlinks included - a repointed symlink is
hashed by its target, not just its content), plus cross-project writes
under `~/.claude/projects` (see below). When the mounted workspace *is*
this repo's own sandbox itself (see the trusted-computing-base note
further below), it also diffs content hashes of the sandbox's own trusted
computing base - `bin/`, `hooks/`, `docker/`, `install.sh`,
`test/` - under that workspace. It's **advisory only**: it fires *after*
the session has already ended, so a report of a planted `core.hooksPath`
or hook file is something to read and clean up *before* the next `git`
or `claude` in that repo, not something that was blocked. It's silent when nothing
changed, and never affects the session's own exit code even if it can't
complete. It doesn't cover `[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.
hashed by its target, not just its content). 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 +259,22 @@ 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.
A finding whose file has since been deleted is **cleared** rather than
approved: there is no content left to pin an approval to, and a surface
that does not exist does not run. Nothing is recorded, so if it comes back
it comes back as a new finding. Refusing instead - the old behaviour - left
the report unresolvable, and because `ack` opens CRITICAL reports first it
hid every other report behind that one.
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.
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 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 +288,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,73 +315,59 @@ 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 sit 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 as a tree. A session gets the workspace's own slug
@@ -391,20 +392,44 @@ hands to whichever session starts against it. See
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** as well. The exit-time audit
runs host-side over the whole tree, so narrowing the mount does not
narrow it; it now covers what the mount cannot reach, which is
concurrent *host* sessions. Residual: the workspace's own transcripts
and 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. The
time-window caveat above applies most sharply here, because on the host
`~/.claude/projects` is a single
directory shared by *every* Claude Code session, not scoped to the one
being audited - so an entirely innocent concurrent session working on
some unrelated project can still produce a `CROSS-PROJECT` finding that
reads like poisoning and isn't.
Residual: the workspace's own transcripts and memory stay fully writable
throughout, since Claude Code needs that for host-side `claude --resume`.
There is no longer a `CROSS-PROJECT` finding for this. It existed when the
whole tree was mounted and a session really could write another project's
slug. Now that it cannot, the only writes the audit could still see there
are other sessions doing their own work - and on a host running several at
once it reported all of them, on every report. Detection that cannot
observe what it was built for, and fires constantly on what it was not,
trains an operator to skim. The mount is the control, and `static.bats`
pins it.
- `~/.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.
- Epitaphs - the summary a session leaves when its context is compacted away -
are kept host-side in `[epitaph] dir`, and that directory is never mounted. A session can neither read what earlier
sessions concluded nor edit what this one is about to record, which is the
same arrangement as the audit store and for the same reason. The transcript
they are extracted from *is* session-writable, so a summary records what was
there rather than vouching for it - and nothing loads one back into a later
session, deliberately: that would be the channel #104 closed, rebuilt.
- 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
@@ -418,51 +443,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 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
@@ -516,13 +535,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
@@ -707,6 +734,18 @@ hands to whichever session starts against it. See
changes takes effect the *next* time `dejima` itself runs,
not the current one.
The report splits on whether git can see the change. tcb_snapshot only
populates when the workspace IS dejima's own repo, so an ordinary change
there is already in the working tree you are about to read in `git status`
and in the pull request - reporting each one as a CRITICAL to acknowledge
duplicated a stronger control and made developing dejima itself unusable.
Those are counted in one INFO line instead.
A change git *cannot* see stays a CRITICAL finding, because nothing else
would catch it: a file in `.gitignore` or `.git/info/exclude`, or one
marked `assume-unchanged` or `skip-worktree`, is live code that runs on the
next launch and appears nowhere in `git status`.
### Declaration files are session-writable
A project's `.dejima/` declaration files sit inside the project mount, which
@@ -970,6 +1009,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
+5 -3
View File
@@ -182,6 +182,7 @@ link_bin dejima-project
# the startup warning about unacknowledged CRITICAL findings tells the
# operator to run `dejima-audit ack`, which is only true once it is on PATH.
link_bin dejima-audit
link_bin dejima-epitaph
# Remote access (opt-in, see docs/remote-access.md). All three are inert
# without [remote-access] enabled = on - they refuse to run at all - so
@@ -235,9 +236,9 @@ else
printf '# dejima configuration. See README.md.\n'
printf '# Every key below is shown at its default; uncomment to change one.\n'
printf '#\n'
printf '# [core] forge and [core] workflow are the exception: they are written\n'
printf '# out uncommented, so this file states what it selects rather than\n'
printf '# inheriting whatever a future release defaults to.\n'
printf '# [core] harness, forge and workflow are written out uncommented, so\n'
printf '# this file states what it selects rather than inheriting a future\n'
printf '# default. harness has none at all - dejima refuses to start unset.\n'
printf '#\n'
printf '# Those two decide whether this sandbox pushes at all. The shipped\n'
printf '# default is forge = none with workflow = local-only: the agent\n'
@@ -261,6 +262,7 @@ else
case "$_key" in
forge.gitea.ssh_host) _seed="${GITEA_SSH_HOST:-}" ;;
forge.gitea.api) _seed="${GITEA_API:-}" ;;
core.harness) _seed="claude-code" ;;
core.forge) _seed="$_starter_forge" ;;
core.workflow) _seed="$_starter_workflow" ;;
core.command) _seed="${SANDBOX_CMD:-}" ;;
+136 -98
View File
@@ -143,16 +143,24 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
[ "$count" -eq 1 ]
}
@test "flags a cross-project write under ~/.claude/projects" {
# The cross-project finding is gone, and these are its replacement: writes
# under ~/.claude/projects must produce NOTHING now, whichever slug they land
# in. It reported a poisoning route a session could once take - the whole
# tree was mounted - and since #104 a session gets its own slugs only, so
# every remaining write it could see belongs to some other session doing its
# own work. With several sessions running it reported all of them on every
# report.
@test "a write to another project's slug is not reported" {
mkdir -p "$PROJDIR/-some-other-project"
echo x > "$PROJDIR/-some-other-project/file1"
snap
echo y > "$PROJDIR/-some-other-project/file2"; touch -d '+1 hour' "$PROJDIR/-some-other-project/file2"
run cmp_
[[ "$output" == *"CROSS-PROJECT history/memory write: -some-other-project"* ]]
[[ "$output" != *"CROSS-PROJECT"* ]]
[[ "$output" != *"-some-other-project"* ]]
}
@test "does not flag the current project's own projects slug" {
@test "a write to this project's own slug is not reported either" {
mkdir -p "$PROJDIR/$CURRENT_SLUG"
echo x > "$PROJDIR/$CURRENT_SLUG/file1"
snap
@@ -161,34 +169,6 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
[[ "$output" != *"CROSS-PROJECT"* ]]
}
@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
echo y > "$PROJDIR/${CURRENT_SLUG}--worktrees-featurex/file2"; touch -d '+1 hour' "$PROJDIR/${CURRENT_SLUG}--worktrees-featurex/file2"
run cmp_
[[ "$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"
@@ -230,7 +210,11 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
[[ "$output" == *".claude/evil"* ]]
}
@test "TCB: flags a change under bin/ when workspace is the audit's own repo root" {
@test "TCB: a change git can see is reported as info, not as a CRITICAL to ack" {
# tcb_snapshot only populates when the workspace IS dejima's own repo, so
# every change here is also in the git working tree the operator is about
# to read. One CRITICAL per file duplicated `git status` and made
# developing dejima itself unusable.
mkdir -p "$WS/bin"
echo x > "$WS/bin/x"
echo x > "$WS/install.sh"
@@ -238,7 +222,27 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
snap
echo changed > "$WS/bin/x"
run cmp_
[[ "$output" == *"TCB (sandbox self) modified: bin/x"* ]]
[[ "$output" != *"TCB (sandbox self) modified"* ]] # nothing to acknowledge
# Recorded as INFO in the report rather than shouted on stderr: the
# startup warning already said the TCB is writable this session.
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
run bash -c "cat '$AUDITDIR/$slug'/*.txt"
[[ "$output" == *"this workspace IS dejima"* ]]
}
@test "TCB: a change git CANNOT see is still a CRITICAL finding" {
# The case that justifies keeping the check at all. A file listed in
# .git/info/exclude is live code that runs on the next launch and appears
# nowhere in `git status`, so nothing but this hash sees it.
mkdir -p "$WS/bin" "$WS/.git/info"
echo x > "$WS/bin/hidden"
echo x > "$WS/install.sh"
echo 'bin/hidden' > "$WS/.git/info/exclude"
export DEJIMA_AUDIT_REPO_ROOT="$(readlink -f "$WS")"
snap
echo changed > "$WS/bin/hidden"
run cmp_
[[ "$output" == *"TCB (sandbox self) modified, INVISIBLE to git: bin/hidden"* ]]
}
@test "TCB: does not flag changes when the workspace is not the audit's own repo root" {
@@ -611,14 +615,56 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
[ "$result" -ne 124 ]
}
@test "show prints the only report there is, and names it" {
# One `ack` run used to walk one report and exit, saying nothing about the
# rest, so clearing a backlog took as many runs as there were reports and
# looked like the tool was stuck on the first one.
@test "ack walks every report in one run when told to continue" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
snap; mkdir -p "$WS/.claude"; echo '{}' > "$WS/.claude/settings.json"; cmp_ >/dev/null 2>&1
[ "$(find "$AUDITDIR/$slug" -name '*.txt' | wc -l)" -eq 2 ]
# approve, continue, approve
run bash -c "printf 'a\ny\na\n' | '$AUDIT' ack '$WS'"
[ "$status" -eq 0 ]
[[ "$output" == *"2 reports waiting"* ]]
[[ "$output" == *".envrc"* ]]
[[ "$output" == *"settings.json"* ]] # the second report, same run
}
@test "ack stops at one report when told not to continue" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
snap; mkdir -p "$WS/.claude"; echo '{}' > "$WS/.claude/settings.json"; cmp_ >/dev/null 2>&1
run bash -c "printf 'a\nn\n' | '$AUDIT' ack '$WS'"
[ "$status" -eq 0 ]
[[ "$output" == *"still waiting"* ]] # said what is left behind
[[ "$output" != *"settings.json"* ]] # and did not open it
}
@test "show indexes the reports rather than dumping one" {
# It used to `cat` a single report: a wall of text that answered neither
# question an operator has - how much is waiting, and what still needs a
# decision - and included findings already acknowledged, so it did not
# even shrink as they worked through it.
snap
echo 'export X=1' > "$WS/.envrc"
cmp_ >/dev/null 2>&1
run bash -c "'$AUDIT' show '$WS' 2>&1"
[ "$status" -eq 0 ]
[[ "$output" == *"1 report(s)"* ]]
[[ "$output" == *"-CRITICAL.txt"* ]] # names it
[[ "$output" == *"ack starts here"* ]] # and which one ack will open
[[ "$output" != *".envrc"* ]] # without the contents
}
@test "show --full prints the report ack would open" {
snap
echo 'export X=1' > "$WS/.envrc"
cmp_ >/dev/null 2>&1
run bash -c "'$AUDIT' show '$WS' --full 2>&1"
[ "$status" -eq 0 ]
[[ "$output" == *".envrc"* ]]
[[ "$output" == *"-CRITICAL.txt"* ]] # said which report it printed
[[ "$output" == *"-CRITICAL.txt"* ]]
}
@test "show and ack agree on which report they mean" {
@@ -638,19 +684,20 @@ WARNING changed workspace file: .vscode/settings.json
REPORT
run "$AUDIT" show "$WS"
[ "$status" -eq 0 ]
[[ "$output" == *".envrc"* ]] # the stale CRITICAL report...
[[ "$output" == *"-CRITICAL.txt"* ]] # the stale CRITICAL report...
[[ "$output" != *"settings.json"* ]] # ...not the newer clean one
}
@test "show falls back to the newest report when no CRITICAL one is pending" {
@test "show marks the newest report as the one ack will open, absent a CRITICAL" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
report_dir="$AUDITDIR/$slug"; mkdir -p "$report_dir"
echo 'older' > "$report_dir/2020-01-01-000000.txt"
echo 'newer' > "$report_dir/2026-01-01-000001.txt"
run "$AUDIT" show "$WS"
[ "$status" -eq 0 ]
[[ "$output" == *"newer"* ]]
[[ "$output" != *"older"* ]]
[[ "$output" == *"2 report(s)"* ]] # both listed, not just one
[[ "$output" == *"2020-01-01-000000.txt"* ]]
[[ "$output" == *"2026-01-01-000001.txt"*"ack starts here"* ]]
}
@test "ack presents a stale unacknowledged CRITICAL report before a newer clean one" {
@@ -791,39 +838,32 @@ REPORT
[[ "$output" != *"-CRITICAL.txt"* ]]
}
@test "a finding that cannot be acknowledged is presented once, not on every run" {
# There is nothing to record for one, so without a marker it re-prompted
# forever and `ack` could never reach a state of having nothing to ask.
@test "a cleared finding is presented once, not on every run" {
# There is nothing to record for a surface that no longer exists, so
# without a `reviewed` marker the report re-prompted forever and `ack`
# could never reach a state of having nothing to ask.
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
mkdir -p "$PROJDIR/-some-other-project"
echo x > "$PROJDIR/-some-other-project/file1"
snap
echo y > "$PROJDIR/-some-other-project/file2"
touch -d '+1 hour' "$PROJDIR/-some-other-project/file2"
cmp_ >/dev/null 2>&1
run bash -c "printf 's\n' | '$AUDIT' ack '$WS'"
[[ "$output" == *"CROSS-PROJECT"* ]]
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
rm -f "$WS/.envrc"
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[[ "$output" == *".envrc"* ]]
grep -q '^reviewed ' "$BASEDIR/$slug"
run bash -c "printf 's\n' | '$AUDIT' ack '$WS'"
[[ "$output" != *"CROSS-PROJECT"* ]]
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[[ "$output" != *".envrc"* ]]
}
@test "quitting a report does not mark it reviewed" {
# The marker means "walked to the end". An abort discards everything
# else, and must discard this too or one q would silence the report.
# The marker means "walked to the end". An abort discards everything else,
# and must discard this too or one q would silence the report.
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
mkdir -p "$PROJDIR/-some-other-project"
echo x > "$PROJDIR/-some-other-project/file1"
snap
echo y > "$PROJDIR/-some-other-project/file2"
touch -d '+1 hour' "$PROJDIR/-some-other-project/file2"
cmp_ >/dev/null 2>&1
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
rm -f "$WS/.envrc"
run bash -c "printf 'q\n' | '$AUDIT' ack '$WS'"
! grep -q '^reviewed ' "$BASEDIR/$slug" 2>/dev/null
run bash -c "printf 's\n' | '$AUDIT' ack '$WS'"
[[ "$output" == *"CROSS-PROJECT"* ]]
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[[ "$output" == *".envrc"* ]]
}
@test "a WARNING git config finding can be acknowledged, both ways (#45)" {
@@ -873,24 +913,6 @@ REPORT
[[ "$output" != *"-CRITICAL.txt"* ]]
}
@test "ack does not write an unmatchable entry for a finding with no id" {
# Was written against a changed git config, which #45 made ackable. The
# invariant is about findings that have no id to record at all, so it now
# uses the one class that still has none: a cross-project write, whose
# subject is another project's slug rather than anything this audit can
# hash and pin.
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
mkdir -p "$PROJDIR/-some-other-project"
echo x > "$PROJDIR/-some-other-project/file1"
snap
echo y > "$PROJDIR/-some-other-project/file2"
touch -d '+1 hour' "$PROJDIR/-some-other-project/file2"
cmp_ >/dev/null 2>&1
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[[ "$output" == *"cannot"* ]]
! grep -qE '^(safe|spam) ' "$BASEDIR/$slug" 2>/dev/null
}
@test "an ack-approved finding is suppressed on the next compare (id round-trips through the baseline)" {
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
@@ -919,24 +941,37 @@ REPORT
# so the report must stay eviction-protected
}
@test "ack refuses to approve a finding whose path no longer exists, and the CRITICAL suffix survives" {
# A since-deleted path has no content left to pin a `safe` approval to -
# writing one anyway (the old behaviour) produced a baseline entry
# baseline_suppresses() can never match (its own empty-sha guard rejects
# it as malformed), so the surface stayed permanently unsuppressible -
# while `a` still advanced crit_acked and let the report shed its
# -CRITICAL.txt protection for good. Refusing outright keeps both the
# baseline and the report's protection honest.
@test "a finding whose path no longer exists is cleared, not refused forever" {
# Refusing outright kept the baseline honest - there is no content left to
# pin a `safe` entry to - but it also meant crit_acked never reached
# crit_seen, so the report kept -CRITICAL.txt. select_report prefers
# CRITICAL, so one deleted file pinned `ack` to a report that no decision
# could resolve and hid every other report behind it, permanently.
#
# Still nothing written to the baseline: an entry with an empty sha can
# never match. The finding is simply cleared, because a surface that does
# not exist does not run. If it comes back, it comes back as new.
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
rm -f "$WS/.envrc" # gone by the time ack runs
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[ "$status" -eq 0 ]
[[ "$output" == *"cannot approve: .envrc no longer exists"* ]]
[[ "$output" == *"nothing recorded"* ]]
[ ! -f "$BASEDIR/$slug" ] # nothing written to the baseline at all
[[ "$output" == *"cleared"* ]]
[[ "$output" == *".envrc"* ]]
! grep -qE '^(safe|spam) ' "$BASEDIR/$slug" 2>/dev/null # nothing unmatchable recorded
grep -q '^reviewed ' "$BASEDIR/$slug" # but the report is done with
run bash -c "ls '$AUDITDIR/$slug'"
[[ "$output" == *"-CRITICAL.txt"* ]] # unacknowledged CRITICAL keeps its protection
[[ "$output" != *"-CRITICAL.txt"* ]] # and no longer blocks the queue
}
# The other half: clearing is not a way to launder a finding whose surface is
# still there. Only an absent one can be cleared.
@test "a finding whose path still exists is approved against its content, not cleared" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[[ "$output" != *"cleared"* ]]
grep -q '^safe .envrc ' "$BASEDIR/$slug"
}
@test "a workspace path containing its own ': ' cannot hijack ack's id derivation" {
@@ -1079,7 +1114,10 @@ REPORT
@test "ack records a safe tcb entry whose id round-trips and suppresses the next compare" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
mkdir -p "$WS/bin"; echo x > "$WS/bin/x"; echo x > "$WS/install.sh"
# Hidden from git, which is the only shape that is still a finding.
mkdir -p "$WS/bin" "$WS/.git/info"
echo x > "$WS/bin/x"; echo x > "$WS/install.sh"
echo 'bin/x' > "$WS/.git/info/exclude"
export DEJIMA_AUDIT_REPO_ROOT="$(readlink -f "$WS")"
snap
echo changed > "$WS/bin/x"
@@ -1137,7 +1175,7 @@ REPORT
cmp_ >/dev/null
run bash -c "cd '$WS' && '$AUDIT' show 2>/dev/null"
[ "$status" -eq 0 ]
[[ "$output" == *".envrc"* ]]
[[ "$output" == *"report(s) for $WS"* ]]
}
@test "show finds the workspace when run from a subdirectory" {
@@ -1147,7 +1185,7 @@ REPORT
cmp_ >/dev/null
run bash -c "cd '$WS/src/deep' && '$AUDIT' show 2>/dev/null"
[ "$status" -eq 0 ]
[[ "$output" == *".envrc"* ]]
[[ "$output" == *"report(s) for $WS"* ]]
}
@test "show finds the parent repo when run from a linked worktree" {
@@ -1162,7 +1200,7 @@ REPORT
cmp_ >/dev/null
run bash -c "cd '$WS/.worktrees/wt' && '$AUDIT' show 2>/dev/null"
[ "$status" -eq 0 ]
[[ "$output" == *".envrc"* ]]
[[ "$output" == *"report(s) for $WS"* ]]
}
@test "show finds the parent repo's report even after the worktree itself was snapshotted" {
@@ -1177,7 +1215,7 @@ REPORT
"$AUDIT" snapshot "$WS/.worktrees/wt" "$(mktemp)"
run bash -c "cd '$WS/.worktrees/wt' && '$AUDIT' show 2>/dev/null"
[ "$status" -eq 0 ]
[[ "$output" == *".envrc"* ]]
[[ "$output" == *"report(s) for $WS"* ]]
}
@test "show names the path it resolved when that workspace has no reports" {
@@ -1198,7 +1236,7 @@ REPORT
other="$(mktemp -d)"; git -C "$other" init -q
run bash -c "cd '$other' && '$AUDIT' show '$WS' 2>/dev/null"
[ "$status" -eq 0 ]
[[ "$output" == *".envrc"* ]]
[[ "$output" == *"report(s) for $WS"* ]]
rm -rf "$other"
}
+363 -19
View File
@@ -188,6 +188,16 @@ EOF
write_config() {
printf '%s\n' "$@" > "$CONFIG_DIR/config"
# A launch is refused without a harness, so every fixture needs one. Its
# own [core] section, which is legal and resolves the same; a test that
# means to omit it writes the file directly.
# A key assignment, not the word anywhere in the arguments: a [mounts]
# path containing "harness" would otherwise suppress the seeding.
local _a _has=0
for _a in "$@"; do
[[ "$_a" =~ ^[[:space:]]*harness[[:space:]]*= ]] && _has=1
done
[ "$_has" -eq 1 ] || printf '[core]\nharness = claude-code\n' >> "$CONFIG_DIR/config"
}
@test "config_get returns the schema default when the key is absent" {
@@ -1045,24 +1055,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 +1136,273 @@ 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"
}
# No default on purpose: a release changing it would move an operator who
# never chose to a different harness on upgrade.
@test "a config with no [core] harness is refused, and says what to add" {
_rw_setup
workspace="$(mktemp -d)"
# Written directly, not through write_config, which seeds a harness for
# every other fixture precisely because a launch needs one.
printf '[core]\nssh_key = %s\nforge = none\nworkflow = local-only\n' \
"$RW_KEY" > "$CONFIG_DIR/config"
_rw_launch "$workspace"
[ "$status" -ne 0 ] || { echo "launched with no harness selected:"; echo "$output"; return 1; }
[[ "$output" == *"[core] harness is not set"* ]]
[[ "$output" == *"harness = claude-code"* ]] # the line to add
[[ "$output" == *"Available:"*"claude-code"* ]]
rm -rf "$workspace"
}
@test "the harness schema carries no default to fall back to" {
# Directly again: write_config seeds one, which is the whole point of it.
printf '[core]\nforge = none\nworkflow = local-only\n' > "$CONFIG_DIR/config"
run lib config_get core harness
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$output" = "" ]
}
# --print-config is what an operator reaches for to find out what is unset, so
# it has to survive the thing being unset.
@test "--print-config still works when no harness is selected" {
printf '[core]\nforge = none\nworkflow = local-only\n' > "$CONFIG_DIR/config"
run "$REPO_ROOT/bin/dejima" --print-config
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"core.harness="* ]]
[[ "$output" != *"invalid harness name"* ]]
}
@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)"
@@ -1338,7 +1602,7 @@ EOF
# a gitea token reaching the container: the shipped defaults select no
# forge at all, so leaving them implicit would mean nothing ever reads
# token_file and the refusal under test could never fire.
printf '[core]\nfirewall = on\nssh_key = %s\nforge = gitea\nworkflow = worktree-pr\n[forge.gitea]\nssh_host = git.example\napi = https://api.example\ntoken_file = %s/token\n' \
printf '[core]\nharness = claude-code\nfirewall = on\nssh_key = %s\nforge = gitea\nworkflow = worktree-pr\n[forge.gitea]\nssh_host = git.example\napi = https://api.example\ntoken_file = %s/token\n' \
"$RW_KEY" "$CONFIG_DIR" > "$CONFIG_DIR/config"
printf 'real-secret\ngarbage\n' > "$CONFIG_DIR/token"
STUB_DIR="$(mktemp -d)"
@@ -1357,3 +1621,83 @@ EOF
|| { echo "docker was invoked despite the refusal"; return 1; }
rm -rf "$workspace" "$RW_HOME" "$STUB_DIR"
}
# --- epitaph: what a session leaves when its context dies ---------------------------------------------------
#
# What a long session worked out is in the summary Claude Code writes when it
# compacts, and that goes away with the container. The store is host-side and
# never mounted, so a later session can neither read what an earlier one
# concluded nor edit what this one is about to record (#104).
_epitaph_fixture() {
EPI_STORE="$(mktemp -d)"
EPI_TR="$(mktemp -d)"
EPI_WS="$(mktemp -d)"; git -C "$EPI_WS" init -q
EPI_SLUG="$(printf '%s' "$EPI_WS" | sed 's/[^a-zA-Z0-9]/-/g')"
mkdir -p "$EPI_TR/$EPI_SLUG"
write_config '[epitaph]' "dir = $EPI_STORE"
}
# One compact_boundary carrying the metadata, then the summary itself.
_epitaph_transcript() {
local f="$EPI_TR/$EPI_SLUG/$1.jsonl" text="${2:-worked out X}" uid="${3:-aaaabbbb}"
{
printf '{"type":"system","subtype":"compact_boundary","compactMetadata":{"trigger":"manual","preTokens":900,"postTokens":90}}\n'
printf '{"type":"user","isCompactSummary":true,"uuid":"%s","sessionId":"s-1","timestamp":"2026-01-02T03:04:05.000Z","message":{"content":"%s"}}\n' "$uid" "$text"
} > "$f"
}
@test "an epitaph is written out of the container's reach" {
_epitaph_fixture
_epitaph_transcript s1 "the thing this session worked out"
run "$REPO_ROOT/bin/dejima-epitaph" save "$EPI_WS" "$EPI_TR"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
local saved; saved="$(find "$EPI_STORE" -name '*.md')"
[ -n "$saved" ]
grep -q 'the thing this session worked out' "$saved"
grep -q 'trigger: manual' "$saved"
grep -q '900 before, 90 after' "$saved"
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
}
@test "saving twice does not duplicate an epitaph already kept" {
# `save` runs on every exit, and a session compacted three times has two
# epitaphs an earlier run already wrote.
_epitaph_fixture
_epitaph_transcript s1 "once only"
"$REPO_ROOT/bin/dejima-epitaph" save "$EPI_WS" "$EPI_TR" >/dev/null 2>&1
"$REPO_ROOT/bin/dejima-epitaph" save "$EPI_WS" "$EPI_TR" >/dev/null 2>&1
[ "$(find "$EPI_STORE" -name '*.md' | wc -l)" -eq 1 ]
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
}
@test "another project's epitaphs are not collected" {
_epitaph_fixture
_epitaph_transcript s1 "mine"
mkdir -p "$EPI_TR/-some-other-project"
printf '{"type":"user","isCompactSummary":true,"uuid":"cccc","sessionId":"s-2","timestamp":"2026-01-02T03:04:05.000Z","message":{"content":"theirs"}}\n' \
> "$EPI_TR/-some-other-project/s2.jsonl"
"$REPO_ROOT/bin/dejima-epitaph" save "$EPI_WS" "$EPI_TR" >/dev/null 2>&1
run grep -rl 'theirs' "$EPI_STORE"
[ "$status" -ne 0 ] || { echo "collected another project's summary"; return 1; }
grep -rq 'mine' "$EPI_STORE"
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
}
@test "no epitaph is written when [epitaph] enabled is off" {
_epitaph_fixture
write_config '[epitaph]' "dir = $EPI_STORE" 'enabled = off'
_epitaph_transcript s1 "should not be kept"
"$REPO_ROOT/bin/dejima-epitaph" save "$EPI_WS" "$EPI_TR" >/dev/null 2>&1
[ -z "$(find "$EPI_STORE" -name '*.md' 2>/dev/null)" ]
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
}
@test "the epitaph store is not mounted into the container" {
# The whole point: a session must not be able to read what earlier
# sessions concluded, or edit what this one is about to record.
run grep -n 'epitaph' "$REPO_ROOT/docker/docker-compose.yml"
[ "$status" -ne 0 ] || { echo "$output"; return 1; }
run grep -nE 'extra_mounts\+=.*epitaph' "$REPO_ROOT/bin/dejima"
[ "$status" -ne 0 ] || { echo "$output"; return 1; }
}
+39 -2
View File
@@ -92,7 +92,8 @@ launch() {
local conf
conf="$(bash -c 'source "$1"; config_path' _ "$LIB")"
mkdir -p "$(dirname "$conf")"
printf '[core]\nssh_key = %s\n' "$LAUNCH_DIR/sandbox-key" >> "$conf"
# No default, so a config that launches must select one.
printf '[core]\nharness = claude-code\nssh_key = %s\n' "$LAUNCH_DIR/sandbox-key" >> "$conf"
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
SANDBOX_TEST_RUN_ENV="$LAUNCH_ENV" \
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
@@ -817,7 +818,7 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
# no domains at all, or by the env prefix below never reaching the child.
printf 'good.example.com\n' > "$CONFIG_DIR/allowed-domains"
launch_setup
printf '[core]\nssh_key = %s\n' "$LAUNCH_DIR/sandbox-key" >> "$CONFIG_DIR/config"
printf '[core]\nharness = claude-code\nssh_key = %s\n' "$LAUNCH_DIR/sandbox-key" >> "$CONFIG_DIR/config"
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
SANDBOX_TEST_RUN_ENV="$LAUNCH_ENV" \
SSH_AUTH_SOCK= ALLOWED_DOMAINS=evil.example \
@@ -908,6 +909,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
+76 -1
View File
@@ -236,7 +236,8 @@ assert_remedy_forges_exist() {
local forge workflow harness
forge="$(lib config_default core.forge)"
workflow="$(lib config_default core.workflow)"
harness="$(lib config_default core.harness)"
# No default to read: the shipped pair under test is forge/workflow.
harness=claude-code
[ "$(lib manifest_get workflow "$workflow" push)" = "off" ] \
|| { echo "default workflow '$workflow' pushes - a fresh install has no forge to push to"; return 1; }
run lib provider_validate_selection "$forge" "$workflow" "$harness"
@@ -591,6 +592,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 \
+74 -26
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() {
@@ -119,12 +177,13 @@ launch() {
local conf
conf="$(bash -c 'source "$1"; config_path' _ "$LIB")"
mkdir -p "$(dirname "$conf")"
printf '[core]\nssh_key = %s\n' "$LAUNCH_DIR/sandbox-key" >> "$conf"
# No default, so a config that launches must select one.
printf '[core]\nharness = claude-code\nssh_key = %s\n' "$LAUNCH_DIR/sandbox-key" >> "$conf"
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
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 +339,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 +362,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"
}
+65 -36
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)" {
@@ -607,9 +618,12 @@ dexec_node() {
@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"
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 \
@@ -617,6 +631,7 @@ dexec_node() {
-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"
@@ -639,6 +654,20 @@ dexec_node() {
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"
}
+188 -43
View File
@@ -449,10 +449,9 @@ 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 not in this list: it is mounted per slug by bin/dejima,
# see the tests below. The rest is the edit-undo record and the task/plan
# state skills write.
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"
@@ -464,6 +463,16 @@ EOF
# 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 ] || {
@@ -487,6 +496,30 @@ EOF
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
@@ -1718,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" {
@@ -1949,12 +1989,15 @@ EOF
grep -q 'not a verdict on who caused them' "$REPO_ROOT/README.md"
}
@test "docs/security.md documents the ~/.claude/projects cross-session false-positive case" {
# Presence-only grep, same caveat as above: confirms the specific
# ~/.claude/projects wording landed, not that the surrounding
# reasoning is sound.
grep -q 'CROSS-PROJECT. finding' "$REPO_ROOT/docs/security.md"
grep -q 'directory shared by' "$REPO_ROOT/docs/security.md"
# Was: that the cross-session false-positive case is documented. The finding
# itself is gone - a session can no longer write another project's slug, so
# every one it could still see belonged to some other session - and what
# needs documenting now is its absence, so that removing detection stays a
# stated decision rather than something a reader has to infer from silence.
@test "docs/security.md says why there is no cross-project finding" {
grep -q 'no longer a .CROSS-PROJECT. finding' "$REPO_ROOT/docs/security.md"
run grep -n 'CROSS-PROJECT history/memory write' "$REPO_ROOT/bin/dejima-audit"
[ "$status" -ne 0 ] || { echo "the finding is back but the doc says it is gone" >&2; false; }
}
# The CI disclosure is the one part of issue #72 that helps a reader whatever
@@ -1987,6 +2030,97 @@ 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 frames dejima as a development environment, not only a sandbox" {
grep -q 'sandboxed development environment' "$REPO_ROOT/README.md"
# The half that is not containment has to be visible as such. A reader
# who is shown only the boundary learns what dejima stops and not what
# it is for.
grep -q 'What it gives an agent to work with' "$REPO_ROOT/README.md"
}
@test "the README does not promise a default harness" {
# `[core] harness` has had no default since it became required - a launch
# is refused without it - but the opening paragraph still read "Claude
# Code by default", which is the promise that change exists to not make.
run grep -n 'Claude Code by default' "$REPO_ROOT/README.md"
[ "$status" -ne 0 ] || { echo "$output"; return 1; }
}
@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"
}
# The branch convention is only a convention - nothing refuses a pull request
# aimed at main - so the three places an agent or a contributor looks have to
# agree, or the one that is stale becomes the instruction someone follows.
@test "the development-branch convention is stated where it is looked for" {
local f
for f in .dejima.md CONTRIBUTING.md docs/development.md; do
grep -q 'development' "$REPO_ROOT/$f" || {
echo "$f does not mention the development branch" >&2
false
}
done
# and the one thing that is easy to get wrong: gitea-pr's default base
grep -q -- '--base development' "$REPO_ROOT/.dejima.md"
grep -q -- '--base development' "$REPO_ROOT/docs/development.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"
@@ -2064,10 +2198,12 @@ EOF
# 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"' "$REPO_ROOT/README.md" \
| sed 's/^src="//; s/"$//'); do
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
@@ -2265,6 +2401,7 @@ EOF
# past validation.
cat > "$DEJIMA_T/config/config" <<EOF
[core]
harness = claude-code
ssh_key = $DEJIMA_T/sandbox-key
forge = none
workflow = local-only
@@ -2535,39 +2672,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
@@ -2701,6 +2825,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.