Compare commits

...
Author SHA1 Message Date
branderson b1374b21ea Say where ~/shared is now, in the briefing and the four docs that name it
tests / confined (conversation) (pull_request_target) Successful in 55s
tests / confined (proxy) (pull_request_target) Successful in 55s
tests / confined (providers) (pull_request_target) Successful in 1m32s
tests / runner-env (confined) (pull_request_target) Successful in 51s
tests / confined (shellcheck) (pull_request_target) Successful in 1m9s
tests / confined (remote) (pull_request_target) Successful in 1m55s
tests / confined (project) (pull_request_target) Successful in 2m33s
tests / confined (static) (pull_request_target) Successful in 2m42s
tests / confined (audit) (pull_request_target) Successful in 2m56s
tests / confined (config) (pull_request_target) Successful in 3m2s
tests / privileged (pull_request_target) Successful in 4m0s
tests / runner-env (privileged) (pull_request_target) Successful in 40s
The briefing told the session ~/shared was a persistent host directory and
nothing about who else could read it, which was accurate when the answer was
everyone. It now says the directory is this project's own and that every
session against this project shares it.

configuration.md gains the layout, the dejima-share commands, and what happened
to files left at share_dir's top level. security.md gains a bullet in the
section that already lists what is sliced per project, because the share
directory was the remaining way around that slicing. remote-access.md's sshfs
example mounts projects/ - one directory per project, no loose files - and says
a single project's directory is mountable on its own.

The 'clear it out periodically' warning stays and gets sharper: the split
bounds which sessions can read what you left, and bounds nothing about how
long.
2026-08-30 21:16:39 -07:00
branderson 6a2b36740e Cover the share split, and wire the new suite into both runners
share.bats drives the real launch with a stubbed docker and asserts on the
SANDBOX_SHARE_DIR compose was actually handed, rather than on --print-config: a
version that resolved the path correctly and then mounted something else would
pass the second.

What it pins is the part that can regress silently - that two projects get two
directories, that dejima-share path names the directory the launch mounts, that
the launch keeps saying what the split left behind until it is gone, that adopt
moves nothing without a yes and never overwrites, and that neither [mounts]
readonly nor read_write can hand back the root.
2026-08-30 21:16:31 -07:00
branderson bc994ccf80 Add dejima-share: where a project's ~/shared is, and what is in it
Splitting share_dir per project makes the inbound direction the hard one - an
operator dropping a file in for the next launch needs to know which directory
that launch will mount. `dejima-share path` answers it and creates the
directory, so a file copied there is waiting.

`list` is the retention story, which is that there is not one: dejima removes
nothing from share_dir and never has, so this prints each project's size and
the date of its last write and says plainly that clearing it out is yours. The
per-project split bounds which sessions can read what you left and bounds
nothing about how long.

`adopt` moves what the split left loose at share_dir's top level into one
project's share, after showing it and asking once, the way dejima-project
approve does. It never overwrites: a name already in the destination is left
loose and reported, because both files are the operator's and this cannot know
which one they meant.

Nothing here deletes anything by itself. The boundary is the mount bin/dejima
sets up; this is the operator's view of the other side of it.
2026-08-30 21:16:31 -07:00
branderson 5f2b51d2ce Mount one project's share directory at ~/shared, not the whole of share_dir
`[core] share_dir` was one directory mounted into every container, so a
session saw the handoff files of every session before it and of every session
running beside it, across projects. That is the boundary ~/.claude is already
sliced per project to hold, with the share directory as the way around it.

A launch now mounts <share_dir>/projects/<slug>, keyed by the same workspace
slug the audit and epitaph stores use. What the container sees is still
~/shared, so nothing an agent has been told changes.

The layout lives beside ws_slug because two callers have to agree on it: what
bin/dejima mounts and what dejima-share tells an operator to put a file in.

<share_dir>/projects joins the guarded stores. Per-project only holds while
nothing else hands the whole of it over, and a session's own share arrives at
~/shared without going through [mounts] at all.

Files an operator left at share_dir's top level are still there and are now
mounted nowhere. Moving them is not this change's business, so every launch
says which ones they are until they are gone - a file handed to a session
yesterday and silently not arriving today is the failure worth avoiding here.
2026-08-30 21:16:19 -07:00
branderson e8ea09a347 Merge pull request 'A fast local test lane: parallel within each suite, and change-aware selection' (#283) from fast-test-lane into development
Reviewed-on: #283
2026-08-30 20:38:26 -07:00
branderson b232636960 Document the two lanes where the suite instructions already are
tests / confined (proxy) (pull_request_target) Successful in 1m15s
tests / confined (conversation) (pull_request_target) Successful in 1m19s
tests / runner-env (confined) (pull_request_target) Successful in 37s
tests / confined (providers) (pull_request_target) Successful in 2m2s
tests / confined (shellcheck) (pull_request_target) Successful in 58s
tests / confined (remote) (pull_request_target) Successful in 3m2s
tests / confined (project) (pull_request_target) Successful in 3m49s
tests / privileged (pull_request_target) Successful in 3m47s
tests / runner-env (privileged) (pull_request_target) Successful in 41s
tests / confined (static) (pull_request_target) Successful in 5m46s
tests / confined (audit) (pull_request_target) Successful in 7m8s
tests / confined (config) (pull_request_target) Successful in 7m25s
.dejima.md's per-file table is gone: it lived as prose an agent had to apply by
hand, and it now lives in suites_for() where it runs. What replaces it is the
distinction the table could not carry - which lane is the gate, and that a
green --changed run is evidence about the suites it names and nothing about the
ones it skipped.
2026-08-30 20:29:20 -07:00
branderson 839715605c Run the tests within a suite concurrently, and select suites from the diff
Two lanes, and each run says which one it was. The default is still every
confined suite - the pull-request gate, unchanged in what it covers. --changed
maps the working diff through suites_for() and runs only what it selects,
saying in as many words that it is not the gate.

The map is the table .dejima.md carried as prose, moved here so that it runs.
A path matching no rule selects every suite, because a path nobody has mapped
is a path nobody has decided about. A path given as an argument stands in for
one git would have reported, so --changed --list answers "what would this
change run?" without depending on what happens to be uncommitted.

Within each file, bats' own --jobs. It needs flock rather than GNU parallel,
and --no-parallelize-across-files is what tells bats that - it otherwise
refuses --jobs outright when GNU parallel is absent, which is every dejima
sandbox. Without flock the script falls back to one at a time and says so. The
CI matrix gets the same flag, where flock comes from util-linux, which Debian
marks Essential.

static.bats pins what makes two lanes safe: the map names only suites that
exist, an unmapped path selects everything, and each lane identifies itself.
2026-08-30 20:29:15 -07:00
branderson 19f5e6e452 State two timing preconditions as conditions, not as sleeps
Both surfaced once the suites started running their own tests concurrently:
they held on an idle machine and failed on a loaded one, which is the pattern
.dejima.md already records twice.

conversation.bats slept a second to mean "the shipper is up with the line in
its batch". Under load the startup that precedes the SIGTERM trap takes longer
than that, so the kill landed before there was a trap and the process died
flushing nothing. The nap between polls is a child `sleep`, so the state is
observable; await_napping waits for it.

The Ctrl-Z harness read the terminal flags at a fixed moment after `fg`. fg
returns when the job is continued and the client sets raw mode some scheduling
later, so on a loaded machine the check read the shell's own canonical mode and
called it a failure to restore. await_raw waits for raw mode the way
await_reclaim already waits for the helper.
2026-08-30 20:29:06 -07:00
branderson b23ea0edb2 Cut the fork cost of every provider-library load
Sourcing bin/dejima-provider-lib.sh cost ~235ms, of which ~180ms was the
source-time warm-up: provider_list and provider_dir reached provider_root and
_provider_axis_dir through $( ), and provider_dir resolved the axis directory
again for every provider, so warming eight directories was around forty forks.

Both helpers gain an assign-into-a-variable form beside the printing one, the
way _config_type_into already sits beside config_type. readlink answers are
memoized per path, and the warm-up resolves every path in one readlink - it
takes several operands and prints one line each - dropping the batch on a
mismatched count rather than caching a pairing it cannot trust.

What is cached is one path's resolution, not a verdict: provider_dir still runs
the containment comparison and the -d check on every call.

This is paid by every bin/dejima invocation and by every test that drives the
library in a fresh shell, which is most of them.
2026-08-30 20:28:59 -07:00
branderson c83e54e35d Merge pull request 'Make the credential proxy provider-generic, with Anthropic as one caller' (#279) from generic-proxy into development
Reviewed-on: #279
Reviewed-by: branderson <brad@branderson.io>
2026-08-30 14:06:34 -07:00
branderson f2cfd0d868 Make the credential proxy provider-generic, with Anthropic as one caller
tests / confined (proxy) (pull_request_target) Successful in 1m16s
tests / confined (conversation) (pull_request_target) Successful in 1m22s
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / confined (shellcheck) (pull_request_target) Successful in 56s
tests / confined (providers) (pull_request_target) Successful in 2m34s
tests / privileged (pull_request_target) Successful in 3m29s
tests / confined (remote) (pull_request_target) Successful in 3m41s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (project) (pull_request_target) Successful in 5m4s
tests / confined (config) (pull_request_target) Successful in 9m30s
tests / confined (static) (pull_request_target) Successful in 5m34s
tests / confined (audit) (pull_request_target) Successful in 8m10s
2026-08-29 17:31:35 -07:00
branderson 817959acb9 Merge pull request 'Match both image spellings, so the reap stops matching nothing' (#278) from runtime-reap into development
Reviewed-on: #278
Reviewed-by: branderson <brad@branderson.io>
2026-08-29 16:43:53 -07:00
branderson a133ba4b7e Match both image spellings, so the reap stops matching nothing
tests / confined (proxy) (pull_request_target) Successful in 1m8s
tests / confined (conversation) (pull_request_target) Successful in 1m20s
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / confined (shellcheck) (pull_request_target) Successful in 59s
tests / confined (providers) (pull_request_target) Successful in 2m32s
tests / privileged (pull_request_target) Successful in 3m20s
tests / confined (remote) (pull_request_target) Successful in 3m37s
tests / runner-env (privileged) (pull_request_target) Successful in 36s
tests / confined (project) (pull_request_target) Successful in 5m5s
tests / confined (static) (pull_request_target) Successful in 5m37s
tests / confined (audit) (pull_request_target) Successful in 8m9s
tests / confined (config) (pull_request_target) Successful in 9m33s
2026-08-29 15:36:10 -07:00
branderson 9db269d8ba Merge pull request 'Name every image dejima builds under localhost, not Docker Hub' (#273) from local-tags into development
Reviewed-on: #273
Reviewed-by: branderson <brad@branderson.io>
2026-08-29 15:25:06 -07:00
branderson 17ef233229 Merge pull request 'Lay out the dejima stack, and list dejima-guard with its gaps' (#274) from stack-table into development
Reviewed-on: #274
Reviewed-by: branderson <brad@branderson.io>
2026-08-29 15:24:39 -07:00
branderson 3c68d868de List the dejima stack in the README, linking out for the detail
tests / confined (proxy) (pull_request_target) Successful in 1m10s
tests / confined (conversation) (pull_request_target) Successful in 1m22s
tests / runner-env (confined) (pull_request_target) Successful in 35s
tests / confined (shellcheck) (pull_request_target) Successful in 59s
tests / confined (providers) (pull_request_target) Successful in 2m40s
tests / privileged (pull_request_target) Successful in 3m23s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (remote) (pull_request_target) Successful in 3m51s
tests / confined (project) (pull_request_target) Successful in 5m17s
tests / confined (static) (pull_request_target) Successful in 5m42s
tests / confined (audit) (pull_request_target) Successful in 8m18s
tests / confined (config) (pull_request_target) Successful in 9m40s
2026-08-29 15:04:48 -07:00
branderson 851da62449 Describe dejima-guard by what it adds, not by how it differs from dejima
tests / confined (proxy) (pull_request_target) Successful in 1m4s
tests / confined (conversation) (pull_request_target) Successful in 1m21s
tests / runner-env (confined) (pull_request_target) Successful in 35s
tests / confined (shellcheck) (pull_request_target) Successful in 57s
tests / confined (providers) (pull_request_target) Successful in 2m25s
tests / privileged (pull_request_target) Successful in 3m13s
tests / confined (remote) (pull_request_target) Successful in 3m27s
tests / runner-env (privileged) (pull_request_target) Successful in 34s
tests / confined (project) (pull_request_target) Successful in 4m46s
tests / confined (static) (pull_request_target) Successful in 5m17s
tests / confined (audit) (pull_request_target) Successful in 7m43s
tests / confined (config) (pull_request_target) Successful in 9m6s
2026-08-29 14:23:54 -07:00
branderson 38cd96a06b Attach the guard's gaps to its name, and link it 2026-08-29 14:14:27 -07:00
branderson 0d8e241801 Lay out the dejima stack, and list dejima-guard with its gaps 2026-08-29 14:14:27 -07:00
branderson e44ddc01ff Tag the workflow image too, and check the invariant repo-wide
tests / confined (proxy) (pull_request_target) Successful in 1m5s
tests / confined (conversation) (pull_request_target) Successful in 1m19s
tests / runner-env (confined) (pull_request_target) Successful in 35s
tests / confined (shellcheck) (pull_request_target) Successful in 57s
tests / confined (providers) (pull_request_target) Successful in 2m21s
tests / privileged (pull_request_target) Successful in 3m8s
tests / confined (remote) (pull_request_target) Successful in 3m20s
tests / runner-env (privileged) (pull_request_target) Successful in 34s
tests / confined (project) (pull_request_target) Successful in 4m37s
tests / confined (static) (pull_request_target) Successful in 5m8s
tests / confined (audit) (pull_request_target) Successful in 7m29s
tests / confined (config) (pull_request_target) Successful in 8m49s
2026-08-29 13:58:59 -07:00
branderson 88bb0394d8 Name every image dejima builds under localhost, not Docker Hub 2026-08-29 13:58:14 -07:00
branderson 185c862559 Merge pull request 'Tag the CI sandbox image under both names so a rename can land' (#277) from ci-image-tag into development
Reviewed-on: #277
Reviewed-by: branderson <brad@branderson.io>
2026-08-29 13:57:13 -07:00
branderson 309f2269c4 Tag the CI sandbox image under both names so a rename can land
tests / confined (proxy) (pull_request_target) Successful in 1m12s
tests / confined (conversation) (pull_request_target) Successful in 1m24s
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / confined (shellcheck) (pull_request_target) Successful in 56s
tests / confined (providers) (pull_request_target) Successful in 2m35s
tests / privileged (pull_request_target) Successful in 3m22s
tests / confined (remote) (pull_request_target) Successful in 3m38s
tests / runner-env (privileged) (pull_request_target) Successful in 35s
tests / confined (project) (pull_request_target) Successful in 5m8s
tests / confined (static) (pull_request_target) Successful in 5m31s
tests / confined (audit) (pull_request_target) Successful in 8m6s
tests / confined (config) (pull_request_target) Successful in 9m32s
2026-08-29 12:12:20 -07:00
branderson bc979ced2d Merge pull request 'Widen the gh repo guard to grouped short flags and api paths' (#270) from gh-guard-spellings into development
Reviewed-on: #270
Reviewed-by: branderson <brad@branderson.io>
2026-08-29 12:01:38 -07:00
branderson 540ee183ab Merge pull request 'Assert the Ctrl-Z race test by name, not by the whole harness passing' (#272) from suspend-test-scope into development
Reviewed-on: #272
Reviewed-by: branderson <brad@branderson.io>
2026-08-29 06:00:58 -07:00
branderson c99dfcb7ec Merge pull request 'Keep the ntfy URL out of curl's argv and out of error output' (#275) from ntfy-argv into development
Reviewed-on: #275
Reviewed-by: branderson <brad@branderson.io>
2026-08-29 06:00:37 -07:00
branderson 0e6b96963a Merge pull request 'Reap runtime.bats' abandoned images, not just its containers and networks' (#271) from runtime-reap into development
Reviewed-on: #271
Reviewed-by: branderson <brad@branderson.io>
2026-08-29 06:00:08 -07:00
branderson 2e0bc4153b Keep the ntfy URL out of curl's argv and out of error output
tests / confined (conversation) (pull_request_target) Successful in 1m22s
tests / confined (proxy) (pull_request_target) Successful in 1m7s
tests / confined (providers) (pull_request_target) Successful in 2m31s
tests / confined (shellcheck) (pull_request_target) Successful in 59s
tests / runner-env (confined) (pull_request_target) Successful in 34s
tests / confined (project) (pull_request_target) Successful in 4m45s
tests / confined (remote) (pull_request_target) Successful in 3m31s
tests / privileged (pull_request_target) Successful in 3m17s
tests / confined (audit) (pull_request_target) Successful in 7m35s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (config) (pull_request_target) Successful in 8m52s
tests / confined (static) (pull_request_target) Successful in 5m29s
2026-08-29 04:24:08 -07:00
branderson 59c27df8c4 Assert the Ctrl-Z race test by name, not by the whole harness passing
tests / confined (proxy) (pull_request_target) Successful in 1m8s
tests / confined (conversation) (pull_request_target) Successful in 1m22s
tests / runner-env (confined) (pull_request_target) Successful in 34s
tests / confined (shellcheck) (pull_request_target) Successful in 58s
tests / confined (providers) (pull_request_target) Successful in 2m32s
tests / privileged (pull_request_target) Successful in 3m20s
tests / confined (remote) (pull_request_target) Successful in 3m34s
tests / runner-env (privileged) (pull_request_target) Successful in 33s
tests / confined (project) (pull_request_target) Successful in 4m51s
tests / confined (static) (pull_request_target) Successful in 5m12s
tests / confined (audit) (pull_request_target) Successful in 7m32s
tests / confined (config) (pull_request_target) Successful in 8m43s
2026-08-29 02:54:16 -07:00
branderson 0fceaaeeb3 Widen the gh repo guard to grouped short flags and api paths
tests / confined (audit) (pull_request_target) Successful in 7m30s
tests / confined (config) (pull_request_target) Successful in 8m46s
tests / confined (conversation) (pull_request_target) Successful in 1m21s
tests / confined (project) (pull_request_target) Successful in 4m51s
tests / confined (providers) (pull_request_target) Successful in 2m36s
tests / confined (proxy) (pull_request_target) Successful in 1m7s
tests / confined (remote) (pull_request_target) Successful in 3m36s
tests / confined (static) (pull_request_target) Successful in 5m8s
tests / privileged (pull_request_target) Successful in 3m26s
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / runner-env (privileged) (pull_request_target) Successful in 33s
tests / confined (shellcheck) (pull_request_target) Successful in 56s
2026-08-29 01:53:13 -07:00
branderson 3cdcd289e1 Reap this suite's abandoned images, not just its containers and networks
tests / confined (conversation) (pull_request_target) Successful in 1m20s
tests / confined (proxy) (pull_request_target) Successful in 1m8s
tests / confined (providers) (pull_request_target) Successful in 2m31s
tests / privileged (pull_request_target) Successful in 3m16s
tests / confined (shellcheck) (pull_request_target) Successful in 59s
tests / runner-env (privileged) (pull_request_target) Successful in 33s
tests / runner-env (confined) (pull_request_target) Successful in 35s
tests / confined (project) (pull_request_target) Successful in 4m49s
tests / confined (remote) (pull_request_target) Successful in 3m34s
tests / confined (audit) (pull_request_target) Successful in 7m32s
tests / confined (config) (pull_request_target) Successful in 8m52s
tests / confined (static) (pull_request_target) Successful in 5m25s
2026-08-29 01:53:10 -07:00
branderson 79fe71a14c Merge pull request 'Wait for the suspend helper before typing Ctrl-Z at it' (#269) from suspend-harness into development
Reviewed-on: #269
Reviewed-by: branderson <brad@branderson.io>
2026-08-29 01:45:28 -07:00
branderson 3dd3022dd3 Wait for the suspend helper before typing Ctrl-Z at it
tests / confined (conversation) (pull_request_target) Successful in 1m22s
tests / confined (proxy) (pull_request_target) Successful in 1m37s
tests / runner-env (confined) (pull_request_target) Successful in 37s
tests / confined (shellcheck) (pull_request_target) Successful in 56s
tests / confined (providers) (pull_request_target) Successful in 2m36s
tests / privileged (pull_request_target) Successful in 3m19s
tests / confined (remote) (pull_request_target) Successful in 3m41s
tests / runner-env (privileged) (pull_request_target) Successful in 35s
tests / confined (project) (pull_request_target) Successful in 5m10s
tests / confined (static) (pull_request_target) Successful in 5m33s
tests / confined (audit) (pull_request_target) Successful in 8m15s
tests / confined (config) (pull_request_target) Successful in 9m34s
2026-08-29 01:13:35 -07:00
branderson 9a612f7b97 Merge pull request 'Document mounting the box's shared directories over sshfs' (#268) from sshfs-docs into development
Reviewed-on: #268
Reviewed-by: branderson <brad@branderson.io>
2026-08-28 23:49:49 -07:00
branderson ad03d02f4a Drop the fstab route, and make mounting on demand the recommendation
tests / confined (proxy) (pull_request_target) Successful in 1m9s
tests / confined (conversation) (pull_request_target) Successful in 1m23s
tests / runner-env (confined) (pull_request_target) Successful in 35s
tests / confined (shellcheck) (pull_request_target) Successful in 58s
tests / confined (providers) (pull_request_target) Successful in 2m42s
tests / privileged (pull_request_target) Successful in 3m30s
tests / confined (remote) (pull_request_target) Successful in 3m59s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
tests / confined (project) (pull_request_target) Successful in 5m40s
tests / confined (static) (pull_request_target) Successful in 5m55s
tests / confined (audit) (pull_request_target) Successful in 9m10s
tests / confined (config) (pull_request_target) Successful in 10m37s
2026-08-28 23:29:52 -07:00
branderson f638c40b76 Disclose what an sshfs mount costs before the recipe, and in the threat model
tests / confined (proxy) (pull_request_target) Successful in 1m12s
tests / runner-env (confined) (pull_request_target) Successful in 38s
tests / confined (conversation) (pull_request_target) Successful in 1m23s
tests / confined (shellcheck) (pull_request_target) Successful in 1m0s
tests / confined (providers) (pull_request_target) Successful in 2m46s
tests / privileged (pull_request_target) Successful in 3m33s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (remote) (pull_request_target) Successful in 4m3s
tests / confined (project) (pull_request_target) Successful in 5m43s
tests / confined (static) (pull_request_target) Successful in 6m1s
tests / confined (audit) (pull_request_target) Successful in 9m8s
tests / confined (config) (pull_request_target) Successful in 10m41s
2026-08-28 23:05:17 -07:00
branderson ed57f646a3 Merge pull request 'Document what the front door's authentication has to do, not one way to do it' (#264) from auth-alternatives into development
Reviewed-on: #264
Reviewed-by: branderson <brad@branderson.io>
2026-08-28 22:27:07 -07:00
branderson 90a264c63c Document mounting the box's shared directories over sshfs
tests / confined (proxy) (pull_request_target) Successful in 1m14s
tests / confined (conversation) (pull_request_target) Successful in 1m28s
tests / runner-env (confined) (pull_request_target) Successful in 37s
tests / confined (shellcheck) (pull_request_target) Successful in 1m0s
tests / confined (providers) (pull_request_target) Successful in 2m56s
tests / privileged (pull_request_target) Successful in 3m41s
tests / runner-env (privileged) (pull_request_target) Successful in 40s
tests / confined (remote) (pull_request_target) Successful in 4m14s
tests / confined (project) (pull_request_target) Successful in 5m53s
tests / confined (static) (pull_request_target) Successful in 6m7s
tests / confined (audit) (pull_request_target) Successful in 9m23s
tests / confined (config) (pull_request_target) Successful in 11m2s
2026-08-28 22:15:07 -07:00
branderson 53abc0ae21 Document what the front door's authentication has to do, not one way to do it
tests / confined (proxy) (pull_request_target) Successful in 1m10s
tests / confined (conversation) (pull_request_target) Successful in 1m23s
tests / runner-env (confined) (pull_request_target) Successful in 37s
tests / confined (shellcheck) (pull_request_target) Successful in 1m0s
tests / confined (providers) (pull_request_target) Successful in 2m47s
tests / privileged (pull_request_target) Successful in 3m33s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
tests / confined (remote) (pull_request_target) Successful in 4m1s
tests / confined (project) (pull_request_target) Successful in 5m42s
tests / confined (static) (pull_request_target) Successful in 5m58s
tests / confined (audit) (pull_request_target) Successful in 9m12s
tests / confined (config) (pull_request_target) Successful in 10m47s
docs/remote-access.md documented exactly one way to authenticate the web
front door - nginx auth_basic over TLS - and was emphatic that the proxy is
the entire gate, which is correct. What it did not say is that Basic is one
answer with particular costs. Anyone whose threat model or existing
infrastructure pointed elsewhere read a document that appeared to have none.

The requirement is now stated as a requirement: authenticate every request to
every server block, the default one included, whatever the software. Then the
shapes that meet it, in order of how much they change - a network the public
internet cannot reach, HTTP Basic, client certificates, a forward-auth proxy,
and something the reader already runs.

What Basic costs is said rather than implied: one shared credential, no
per-operator identity, no second factor, revocation by editing htpasswd, and
an access log that cannot tell two people apart. For a solo operator that is
a reasonable trade, and worth saying it is one.

Two properties in the old text survive deliberately. The bind address is not
replaced by any of this - a VPN interface is still one specific address, and
the wildcard is still wrong. And no option stops a page in a browser that has
already authenticated from acting; that used to sit attached to Basic in the
nginx example, and belongs with all of them.

The schematic said "TLS + auth_basic" and now says "TLS + auth", at the same
width so the box does not skew. The nginx example still uses Basic, being the
shortest thing to show, and now says so and points at the section.

One test, pinning the requirement rather than any of the software named, so
replacing an option does not fail it. Four assertions, each mutation-checked.

static 408.

Fixes #212
2026-08-28 21:30:25 -07:00
branderson 52a7a21f8a Merge pull request 'Give the web terminal a palette, since a browser has none to inherit' (#267) from web-theme into development
Reviewed-on: #267
Reviewed-by: branderson <brad@branderson.io>
2026-08-28 20:59:52 -07:00
branderson 549e5eb351 Give the web terminal a palette, since a browser has none to inherit
tests / confined (proxy) (pull_request_target) Successful in 1m10s
tests / confined (conversation) (pull_request_target) Successful in 1m25s
tests / runner-env (confined) (pull_request_target) Successful in 38s
tests / confined (shellcheck) (pull_request_target) Successful in 1m1s
tests / confined (providers) (pull_request_target) Successful in 2m50s
tests / privileged (pull_request_target) Successful in 4m1s
tests / confined (remote) (pull_request_target) Successful in 4m8s
tests / runner-env (privileged) (pull_request_target) Successful in 42s
tests / confined (project) (pull_request_target) Successful in 5m51s
tests / confined (static) (pull_request_target) Successful in 5m58s
tests / confined (audit) (pull_request_target) Successful in 9m25s
tests / confined (config) (pull_request_target) Successful in 11m4s
Reported from a live web attach: colour works, it is just not gruvbox - white
on black, a bright red context bar, pink and blue where the same session over
SSH is gruvbox.

The cause is the surface. dejima's output reaches a terminal in indexed
colour precisely so the terminal's own theme fills the slots, which is what
#266 established. On the web front door dejima IS the terminal: xterm.js has
no theme to inherit and falls back to its stock palette. ttyd was given
fontSize, tmuxPrefix, focusOnTap and buttonColors, and no theme at all.

So it gets one - all sixteen ANSI slots plus background, foreground and
cursor, in upstream gruvbox by its own names. By the same rule as
web/src/page.css: exact hexes on the two surfaces where dejima owns both
sides of the contrast, the terminal's own palette everywhere else.

Two tests. One that every slot is present, because a missing single slot
falls back to xterm.js's default for that slot alone and is harder to notice
than no theme at all. One that the values page.css and the theme share stay
in step, which is the drift two copies of a palette invite.

Two existing tests needed changing, both because they measured a proxy that
stopped holding. expect_ttyd_argv compares the whole vector, so an added
argument fails it by design - it reads the theme out of the script rather
than transcribing it, since what it asserts is the shape of the exec, not the
colour values. And "an unset ttyd_font_size sends no option" was written as
"no -t reaches ttyd at all", which was true only while every -t was optional.

remote 113, static 414.
2026-08-28 20:44:09 -07:00
branderson 4e093f0c4a Merge pull request 'Use the terminal's own palette, and keep the exact hexes where dejima owns the background' (#266) from one-palette into development
Reviewed-on: #266
Reviewed-by: branderson <brad@branderson.io>
2026-08-28 16:46:55 -07:00
branderson d1f8a54883 Write the wordmark in one colour, wherever dejima writes it
tests / confined (proxy) (pull_request_target) Successful in 1m19s
tests / confined (conversation) (pull_request_target) Successful in 1m28s
tests / runner-env (confined) (pull_request_target) Successful in 39s
tests / confined (shellcheck) (pull_request_target) Successful in 58s
tests / confined (providers) (pull_request_target) Successful in 3m6s
tests / privileged (pull_request_target) Successful in 3m48s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
tests / confined (remote) (pull_request_target) Successful in 4m27s
tests / confined (project) (pull_request_target) Successful in 6m10s
tests / confined (static) (pull_request_target) Successful in 6m22s
tests / confined (audit) (pull_request_target) Successful in 9m40s
tests / confined (config) (pull_request_target) Successful in 11m12s
The launch banner set "dejima" in bold cyan and the two pickers set it in the
badge colour, because before this branch there was no shared name for it. Now
there is, so all three use C_BADGE.

The test for it grepped the whole file and passed against a build where the
banner had been changed back - the comment above the line still said
"C_BADGE". It reads code with comments stripped now, which is the third time
that shape has turned up in this suite.

static 412, remote 113, config 186, audit 164, project 146, providers 96.
2026-08-28 16:34:36 -07:00
branderson b71cd7617d Use the terminal's own palette, and keep the exact hexes where dejima owns the background
Two palettes existed - dejima-color-lib.sh in basic ANSI and dejima-tui-lib.sh
in hardcoded gruvbox truecolor - and they disagreed on C_OK and C_WARN, so
which green a caller got depended on which file it had sourced.

Merging them exposed the larger problem. The truecolor palette was drawn for
the colour the session picker drew project names in - is 1.21:1, bright yellow
1.49:1, bright green 1.82:1, bright orange 2.23:1. Nothing in it clears 3:1 on
both a dark and a light background, because a palette drawn for one cannot.
The picker was unreadable on any terminal not themed like the author's.

So the terminal palette is now the terminal's own, and that keeps the brand
rather than giving it up: every colour here is an ANSI slot a gruvbox theme
fills with the exact hex the marks use - bright green #b8bb26, bright yellow
gruvbox terminal nothing changes. On any other, the theme's author already
chose those slots against their own background, which is the only way to be
legible on a background dejima does not control.

Three of the old colours had no slot and should not have had one:

light1 was gruvbox's own foreground, so it is the default foreground now -
empty. That is both the correct semantic and the fix for the 1.21:1 case.
dark4 and dark2 were "recede", which dim does against any background.
Orange has no ANSI slot at all; the wordmark is bold bright yellow, the
nearest thing every terminal has.

web/src/page.css keeps the exact hexes and is the one place that should: the
page sets its own background, so dejima owns both sides of the contrast there.
The test that compared page against picker is gone, because they are no longer
supposed to match - it would have been asking the terminal to break.

remote 111, static 407, audit 164, config 186, project 145, providers 96,
conversation 12, proxy 32.
2026-08-28 15:35:32 -07:00
branderson f43ffdcf6a Merge pull request 'Say what a launch is, at the top of it' (#265) from startup-banner into development
Reviewed-on: #265
Reviewed-by: branderson <brad@branderson.io>
2026-08-28 15:33:59 -07:00
branderson c03800e435 Merge pull request 'Clear the screen before drawing the session picker' (#263) from picker-clear into development
Reviewed-on: #263
Reviewed-by: branderson <brad@branderson.io>
2026-08-28 15:32:12 -07:00
branderson e73c6df114 Say what a launch is, at the top of it
tests / confined (proxy) (pull_request_target) Successful in 1m15s
tests / confined (conversation) (pull_request_target) Successful in 1m26s
tests / runner-env (confined) (pull_request_target) Successful in 38s
tests / confined (shellcheck) (pull_request_target) Successful in 57s
tests / confined (providers) (pull_request_target) Successful in 3m6s
tests / privileged (pull_request_target) Successful in 3m44s
tests / runner-env (privileged) (pull_request_target) Successful in 41s
tests / confined (remote) (pull_request_target) Successful in 4m26s
tests / confined (project) (pull_request_target) Successful in 6m17s
tests / confined (static) (pull_request_target) Successful in 6m26s
tests / confined (audit) (pull_request_target) Successful in 9m46s
tests / confined (config) (pull_request_target) Successful in 11m25s
The startup output was a list of checks with no point at which it said what
it is. The facts that differ between one launch and the next - which project,
which branch, which harness and model, which forge and workflow, and whether
the firewall, remote access and epitaph are on - were ones an operator had to
remember or go and read.

Three lines above the checks: the mark, the project and branch, the selection,
and the switches whose state changes what a session can do. Deliberately not a
config dump; the value is seeing at a glance that this launch is not
configured like the last one.

No ASCII rendering of the logo. The wordmark is 2007x492 of illustration with
gradients, and at the ~9 cells a 60-column banner gives it, it is a smudge -
verified by rendering it at 10x5 through 24x12 and looking. chafa was working
correctly and produced exact truecolor; the input is simply not a mark that
survives a terminal cell grid. The word is set as text instead, in the
operator's own font, which is sharp at every size, scales with their settings,
costs no escape bytes, and raises no question about deriving from the artwork.

Labels dim, values not: the labels are how you find the value you came for.
The firewall gets a colour of its own because it is the one switch here that
is a boundary rather than a choice - green with the domain count, yellow when
it is not in force.

[ui] banner, defaulting on, turns it off. Nothing is drawn when stderr is not
a terminal, so a redirected launch stays a log.

In bin/dejima's own palette rather than the pickers': dejima-color-lib.sh and
dejima-tui-lib.sh both define C_OK and C_WARN with different values, so
sourcing the second here would silently recolour every warning this script
already prints. Two palettes is a wart worth fixing on its own.

Four tests, all mutation-checked. One of them was vacuous first: the banner
colours its labels, so a reset escape sits between "harness" and its value,
and the assertion matched nothing whether the banner drew or not - it passed
against a build with the switch removed. They compare against $output with
SGR stripped now.

static 411, config 186, providers 96, project 145.

Fixes #211
2026-08-28 01:50:46 -07:00
branderson 3185d9fb5c Clear the screen before drawing the session picker
tests / confined (proxy) (pull_request_target) Successful in 1m13s
tests / confined (conversation) (pull_request_target) Successful in 1m25s
tests / runner-env (confined) (pull_request_target) Successful in 39s
tests / confined (shellcheck) (pull_request_target) Successful in 1m1s
tests / confined (providers) (pull_request_target) Successful in 2m56s
tests / privileged (pull_request_target) Successful in 3m46s
tests / confined (remote) (pull_request_target) Successful in 4m17s
tests / runner-env (privileged) (pull_request_target) Successful in 41s
tests / confined (static) (pull_request_target) Successful in 5m38s
tests / confined (project) (pull_request_target) Successful in 6m4s
tests / confined (audit) (pull_request_target) Successful in 9m37s
tests / confined (config) (pull_request_target) Successful in 11m11s
Reopening the picker from the web toolbar is a full page load, and it drew on
top of whatever the previous session had left on screen - rows interleaved
with the tail of the session just left, which reads as a rendering fault
rather than a new screen. Invisible over SSH, where a fresh connection to the
forced command usually starts on a blank one, which is why it went unnoticed.

Cleared in dejima-attach rather than in the web front end: it is the shared
core behind both front doors, so this fixes SSH too. 3J alongside 2J, so a
swipe up on a phone does not land in the middle of the previous session's
output - the same confusion one gesture away.

Its own function rather than part of tui_banner, because dejima-allow's
picker shares the banner and must NOT do this: that one is a prompt inside a
shell the operator is already using, and wiping their scrollback to ask one
question is worse than the interleaving this fixes. There is a test for that
specifically.

It rides the same switch as the colour and the glyphs. The first version gave
it its own terminal check, reasoning that NO_COLOR asks for no decoration
rather than for a front door drawn over the last session - which broke the
existing promise that NO_COLOR leaves the picker plain in both senses, and
contradicted the reasoning already written above the palette: one flag is
easier to reason about than two.

remote 113, project 146, static 407.

Fixes #213
2026-08-27 23:06:56 -07:00
branderson b5ac1b3b46 Merge pull request 'Make the guard tests assert the refusal, not just its message' (#261) from test-hardening into development
Reviewed-on: #261
Reviewed-by: branderson <brad@branderson.io>
2026-08-27 21:58:34 -07:00
branderson ea3e9c09e8 Make the guard tests assert the refusal, not just its message
tests / confined (proxy) (pull_request_target) Successful in 1m16s
tests / confined (conversation) (pull_request_target) Successful in 1m27s
tests / runner-env (confined) (pull_request_target) Successful in 38s
tests / confined (shellcheck) (pull_request_target) Successful in 1m1s
tests / confined (providers) (pull_request_target) Successful in 3m4s
tests / privileged (pull_request_target) Successful in 3m58s
tests / confined (remote) (pull_request_target) Successful in 4m23s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
tests / confined (static) (pull_request_target) Successful in 5m43s
tests / confined (project) (pull_request_target) Successful in 6m9s
tests / confined (audit) (pull_request_target) Successful in 9m43s
tests / confined (config) (pull_request_target) Successful in 11m24s
Three release-review test items.

The blocked-path guard tests asserted that the refusal was printed. A guard
that printed it and then ran the command anyway passed them, which is the
shape of failure a guard test exists to catch. assert_forge_refused now
checks all three things a refusal is: the message, that the stub CLI's
"real: $*" is absent, and a non-zero exit. Removing the `exit 1` from
refuse_repo now fails with "was refused AND ran" (#245).

The "github-pr only reads" grep matched subcommands and --method, neither of
which a GraphQL write uses: it is a POST to one endpoint with the verb in the
query body. `mutation` is in the pattern now, and the tool exists precisely
to reach GraphQL, so that was the one route it could not see (#245).

The tty-suspend fixture defaulted TTY_SUSPEND_WORK to /tmp and then wrote and
executed three fixed names there. Every bats caller passes a directory, so
nothing shipped took that branch - but the fixture runs on CI hosts, and the
mutation-check for the new test demonstrated the hazard by leaving
/tmp/launcher.sh and /tmp/container.py behind. It requires the variable now.
Its expect("Stopped") also read the shell's job-control notice in whatever
locale the runner had; the child gets LC_ALL=C (#249).

One test asserts a manifest contract rather than a behaviour: entrypoint.sh
installs the PATH shadow from merge_block and disclose only, while repo_flags
and repo_env are exported regardless - so a forge declaring repo_flags alone
would hand the guard its configuration with no guard on PATH to read it. Both
shipped forges declare disclose, so nothing is broken; this is for the third
forge someone writes (#244).

Five tests, all mutation-checked. static 402, providers 95.

Fixes #244
Fixes #245
Fixes #249
2026-08-27 21:39:02 -07:00
branderson d082e6a75c Merge pull request 'Default block_merge to on, and scope the self-hosting caveat to the case it applies to' (#260) from block-merge-default into development
Reviewed-on: #260
Reviewed-by: branderson <brad@branderson.io>
2026-08-27 21:37:17 -07:00
branderson 77b2d02866 Say that the self-hosting caveat needs dejima's own checkout, and watch tools/
tests / confined (proxy) (pull_request_target) Successful in 1m17s
tests / confined (conversation) (pull_request_target) Successful in 1m29s
tests / runner-env (confined) (pull_request_target) Successful in 40s
tests / confined (shellcheck) (pull_request_target) Successful in 1m1s
tests / confined (providers) (pull_request_target) Successful in 3m6s
tests / privileged (pull_request_target) Successful in 4m16s
tests / confined (remote) (pull_request_target) Successful in 4m29s
tests / runner-env (privileged) (pull_request_target) Successful in 42s
tests / confined (static) (pull_request_target) Successful in 5m53s
tests / confined (project) (pull_request_target) Successful in 6m20s
tests / confined (audit) (pull_request_target) Successful in 10m6s
tests / confined (config) (pull_request_target) Successful in 11m39s
Review of #247 asked whether the scope was expressed, and it was not - the
precondition appeared once, as a subordinate clause in a different paragraph,
while the paragraphs drawing the consequences carried none of it. Read
straight through, "Provider trust" describes an exposure every user has.

Nothing mounts forges/, workflows/, harnesses/, tools/ or the rest of the
checkout into a container: the resolved values go in, the files do not. So a
session pointed at any other project cannot read those paths, let alone write
them. The whole section is about one configuration - dejima sandboxing the
repository it is installed from - and now says so before the consequences
rather than in the middle of them.

Checking that turned up the claim being unbacked. docs/security.md said the
provider directories are in the audit's TCB list "for that reason", and #247
appended tools/ to the same sentence - but the audit's list was `bin hooks
docker test forges workflows harnesses`, with no tools. The doc asserted a
watch that did not exist. tools/ is in the list now.

The launch warning had the mirror problem, listing bin/, hooks/, docker/ and
install.sh while the audit watched those plus test/ and every manifest
directory. A warning that under-names the writable surface is one an operator
reads as complete.

Three tests, all mutation-checked. The first derives from what is on disk -
a directory shipping manifests that the audit does not watch fails it - so
the next axis is covered the day it lands rather than the day someone
remembers.

audit 164, static 405, providers 95, config 186, project 145.
2026-08-27 21:07:59 -07:00
branderson 0c2ceba35b Default block_merge to on, so a workflow that forgets it fails safe
tests / confined (proxy) (pull_request_target) Successful in 1m36s
tests / confined (conversation) (pull_request_target) Successful in 1m48s
tests / confined (shellcheck) (pull_request_target) Successful in 58s
tests / runner-env (confined) (pull_request_target) Successful in 39s
tests / confined (providers) (pull_request_target) Successful in 3m18s
tests / confined (remote) (pull_request_target) Successful in 4m25s
tests / confined (project) (pull_request_target) Successful in 6m17s
tests / confined (static) (pull_request_target) Successful in 5m25s
tests / confined (audit) (pull_request_target) Successful in 9m20s
tests / privileged (pull_request_target) Successful in 9m26s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (config) (pull_request_target) Successful in 10m35s
The schema default was `off`, so a workflow manifest omitting block_merge
silently permitted merging from inside a session. Every shipped workflow sets
it explicitly and an invariant test pins that, so nothing today depends on the
default - what it decides is what happens to the next workflow someone writes,
and a guard should fail in the refusing direction.

The invariant test covering shipped workflows passes either way, so this has
its own: a fixture workflow that names no block_merge must resolve to on.

Two documentation corrections found alongside it, both release-review items
filed rather than fixed at the time:

docs/security.md's self-hosting caveat named forges/*/forge.conf as
session-writable input to the next launch and not tools/*/tool.conf, which is
the same class - the host reads it, and the image build installs what it
points at, as root (#247).

And the negative-space list did not say that Ctrl-Z suspends bin/dejima on the
host rather than the session. The container keeps running with its grants
while the terminal looks stopped, which matters most to whoever suspends one
because they are uneasy about what it is doing (#246).

Three tests, all mutation-checked. providers 95, config 186, project 145,
static 402.

Fixes #250
Fixes #246
Fixes #247
2026-08-27 18:00:26 -07:00
branderson 4d1bb99180 Merge pull request 'Make [identity.default] the account a session gets, and deprecate [core] ssh_key' (#258) from ssh-key-identity into development
tests / confined (proxy) (pull_request_target) Successful in 1m14s
tests / confined (conversation) (pull_request_target) Successful in 1m25s
tests / runner-env (confined) (pull_request_target) Successful in 38s
tests / confined (shellcheck) (pull_request_target) Successful in 1m2s
tests / confined (providers) (pull_request_target) Successful in 3m0s
tests / privileged (pull_request_target) Successful in 3m49s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (remote) (pull_request_target) Successful in 4m22s
tests / confined (static) (pull_request_target) Successful in 5m39s
tests / confined (project) (pull_request_target) Successful in 6m9s
tests / confined (audit) (pull_request_target) Successful in 9m44s
tests / confined (config) (pull_request_target) Successful in 11m21s
Reviewed-on: #258
Reviewed-by: branderson <brad@branderson.io>
2026-08-27 03:58:23 -07:00
branderson 4163ec12cd Point the headless-push setup at [identity.default]
tests / confined (proxy) (pull_request_target) Successful in 1m15s
tests / confined (conversation) (pull_request_target) Successful in 1m29s
tests / runner-env (confined) (pull_request_target) Successful in 38s
tests / confined (shellcheck) (pull_request_target) Successful in 1m1s
tests / confined (providers) (pull_request_target) Successful in 3m6s
tests / privileged (pull_request_target) Successful in 3m57s
tests / runner-env (privileged) (pull_request_target) Successful in 41s
tests / confined (remote) (pull_request_target) Successful in 4m30s
tests / confined (static) (pull_request_target) Successful in 5m47s
tests / confined (project) (pull_request_target) Successful in 6m22s
tests / confined (audit) (pull_request_target) Successful in 9m56s
tests / confined (config) (pull_request_target) Successful in 11m33s
The section walks an operator through creating a deploy key and then shows
them writing it to the deprecated key, which would warn on every launch from
the moment they finished following it.
2026-08-27 03:46:22 -07:00
branderson 028fd8fb7f Warn about [core] ssh_key on every launch that sets it
The note only fired when nothing else named an account, so a config with
[core] ssh_key and a named identity was told nothing - and that config is one
[identity.default] away from refusing to start. Being told on the launch that
breaks is worse than being told now.

It names the key's path, read from the file rather than through config_get:
in this process config_get is bin/dejima's wrapper, so with a named identity
in force it answers with THAT identity's key, and printing it as the thing to
move would name a key the operator is not being asked to touch.

Every launch rather than once, because there is nowhere to record "once" that
a session could not reach, and a deprecation nobody sees until removal is not
a deprecation. It stops the moment the config is migrated, which is the other
half of a warning worth having.
2026-08-27 03:14:55 -07:00
branderson 69713202ad Make [identity.default] the account a session gets, and deprecate [core] ssh_key
[core] ssh_key and an identity's ssh_key were the same thing under two names,
and nothing said which won. Resolution was: the identity's if it named one,
else [core]'s - so an identity that named no key silently pushed with the
default account's, which is the mixing identities exist to prevent. The
paragraph directly beneath that rule in the docs argued against it: pushing
under the default key when you asked for another one does not fail at all,
which is the direction worth being careful about.

Identities are new in this release and [core] ssh_key is not, so this is the
moment the two spellings either converge or calcify.

[identity.default] is now the identity in force when none is named. It is
where a host's ordinary account goes, and having a name is what lets it carry
a git name and a forge credential beside the key rather than those coming
from three places.

[core] ssh_key is deprecated, not removed. A config with no [identity.default]
reads it exactly as before and says once at launch what replaced it. A config
with both is refused rather than ranked: which key a session pushed with is
not a thing to work out from precedence rules.

Credentials no longer fall through either. An identity that names some and
leaves the rest to the section beneath is refused, naming each one - the same
failure as a missing section, reached through a partial one. Only the selected
forge's keys, since refusing over a credential nothing on this launch reads
would let an unrelated section block a launch.

One gap is documented rather than left to be found: a forge key with a schema
default still answers for an identity that names none, because nothing in the
file says so.

Both refusals and the missing-section one are now a single identity_validate,
which --print-config calls too. It had its own copy of the first check, and
would otherwise have printed this machine's credentials under an identity's
name - the confusion the refusal exists to prevent.

Performance: identity_get runs on every config_get through bin/dejima's
wrapper. Resolving the identity before checking whether the key could belong
to one turned a --print-config from 2.4s to 41s, walking every forge manifest
per key to discover the key was core.harness. The cheap guard runs first now,
the manifest lookup last, and a test pins that order rather than a timing.
2.4s -> 2.6s on the worst case, which reads every key.

config 184, providers 94, static 400, project 145, remote 111, audit 164,
proxy 32, conversation 12.
2026-08-27 03:14:10 -07:00
branderson decf60fa9e Merge pull request 'Put the instructions before the essays, and cut what was padding' (#257) from prose-economy into development
Reviewed-on: #257
Reviewed-by: branderson <brad@branderson.io>
2026-08-27 03:10:45 -07:00
branderson 5c0f4b80c3 Restore three passages the economy pass should not have cut
tests / confined (proxy) (pull_request_target) Successful in 1m14s
tests / confined (conversation) (pull_request_target) Successful in 1m27s
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / confined (shellcheck) (pull_request_target) Successful in 57s
tests / confined (providers) (pull_request_target) Successful in 2m51s
tests / confined (remote) (pull_request_target) Successful in 4m20s
tests / privileged (pull_request_target) Successful in 3m58s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
tests / confined (static) (pull_request_target) Successful in 5m49s
tests / confined (project) (pull_request_target) Successful in 6m3s
tests / confined (audit) (pull_request_target) Successful in 9m53s
tests / confined (config) (pull_request_target) Successful in 9m59s
All three are the project's own position rather than padding, and the pass
treated them as padding because each was short.

The default-branch claim gets its caveat back: none of the three workflows
puts work on your default branch because of how they are arranged, not
because anything is enforced against a session that has stopped cooperating.
Without it that claim reads as a guarantee.

"Understand what you ship" returns as a README bullet. It was the only one of
the ten responsible-use rules with no bullet left; the other two that lost
theirs were folded into neighbours rather than dropped.

And the claim that carries it - a good model is not a substitute for
engineering judgement or human review - is back in both places it was made,
the README bullet and the section in docs/responsible-use.md that argues for
it. Spelled "judgement" there now, matching every other use in the tree.
2026-08-27 02:54:24 -07:00
branderson 1587a154fd Fix two identity references left behind when the examples were renamed
tests / confined (proxy) (pull_request_target) Successful in 1m17s
tests / confined (conversation) (pull_request_target) Successful in 1m27s
tests / runner-env (confined) (pull_request_target) Successful in 37s
tests / confined (shellcheck) (pull_request_target) Successful in 59s
tests / confined (providers) (pull_request_target) Successful in 2m49s
tests / privileged (pull_request_target) Successful in 3m55s
tests / confined (remote) (pull_request_target) Successful in 4m20s
tests / runner-env (privileged) (pull_request_target) Successful in 41s
tests / confined (static) (pull_request_target) Successful in 5m46s
tests / confined (project) (pull_request_target) Successful in 6m1s
tests / confined (audit) (pull_request_target) Successful in 9m56s
tests / confined (config) (pull_request_target) Successful in 9m57s
The example identities became agent-gitea/agent-github/agent-vendor, and
two references to the old person-shaped pair survived: "the `oss` identity
above", pointing at a section that no longer exists, and `wrok`, which read
as a typo of `work` when nothing called `work` was left to typo.

No identity in the block omits `token_file` on the gitea path, so the
fall-through paragraph states the rule without naming one. The refusal
example becomes a typo of a name that is actually there.

The neighbouring test passed throughout, because it matches the section
spelling (`identity.oss`) and the prose says "the oss identity". It now
also resolves every identity the prose names against the example block,
allowing an undefined name only on the sentence that introduces it as
having no section - which is the one place naming a missing section is the
point. Verified both ways: a reintroduced `oss` reference fails it, and the
deliberate no-section name still passes.
2026-08-27 02:33:15 -07:00
branderson 17602234de Address review: voice note and badge back to the top, fix a pointer's render
tests / confined (proxy) (pull_request_target) Successful in 1m15s
tests / confined (conversation) (pull_request_target) Successful in 1m27s
tests / runner-env (confined) (pull_request_target) Successful in 39s
tests / confined (shellcheck) (pull_request_target) Successful in 58s
tests / confined (providers) (pull_request_target) Successful in 2m46s
tests / privileged (pull_request_target) Successful in 3m47s
tests / confined (remote) (pull_request_target) Successful in 4m0s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (static) (pull_request_target) Successful in 5m11s
tests / confined (project) (pull_request_target) Successful in 5m25s
tests / confined (config) (pull_request_target) Successful in 8m45s
tests / confined (audit) (pull_request_target) Successful in 8m51s
The developer's note is the one thing in the README that is not the agent
voice, so it contextualizes everything after it and belongs below the pitch
rather than tucked inside the agent-perspective section. The badge goes back
with it, at the top rather than under documentation.

The docs/security.md pointer under "GitHub push" had its dash starting the
following line, which markdown reads as a list item - so it rendered as a
bullet where every other pointer on the page renders as a paragraph. Dash
moved to the end of the link line, matching the rest.
2026-08-27 01:28:11 -07:00
branderson 7585c4e8b6 Put the instructions before the essays, and cut what was padding
tests / confined (proxy) (pull_request_target) Successful in 1m12s
tests / confined (conversation) (pull_request_target) Successful in 1m25s
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / confined (shellcheck) (pull_request_target) Successful in 57s
tests / confined (providers) (pull_request_target) Successful in 2m38s
tests / privileged (pull_request_target) Successful in 3m41s
tests / confined (remote) (pull_request_target) Successful in 4m4s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
tests / confined (static) (pull_request_target) Successful in 5m32s
tests / confined (project) (pull_request_target) Successful in 5m47s
tests / confined (audit) (pull_request_target) Successful in 9m38s
tests / confined (config) (pull_request_target) Successful in 9m37s
A reader arriving at the README met most of two thousand words of pitch,
name history, agent perspective and a ten-point responsible-use essay
before the first instruction. Setup was the ninth section on the page.

README: reordered to pitch, bounds, setup, usage, sign-in, providers,
responsible use, the per-feature sections, then the agent perspective,
the name and the indexes. Setup is four numbered steps plus two
independent optional blocks, with the prerequisites folded in. "Using
this responsibly" was a near-duplicate of docs/responsible-use.md and is
now seven bullets with the reasoning left where it lives. The Ctrl-Z
explanation moves to docs/configuration.md. 6435 words to 5253.

remote-access.md: the bind address, the one step where a plausible
choice is a full sandbox escape, sat below seventy lines of config
validation prose. It, and picking and verifying the address, now come
first; the config reference follows, example and restart command ahead
of its validation rules; the diagrams move below the front doors.

configuration.md: the approval workflow was reached only after ~1100
words on the corpus rule and tarball digests. Those follow it now. Fixes
a docs/security.md link that needed to be security.md from inside docs/.

security.md: the exit-time audit was a single 1800-word bullet list.
Same content under five subheadings. Records that a walked report is
evicted by rotation rather than by ack, which the README carried and no
longer does.

responsible-use.md: economy pass, no rule removed, and its opening no
longer counts the README's rules.

threat-model.md: says what the document is before saying how much to
trust it.
2026-08-27 00:15:11 -07:00
branderson a5b35c3750 Merge pull request 'Keep the update-check cache with the rest of dejima's host state' (#256) from cache-home into development
Reviewed-on: #256
Reviewed-by: branderson <brad@branderson.io>
2026-08-26 23:45:35 -07:00
branderson 4bdf3a7f14 Merge pull request 'Correct three doc claims the code no longer supports' (#251) from doc-fixes into development
Reviewed-on: #251
Reviewed-by: branderson <brad@branderson.io>
2026-08-26 23:44:43 -07:00
branderson 6f9b93517e Merge pull request 'Document identities as the session's accounts, not the operator's' (#255) from identity-docs into development
Reviewed-on: #255
Reviewed-by: branderson <brad@branderson.io>
2026-08-26 23:44:21 -07:00
branderson 6bb5d0ef3c Drop the XDG_STATE_HOME pinning the cache move makes unnecessary
tests / confined (conversation) (pull_request_target) Successful in 1m29s
tests / confined (proxy) (pull_request_target) Successful in 1m14s
tests / confined (providers) (pull_request_target) Successful in 2m40s
tests / confined (shellcheck) (pull_request_target) Successful in 1m1s
tests / privileged (pull_request_target) Successful in 3m44s
tests / runner-env (confined) (pull_request_target) Successful in 38s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (remote) (pull_request_target) Successful in 4m10s
tests / confined (project) (pull_request_target) Successful in 5m37s
tests / confined (audit) (pull_request_target) Successful in 9m18s
tests / confined (config) (pull_request_target) Successful in 9m20s
tests / confined (static) (pull_request_target) Successful in 5m39s
#253 set XDG_STATE_HOME in four launch helpers because an operator who
exported it moved the update-check cache out from under a fixture's HOME
override, so the suite would write into their real state directory. The cache
is built from $HOME now, which a fixture setting HOME cannot be redirected
around, so the pinning guards nothing and its comment describes a hazard that
no longer exists.
2026-08-26 23:13:46 -07:00
branderson 7b82328541 Document identities as the session's accounts, not the operator's
tests / confined (proxy) (pull_request_target) Successful in 1m17s
tests / confined (conversation) (pull_request_target) Successful in 1m30s
tests / runner-env (confined) (pull_request_target) Successful in 38s
tests / confined (shellcheck) (pull_request_target) Successful in 1m3s
tests / confined (providers) (pull_request_target) Successful in 2m50s
tests / privileged (pull_request_target) Successful in 3m49s
tests / confined (remote) (pull_request_target) Successful in 4m12s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (static) (pull_request_target) Successful in 5m18s
tests / confined (project) (pull_request_target) Successful in 5m34s
tests / confined (config) (pull_request_target) Successful in 9m11s
tests / confined (audit) (pull_request_target) Successful in 9m13s
configuration.md said an [identity.<name>] groups what belongs to a person
rather than to a project, and gave "work" and "oss" as the example - one
human's two accounts. The README and docs/security.md both say the opposite:
give the session an account that is not you. The section describing the
mechanism for holding several of those described it as a way of switching
between your own.

Rewritten around what it is for. An identity is one agent account: the key,
the token, the name on the commits, so a launch pointed at a different account
is one word rather than four edits. The examples are a gitea agent, a GitHub
agent, and a second GitHub agent for a repository the first may not touch -
which is the arrangement it exists to make possible, one host running a
session against each at the same time.

The reason there is more than one is stated rather than assumed: three
accounts are three things to scope, revoke and read in a log independently, so
a session that turns out to have been prompt-injected reached what its own
account could reach.

Also corrected: the [core] identity row said the section supplies "this
project's" credentials, which is the same confusion in one line; providers.md
described identity_keys as a forge's person-owned keys; and the closing
paragraph implied a project can already ask for an identity. It cannot -
there is no .dejima/ kind for one - and what a project layer would mean is
not "this project's credentials", which cannot be committed, but "whichever
account you have called X".

One test, mutation-checked against both the old framing and the old example.

static 394, providers 94.
2026-08-26 23:13:30 -07:00
branderson 4c63db4891 Correct three doc claims the code no longer supports
tests / confined (audit) (pull_request_target) Successful in 9m17s
tests / confined (config) (pull_request_target) Successful in 9m16s
tests / confined (conversation) (pull_request_target) Successful in 1m25s
tests / confined (project) (pull_request_target) Successful in 5m41s
tests / confined (providers) (pull_request_target) Successful in 2m40s
tests / confined (proxy) (pull_request_target) Successful in 1m11s
tests / confined (remote) (pull_request_target) Successful in 4m15s
tests / confined (static) (pull_request_target) Successful in 5m38s
tests / confined (shellcheck) (pull_request_target) Successful in 1m2s
tests / runner-env (confined) (pull_request_target) Successful in 38s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / privileged (pull_request_target) Successful in 3m59s
Release-review findings 5 and 7, plus the minor items grouped with them.
Finding 6 - configuration.md and providers.md claiming a project-side identity
layer that does not exist - is not here: it is superseded by #255, which
rewrites that whole section rather than the two sentences, and two PRs editing
the same paragraph is how both of them end up conflicting.

The capability floor said "the seven it actually uses. Six are the
entrypoint's own"; compose grants eight. The counts are gone rather than
corrected - a number in prose that has to match a list in a file is one the
repo's own rule says not to write. The doc names the split and points at
docker-compose.yml.

All three shipped workflows set block_merge = on, and the README still said
the merge guard was "inert unless that workflow turns it on" - true of the
mechanism, misleading about what you get. It now says every shipped workflow
turns it on, and that the switch is there for a third-party workflow.

Minor, same branch: tools.md was missing from the README docs index, and
.dejima.md's suite table gained rows for bin/dejima-tty-suspend and tools/.

Three tests, all mutation-checked, and two of them derived rather than
grepped: the docs index is checked against docs/ itself (release.md excluded
by name, as the maintainer's process), and the merge claim is checked against
the workflow manifests, so a workflow shipped with block_merge off fails the
test instead of leaving the README wrong. The third refuses a capability count
coming back.
2026-08-26 23:07:18 -07:00
branderson 8368ffb67a Keep the update-check cache with the rest of dejima's host state
The cache went to ${XDG_STATE_HOME:-~/.local/state}/dejima/update-check. By
the letter of the spec that is right - a timestamp and a hash that can be
deleted at any moment is what the state directory is for - but it made
~/.local/state a fifth place dejima writes, for one file, and `mkdir -p`
created that base directory on hosts with no other use for it. It was also
the only host-side path dejima had that no document mentioned.

It is ~/.local/share/dejima/update-check now, beside the audit store, the
epitaphs, the baselines and the shared directory. One root is worth more than
the distinction between data and state.

No config key, where the four directories beside it have one: those are
records an operator reads, or a directory that gets mounted. Nothing is
served by being able to relocate a hash.

This also removes the reason the tests had to pin XDG_STATE_HOME. An operator
who exports it moved the cache out from under a fixture's HOME override, so
tests could write into their real state directory; a path under HOME cannot
be redirected out from under a fixture that sets HOME.

A test refuses a new state root coming back, reading code rather than
comments - _cache_file's own comment says what it moved away from.

config 176, static 395.
2026-08-26 22:31:27 -07:00
branderson edc55d258f Merge pull request 'Offer the running sessions to pick from, instead of listing them to copy' (#254) from allow-picker into development
Reviewed-on: #254
Reviewed-by: branderson <brad@branderson.io>
2026-08-26 22:08:22 -07:00
branderson a41bae1b2f Merge pull request 'Stop the launch fixtures reaching the network to check for updates' (#253) from fixture-update into development
Reviewed-on: #253
Reviewed-by: branderson <brad@branderson.io>
2026-08-26 22:07:07 -07:00
branderson 88de8b8670 Offer the running sessions to pick from, instead of listing them to copy
tests / confined (audit) (pull_request_target) Successful in 9m9s
tests / confined (config) (pull_request_target) Successful in 8m58s
tests / confined (conversation) (pull_request_target) Successful in 1m27s
tests / privileged (pull_request_target) Successful in 3m40s
tests / runner-env (privileged) (pull_request_target) Successful in 35s
tests / confined (shellcheck) (pull_request_target) Successful in 59s
tests / confined (proxy) (pull_request_target) Successful in 1m13s
tests / confined (providers) (pull_request_target) Successful in 2m34s
tests / runner-env (confined) (pull_request_target) Successful in 42s
tests / confined (static) (pull_request_target) Successful in 5m27s
tests / confined (remote) (pull_request_target) Successful in 3m44s
tests / confined (project) (pull_request_target) Successful in 5m20s
With several sessions up, `dejima-allow <domain>` printed run ids and stopped.
The name column was a fixed 28 and "dejima-sandbox-run-" plus twelve hex
digits is 31, so every real name overran it and the project beside it started
a space later on each row - there was no telling where one ended and the next
began, and the answer was something to copy back into a flag.

On a terminal it now offers them, numbered, by project, under the same
heading dejima-attach uses. Anywhere else - a script, a pipe, a session
asking its operator to run this - it prints the same rows and stops, because
a prompt nobody can answer is worse than a refusal. Both ends of the tty are
checked, since half of either is how something ends up hung on a read.

The palette, the heading and the numbered prompt move to
bin/dejima-tui-lib.sh. Picking one session out of several is the same job in
both tools and an operator holding one terminal should not meet two answers
to it. Rows stay with each tool: they list different things from different
sources, so the columns are each tool's own, measured against its own data.

Two things this turned up, both found by rendering it rather than reading it:

The single-container assignment after the disambiguation block ran
unconditionally. It was reachable only in the single case before, because the
multi case exited - with the picker returning from that branch it silently
discarded the choice and acted on whichever session docker listed first.

tui_pick returns its answer in a variable rather than on stdout. In
`$(tui_pick ...)` it runs in a subshell, where the `exit 0` on `q` ends the
subshell and hands the caller an empty string to use as an index.

Columns are now padded to the widest value in both forms, and the attach
column is absent rather than blank when no session has a number.

Four tests, all mutation-checked: no tty means no prompt, the number typed is
the row acted on, q allows nothing, and the name starts at one column on
every row. static 393, project 145, remote 109, config 172, providers 94.
2026-08-26 21:51:46 -07:00
branderson baff452a22 Merge pull request 'Send one notification per stop, not a second one a minute later' (#238) from notify-once into development
Reviewed-on: #238
Reviewed-by: branderson <brad@branderson.io>
2026-08-26 21:49:51 -07:00
branderson aa78ff896f Send one notification per stop, not a second one a minute later
tests / confined (proxy) (pull_request_target) Successful in 1m14s
tests / runner-env (confined) (pull_request_target) Successful in 41s
tests / confined (conversation) (pull_request_target) Successful in 1m27s
tests / confined (shellcheck) (pull_request_target) Successful in 1m0s
tests / confined (providers) (pull_request_target) Successful in 2m48s
tests / confined (project) (pull_request_target) Successful in 5m59s
tests / confined (config) (pull_request_target) Successful in 9m58s
tests / privileged (pull_request_target) Successful in 4m0s
tests / confined (remote) (pull_request_target) Successful in 4m16s
tests / runner-env (privileged) (pull_request_target) Successful in 41s
tests / confined (static) (pull_request_target) Successful in 5m41s
tests / confined (audit) (pull_request_target) Successful in 10m5s
Claude Code raises Notification when it needs permission to run something and
when the prompt has sat idle. A dejima session runs with permission prompts
off, so the first never happens - what is left is a timer that expires
shortly after the Stop it follows. Registering both handlers put two pushes
on the operator's phone for one event, about a minute apart, the second
saying nothing the first had not.

Debouncing cannot merge them and should not: the watcher keys on run id AND
event, so waiting and done stay separate signals for a harness that can tell
them apart. The fix is to stop registering the one this harness cannot
distinguish.

`waiting` stays in the hook's enum and in the watcher's. The watcher's is the
security boundary rather than a list of what gets registered - the marker
directory is bind-mounted read-write, so a session creates whatever names it
likes, and dropping the arm would leave a marker that accumulates instead of
one that is consumed. Both are covered.

What this gives up is being told about a session that has gone idle without
having stopped first, which Claude Code does not currently distinguish. Said
plainly in docs/remote-access.md rather than left for someone to find missing.

Also corrects three comments and a troubleshooting step that named a hook no
longer registered - the step told operators to expect 2 dejima-notify entries
in the settings layer, which would now read as a fault.

Tests: two new in remote.bats, both mutation-checked - one that the idle
timer is not registered, one that the watcher still consumes a waiting marker
nothing writes. One in static.bats pinning why, and what it costs.

Suites: remote 111, providers 94, static 387, config 162.
2026-08-26 20:54:46 -07:00
46 changed files with 5347 additions and 1800 deletions
+32 -27
View File
@@ -24,42 +24,47 @@ relaunch so the derived image is rebuilt), not installing it in-session.
If it is missing and you cannot get a rebuild, say the suites were not run
rather than reporting a change as tested.
## Run the suite for what you changed, not all of them
## Two lanes: one for iterating, one for the pull request
The suites are independent and cover different areas. Running all of them for
every edit is the single biggest drag on iteration here — reach for the whole
set before opening a PR, not between edits.
Between edits:
| You changed | Run |
| --- | --- |
| `bin/dejima-audit` | `test/audit.bats` |
| `bin/dejima-config-lib.sh`, `install.sh` | `test/config.bats` |
| `bin/dejima-provider-lib.sh`, `forges/`, `workflows/`, `harnesses/` | `test/providers.bats` |
| `bin/dejima-attach`, `-ttyd`, `-notify-watch`, `web/`, `systemd/` | `test/remote.bats` |
| `bin/dejima-project-lib.sh`, `-dep`, `-project`, `-allow` | `test/project.bats` |
| `bin/dejima-update-check` | `test/config.bats` |
| `bin/dejima-anthropic-proxy`, `docker/anthropic-proxy-forwarder.js` | `test/proxy.bats` |
| `docker/`, `hooks/`, `README.md`, `docs/` | `test/static.bats` |
| anything under `docker/` that has to actually boot | `test/runtime.bats` (needs Docker) |
```sh
bash test/run-confined.sh --changed
```
When the whole set genuinely is wanted, run it concurrently rather than one
file after another:
It reads `git diff` — committed, uncommitted and untracked — maps each path to
the suites that cover it, and runs only those. `--list` prints the selection
without running anything, and a path given as an argument stands in for one git
would have reported, so `--changed --list docker/Dockerfile` answers "what would
this change run?" directly.
Before opening a pull request, and for any claim that a change is tested:
```sh
bash test/run-confined.sh
```
The suites share no state, so they run together safely: about seven minutes
instead of about twenty-five. It reports what each suite ran as well as what
failed, because a suite that cannot start - a syntax error, or duplicate test
names, which bats refuses outright - exits nonzero having run nothing, and
that reads as "no failures" to anything counting only failures.
That is the gate: every confined suite, no selection. Each run prints which lane
it was, and `--changed` says in as many words that it is not the gate. **A green
`--changed` run is not a tested change** — it is evidence about the suites it
names and nothing about the ones it skipped.
Two things that are not in that table because they defeat it: `bin/dejima`
itself and `bin/dejima-config-lib.sh` are read by nearly every other suite, so a
change to either is one of the cases where the whole set genuinely applies. And
a change to a shipped doc or a `docker/` file usually needs `static.bats` *as
well as* whatever else it touched — that suite asserts on files it does not own.
The map lives in `suites_for()` in `test/run-confined.sh` rather than in a table
here, so that it runs instead of being remembered, and so an agent cannot map a
change to the wrong suite by hand. A path matching no rule selects every suite:
a path nobody has mapped is a path nobody has decided about. When you add a
file that a specific suite covers, add the rule — `static.bats` checks the map
names only suites that exist, but nothing can tell that a rule is too narrow.
Both lanes run the tests within each file concurrently as well as the files
themselves, which is most of where the wall clock went. It is bats' own
`--jobs`, and it needs `flock` rather than GNU parallel; without `flock` the
script falls back to one at a time and says so.
Each run reports what every suite ran as well as what failed, because a suite
that cannot start — a syntax error, or duplicate test names, which bats refuses
outright — exits nonzero having run nothing, and that reads as "no failures" to
anything counting only failures.
**CI runs `test/runtime.bats` on every pull request**, as a separate
`privileged` job in `.gitea/workflows/tests.yml`. So being unable to run it
+20 -2
View File
@@ -87,6 +87,7 @@ jobs:
- config
- providers
- conversation
- share
name: confined (${{ matrix.suite }})
container:
# Debian bookworm plus a node binary - node is what actions/checkout
@@ -163,9 +164,16 @@ jobs:
# they fail on the terminal rather than on anything they mean to check.
# xterm-256color ships in ncurses-base, which is Essential - nothing to
# install. Set on the command rather than in env: so it survives su.
#
# --jobs runs the tests within the file concurrently. bats needs GNU
# parallel only to parallelize ACROSS files; within one file it uses its
# own flock-based semaphore, and flock is in util-linux, which Debian
# marks Essential - so nothing here has to install anything for it.
# --no-parallelize-across-files is what tells bats that, since it
# otherwise refuses --jobs outright when GNU parallel is absent.
- run: |
chown -R node:node .
su node -c "TERM=xterm-256color bats test/${{ matrix.suite }}.bats"
su node -c "TERM=xterm-256color bats --jobs $(nproc) --no-parallelize-across-files test/${{ matrix.suite }}.bats"
shellcheck:
# Its own job rather than a matrix entry: it installs a different package,
@@ -425,8 +433,18 @@ jobs:
# SKIP_BUILD below then reuses this image rather than skipping a build.
# The image is still built fresh from this commit, so the property the
# test file defaults to - never assert against a stale image - holds.
# Named under localhost/ so a stray push cannot resolve to Docker Hub,
# like every other image this repo builds (#53).
#
# This step and runtime.bats must agree on that name, and they come from
# different commits: this file is read from the BASE branch (see the
# pull_request_target note at the top) while the checkout above is the
# branch under test. So a rename here lands in two steps - the base
# tagging both names first, then this - and a future one has to do the
# same. A single pull request that changes both sides fails, because the
# half that runs is the half it did not change.
- name: Build the sandbox image
run: docker build -t dejima:latest -f docker/Dockerfile .
run: docker build -t localhost/dejima:latest -f docker/Dockerfile .
- run: bats test/runtime.bats
env:
+1 -1
View File
@@ -1,7 +1,7 @@
.claude
web/node_modules
# Python bytecode. bin/dejima-anthropic-proxy is a python program, so anything
# Python bytecode. bin/dejima-credential-proxy is a python program, so anything
# that imports or compiles it leaves these beside it.
__pycache__/
*.pyc
+508 -604
View File
File diff suppressed because it is too large Load Diff
+137 -26
View File
@@ -446,13 +446,9 @@ if [ "$PRINT_CONFIG" = 1 ]; then
# nothing would show this machine's own credentials as though they were
# the identity's - the exact confusion the refusal exists to prevent, in
# the one command an operator runs to check what they configured.
_identity=""
config_exists && { _identity="$(config_get core identity)" || _identity=""; }
if [ -n "$_identity" ] && ! identity_exists "$_identity"; then
echo "[core] identity is '$_identity', and no [identity.$_identity] section sets" >&2
echo "anything in $(config_path)." >&2
exit 1
fi
# The forge is read here rather than taken from DEJIMA_FORGE_NAME, which
# is resolved further down, past this early exit.
identity_validate "$(config_get core forge)" || exit 1
while IFS= read -r _key; do
_section="${_key%.*}"
_leaf="${_key##*.}"
@@ -626,11 +622,18 @@ SANDBOX_EXTRA_MOUNTS="$(_append_mounts "$SANDBOX_EXTRA_MOUNTS" \
# operator names in [mounts] readonly, which is computed at run time. Listed
# whatever [epitaph] enabled says: turning the feature off stops new
# epitaphs being written, it does not empty the store.
#
# The per-project share root is here because the ~/shared mount is only per
# project while nothing else hands the whole of it over. A session's own share
# arrives at ~/shared without going through this list; naming the root that
# holds every project's would put the pile back, which is the thing the split
# removed.
GUARDED_STORES=()
_guarded_stores() {
[ ${#GUARDED_STORES[@]} -eq 0 ] || return 0
GUARDED_STORES=("$(config_root)" "$AUDIT_DIR" "$BASELINE_DIR" \
"$(config_get epitaph dir)")
"$(config_get epitaph dir)" \
"$(share_projects_root "$(config_get core share_dir)")")
}
# Refuse $2 if it overlaps any of the paths in $3.., in either direction: an
@@ -648,8 +651,9 @@ _refuse_guarded_mount() {
_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
echo "Even read-only, that hands the session every project's approvals, the" >&2
echo "record of what earlier sessions were caught doing, or every project's" >&2
echo "handoff files." >&2
exit 1
done
}
@@ -689,8 +693,9 @@ if [ -n "$SANDBOX_RW_MOUNTS" ]; then
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
echo "that runs unsandboxed on the host the next time you start an agent." >&2
echo "dependencies, delete the audit findings it is named in, read and rewrite" >&2
echo "every project's handoff files, or plant code that runs unsandboxed on the" >&2
echo "host the next time you start an agent." >&2
exit 1
done
# A path in both lists would be resolved by whichever mount Docker
@@ -825,8 +830,10 @@ fi
# confirm" is not a reason to allow it.
DEJIMA_FORGE_REPO_FLAGS="$(manifest_get forge "$DEJIMA_FORGE_NAME" repo_flags)"
DEJIMA_FORGE_REPO_ENV="$(manifest_get forge "$DEJIMA_FORGE_NAME" repo_env)"
DEJIMA_FORGE_REPO_PATHS="$(manifest_get forge "$DEJIMA_FORGE_NAME" repo_paths)"
DEJIMA_FORGE_REPO=""
if [ -n "$DEJIMA_FORGE_REPO_FLAGS" ] || [ -n "$DEJIMA_FORGE_REPO_ENV" ]; then
if [ -n "$DEJIMA_FORGE_REPO_FLAGS" ] || [ -n "$DEJIMA_FORGE_REPO_ENV" ] ||
[ -n "$DEJIMA_FORGE_REPO_PATHS" ]; then
_origin_url="$(git -C "$WORKSPACE_DIR" remote get-url origin 2>/dev/null)" || _origin_url=""
if [ -n "$_origin_url" ]; then
_origin_url="${_origin_url%/}"
@@ -976,9 +983,35 @@ fi
# touching the actual project checkout. Persists across sessions (it's a
# real host directory, not a container volume) since the point is sharing
# things with the host, not just scratch space for one run.
SANDBOX_SHARE_DIR="$(config_get core share_dir)"
#
# One directory per project, not one for the host. `[core] share_dir` was
# mounted whole, so every session saw the handoff files of every session
# before it and of every session running beside it, across projects - the same
# boundary ~/.claude is already sliced per project to hold. What the container
# sees is still ~/shared, so nothing an agent has been told changes; what it
# cannot see any more is another project's.
SANDBOX_SHARE_ROOT="$(config_get core share_dir)"
SANDBOX_SHARE_DIR="$(share_dir_for "$SANDBOX_SHARE_ROOT" "$WORKSPACE_DIR")"
mkdir -p "$SANDBOX_SHARE_DIR"
# Files an operator left in share_dir before the split are still there and are
# no longer mounted anywhere. Said every launch while that is true, because
# the alternative is a file that was handed to a session yesterday silently
# not arriving today.
share_loose="$(find "$SANDBOX_SHARE_ROOT" -mindepth 1 -maxdepth 1 \
! -name projects -printf '%f\n' 2>/dev/null | sort)" || true
if [ -n "$share_loose" ]; then
echo "Note: $SANDBOX_SHARE_ROOT holds entries no session can see any more," >&2
echo " because ~/shared is now this project's own subdirectory of it:" >&2
while IFS= read -r _entry; do
[ -n "$_entry" ] || continue
printf ' %s\n' "$_entry" >&2
done <<< "$share_loose"
echo " Move them into a project's share with 'dejima-share adopt <path>'," >&2
echo " or delete them. 'dejima-share list' shows what each project holds." >&2
fi
unset share_loose _entry
# ~/.claude is not mounted as a whole (see the mount inventory in
# docker-compose.yml) - every path that crosses into the container is named
# individually, and Docker answers a missing bind source by creating a
@@ -1425,6 +1458,7 @@ extra_mounts+=(-e "DEJIMA_MERGE_BLOCK=$DEJIMA_MERGE_BLOCK")
extra_mounts+=(-e "DEJIMA_DISCLOSE=$DEJIMA_DISCLOSE")
extra_mounts+=(-e "DEJIMA_FORGE_REPO=$DEJIMA_FORGE_REPO")
extra_mounts+=(-e "DEJIMA_FORGE_REPO_FLAGS=$DEJIMA_FORGE_REPO_FLAGS")
extra_mounts+=(-e "DEJIMA_FORGE_REPO_PATHS=$DEJIMA_FORGE_REPO_PATHS")
extra_mounts+=(-e "DEJIMA_FORGE_REPO_ENV=$DEJIMA_FORGE_REPO_ENV")
# The harness's own configuration, mounted at the mode it declared, and -
@@ -1617,7 +1651,7 @@ done
export SANDBOX_HARNESS_SEED
# The real Anthropic OAuth credential never enters the container: a host-side
# proxy injects it per request (bin/dejima-anthropic-proxy, docs/security.md).
# proxy injects it per request (bin/dejima-credential-proxy, docs/security.md).
# ~/.claude is not mounted whole, so the credential has no path into the
# container and nothing needs masking (#50, docs/security.md).
#
@@ -1644,11 +1678,23 @@ SANDBOX_FAKE_CREDENTIALS_FILE=""
SANDBOX_ANTHROPIC_PROXY_PID=""
if [ "$(config_get_bool credentials proxy)" != "0" ] \
&& jq -e '.claudeAiOauth.accessToken' "$HOME/.claude/.credentials.json" >/dev/null 2>&1; then
SANDBOX_ANTHROPIC_PROXY_DIR="$(mktemp -d "${TMPDIR:-/tmp}/dejima-anthropic-proxy.XXXXXX")"
SANDBOX_ANTHROPIC_PROXY_DIR="$(mktemp -d "${TMPDIR:-/tmp}/dejima-credential-proxy.XXXXXX")"
SANDBOX_ANTHROPIC_PROXY_SOCKET="$SANDBOX_ANTHROPIC_PROXY_DIR/proxy.sock"
"$SCRIPT_DIR/dejima-anthropic-proxy" \
# Every Anthropic-shaped value is stated here rather than defaulted in the
# proxy, which now knows no provider's name (#171). When the provider layer
# lands these come from a manifest instead; until then this is the one
# caller and this is where the arrangement lives.
"$SCRIPT_DIR/dejima-credential-proxy" \
--socket "$SANDBOX_ANTHROPIC_PROXY_SOCKET" \
--credentials "$HOME/.claude/.credentials.json" &
--credentials "$HOME/.claude/.credentials.json" \
--upstream "https://api.anthropic.com" \
--credential-kind anthropic-oauth \
--oauth-endpoint "https://platform.claude.com/v1/oauth/token" \
--oauth-client-id "9d1c250a-e61b-44d9-88ed-5944d1962f5e" \
--auth-header Authorization \
--auth-format "Bearer {token}" \
--strip-header x-api-key \
--ensure-header "anthropic-beta=oauth-2025-04-20" &
SANDBOX_ANTHROPIC_PROXY_PID=$!
for _ in $(seq 1 50); do
[ -S "$SANDBOX_ANTHROPIC_PROXY_SOCKET" ] && break
@@ -1870,6 +1916,59 @@ fi
# 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.
# What this launch is, before the long silent part. The startup output is
# otherwise a list of checks with no point at which it says what it is, and
# the facts below are the ones that differ between one launch and the next -
# the ones an operator otherwise has to remember or go and read (#211).
#
# Deliberately not a config dump. The value is seeing at a glance that this
# launch is not configured like the last one, so what is here is the selection
# plus the switches whose state changes what a session can do.
#
# In bin/dejima's own palette rather than the pickers': dejima-color-lib.sh
# and dejima-tui-lib.sh both define C_OK and C_WARN, with different values, so
# sourcing the second here would silently recolour every warning this script
# already prints. Unifying the two palettes is worth doing and is not this.
startup_banner() {
[ "$(config_get_bool ui banner)" = "1" ] || return 0
# Nothing at all when stderr is not a terminal: a redirected launch is a
# log, and this is decoration in it.
[ -t 2 ] || return 0
local branch model domains firewall remote epitaph
branch="$(git -C "$WORKSPACE_DIR" symbolic-ref --quiet --short HEAD 2>/dev/null)" || branch=""
model="$(harness_config_get model 2>/dev/null)" || model=""
# The count, not the list: the list is long and the number is what changes.
domains="$(printf '%s' "${ALLOWED_DOMAINS:-}" | wc -w)"
if [ "${ENABLE_FIREWALL:-1}" = "1" ]; then
firewall="${C_OK}on${C_RESET} ($domains domains)"
else
# The one switch worth a colour of its own: everything else here is a
# choice, and this one is a boundary that is not in force.
firewall="${C_WARN}off${C_RESET}"
fi
remote="$(remote_enabled && echo on || echo off)"
epitaph="$([ "$(config_get_bool epitaph enabled)" = 1 ] && echo on || echo off)"
# Labels dim, values not. The labels are how you find the value you came
# for; the values are what differs between one launch and the next.
# C_BADGE, the same name the pickers set the mark in. Two surfaces
# spelling the wordmark two different colours is the drift a shared
# palette exists to stop.
printf '\n %sdejima%s %s%s%s' \
"$C_BADGE" "$C_RESET" "$C_KEY" "$(basename -- "$WORKSPACE_DIR")" "$C_RESET" >&2
[ -z "$branch" ] || printf ' %s%s%s %s' "$C_DIM" "$GLYPH_SEP" "$C_RESET" "$branch" >&2
printf '\n %sharness%s %s %sforge%s %s %sworkflow%s %s\n' \
"$C_DIM" "$C_RESET" "$DEJIMA_HARNESS_NAME${model:+ ($model)}" \
"$C_DIM" "$C_RESET" "$DEJIMA_FORGE_NAME" \
"$C_DIM" "$C_RESET" "$DEJIMA_WORKFLOW_NAME" >&2
printf ' %sfirewall%s %s %sremote%s %s %sepitaph%s %s\n\n' \
"$C_DIM" "$C_RESET" "$firewall" \
"$C_DIM" "$C_RESET" "$remote" \
"$C_DIM" "$C_RESET" "$epitaph" >&2
}
startup_banner
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
@@ -1909,17 +2008,29 @@ fi
# below will report any change to it.
if [ "$(readlink -f "$WORKSPACE_DIR")" = "$(readlink -f "$REPO_ROOT")" ]; then
echo "Warning: sandbox's own repo is the mounted workspace - its trusted" >&2
echo "computing base (bin/, hooks/, docker/, install.sh) is writable this" >&2
echo "session; the exit audit will report any changes to review." >&2
echo "computing base (bin/, hooks/, docker/, test/, install.sh, and the" >&2
echo "provider and tool manifests) is writable this session; the exit audit" >&2
echo "will report any changes to review." >&2
fi
# Every image this builds is named under localhost/, which Docker reads as a
# registry host rather than as the first path component of a Docker Hub name
# (#53). A bare `dejima:node` resolves to `docker.io/library/dejima:node` the
# moment anyone pushes it, and none of these images is meant to leave the host
# that built them. Pulling the base still goes to Hub, deliberately: that is
# where `node:22` is.
#
# One prefix, used by every tag below and by the compose file's default, so
# there is no second place for a bare name to reappear.
SANDBOX_IMAGE_REPO="localhost/dejima"
# Compose builds the shared base image under its own tag, explicitly - with
# image: now honoring $SANDBOX_IMAGE, letting the derived tag leak into this
# build would rebuild the base Dockerfile under the derived name.
SANDBOX_IMAGE="dejima:${SANDBOX_USER}" \
SANDBOX_IMAGE="${SANDBOX_IMAGE_REPO}:${SANDBOX_USER}" \
docker compose -f "$COMPOSE_FILE" build sandbox
SANDBOX_IMAGE="dejima:${SANDBOX_USER}"
SANDBOX_IMAGE="${SANDBOX_IMAGE_REPO}:${SANDBOX_USER}"
# Each layer builds FROM whatever the previous one left in $SANDBOX_IMAGE, so
# global packages are shared by every project instead of being rebuilt into
@@ -1978,7 +2089,7 @@ fi
if [ -n "$SANDBOX_HARNESS_INSTALL" ] || [ -n "$SANDBOX_HARNESS_IMAGE_DIRS" ] \
|| [ -n "$SANDBOX_HARNESS_IMAGE_ENV" ] || [ -n "$SANDBOX_HARNESS_IMAGE_FILES" ]; then
tag="dejima:${SANDBOX_USER}-harness-${DEJIMA_HARNESS_NAME}"
tag="${SANDBOX_IMAGE_REPO}:${SANDBOX_USER}-harness-${DEJIMA_HARNESS_NAME}"
generate_spec_dockerfile "$SANDBOX_IMAGE" "$SANDBOX_HARNESS_INSTALL" \
"$SANDBOX_HARNESS_IMAGE_DIRS" "$SANDBOX_HARNESS_IMAGE_ENV" \
"$SANDBOX_HARNESS_IMAGE_FILES" \
@@ -2006,7 +2117,7 @@ for _tool in $DEJIMA_TOOLS; do
_tool_install="$(manifest_get tool "$_tool" install)"
_tool_env="$(manifest_get tool "$_tool" image_env)"
_tool_layout="$(manifest_get tool "$_tool" install_layout)"
tag="dejima:${SANDBOX_USER}-tool-${_tool}-$(printf '%s' "$_tool_install" | sha256sum | cut -c1-12)"
tag="${SANDBOX_IMAGE_REPO}:${SANDBOX_USER}-tool-${_tool}-$(printf '%s' "$_tool_install" | sha256sum | cut -c1-12)"
DEJIMA_TARBALL_STRIP="$_tool_layout" \
generate_spec_dockerfile "$SANDBOX_IMAGE" "$_tool_install" "" "$_tool_env" "" \
> "$SANDBOX_DERIVED_DOCKERFILE"
@@ -2024,10 +2135,10 @@ done
unset _tool _tool_install _tool_env _tool_layout
if [ -s "$(config_root)/dependencies" ]; then
build_dep_layer "dejima:${SANDBOX_USER}-global" "$(config_root)"
build_dep_layer "${SANDBOX_IMAGE_REPO}:${SANDBOX_USER}-global" "$(config_root)"
fi
if [ -n "$PROJECT_STORE" ] && [ -s "$PROJECT_STORE/dependencies" ]; then
build_dep_layer "dejima:${SANDBOX_USER}-$(project_slug "$WORKSPACE_DIR")" "$PROJECT_STORE"
build_dep_layer "${SANDBOX_IMAGE_REPO}:${SANDBOX_USER}-$(project_slug "$WORKSPACE_DIR")" "$PROJECT_STORE"
fi
export SANDBOX_IMAGE
+93 -13
View File
@@ -29,11 +29,14 @@ Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>
Scope, which decides who the grant is for and how long it lasts:
(none) the running container, right now, until it exits. Finds it
if exactly one is running; refuses if none or several.
if exactly one is running. With several, it offers them to
pick from on a terminal, and lists them and stops anywhere
else - a prompt is no use to a script.
--container <id> the same, for a named container. Use this when a session
told you its own id rather than letting anything guess.
With several running, they are listed by project and by
the number the dejima-attach picker gives each one.
the number the dejima-attach picker gives each one. On a
terminal it offers them to pick from instead.
--project [path] every future session in one project. Also writes the domain
to that project's .dejima/allowed-domains, so it is shared
with everyone working on it. Path defaults to $PWD.
@@ -63,6 +66,11 @@ source "$SCRIPT_DIR/dejima-project-lib.sh"
# reason init-firewall.sh and dns-proxy.sh share this file.
# shellcheck source=../docker/fixed-domains.sh
source "$SCRIPT_DIR/../docker/fixed-domains.sh"
# The palette, heading and prompt dejima-attach uses. Picking one session out
# of several is the same job here, and an operator holding one terminal should
# not meet two different answers to it.
# shellcheck source=dejima-tui-lib.sh
source "$SCRIPT_DIR/dejima-tui-lib.sh"
usage() {
echo "Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>" >&2
@@ -146,7 +154,15 @@ fi
#
# Reads only. Nothing here decides which container is acted on - that is the
# caller's --container, or the single running one.
describe_containers() {
# Fills CT_IDS, CT_NAMES, CT_PROJECTS and CT_ATTACH, in the order docker
# listed them, and measures the widest project and name for the renderers
# below. Padding to the widest value rather than to a guess is not cosmetic
# here: "dejima-sandbox-run-" plus twelve hex digits is 31 characters, so a
# fixed 28 ran every name straight into the project beside it and there was
# no telling where one ended.
CT_IDS=(); CT_NAMES=(); CT_PROJECTS=(); CT_ATTACH=()
CT_W_NAME=0; CT_W_PROJECT=0; CT_HAS_ATTACH=0
collect_containers() {
local listing="$1" name id project row n=0 idx
local -A attach_index=()
while IFS=$'\t' read -r _ _ _ _ _ row; do
@@ -158,12 +174,61 @@ describe_containers() {
[ -n "$name" ] || continue
project="$(docker inspect -f '{{.Config.WorkingDir}}' "$id" 2>/dev/null)" || project=""
[ -n "$project" ] && project="$(basename "$project")"
project="${project:-?}"
idx="${attach_index[${id:0:12}]:-}"
printf ' %-28s %-24s %s\n' "$name" "${project:-?}" \
"${idx:+(dejima-attach $idx)}"
CT_IDS+=("$id"); CT_NAMES+=("$name"); CT_PROJECTS+=("$project")
CT_ATTACH+=("$idx")
[ -z "$idx" ] || CT_HAS_ATTACH=1
[ "${#name}" -le "$CT_W_NAME" ] || CT_W_NAME="${#name}"
[ "${#project}" -le "$CT_W_PROJECT" ] || CT_W_PROJECT="${#project}"
done <<< "$listing"
}
# The non-interactive form: a list to read, with the flag that acts on a row.
# This is what a script gets, and what an agent is shown when it asks the
# operator to run one of these.
describe_containers() {
local i
for i in "${!CT_IDS[@]}"; do
printf ' %-*s %-*s %s\n' \
"$CT_W_PROJECT" "${CT_PROJECTS[$i]}" \
"$CT_W_NAME" "${CT_NAMES[$i]}" \
"${CT_ATTACH[$i]:+(dejima-attach ${CT_ATTACH[$i]})}"
done
}
# The interactive form. Same rows, numbered, under dejima-attach's heading -
# an operator with several sessions open is choosing between the same things
# in both tools, and picking by project is the only way they can.
#
# Headed, where dejima-attach's rows are not: there the coloured state word
# says what each column is, and vertical space on a phone is the scarce
# thing. Here the columns are two names and a number, which say nothing about
# themselves.
pick_container() {
local i
tui_banner 'running sessions'
# The attach column is absent, not blank, when no session has a number -
# remote access off is the ordinary case, and a labelled empty column
# reads as something that failed.
if [ "$CT_HAS_ATTACH" -eq 1 ]; then
printf ' %s%-*s %-*s %s%s\n' "$C_SUBTLE" \
"$CT_W_PROJECT" "project" "$CT_W_NAME" "container" "attach" "$C_OFF"
else
printf ' %s%-*s %s%s\n' "$C_SUBTLE" \
"$CT_W_PROJECT" "project" "container" "$C_OFF"
fi
for i in "${!CT_IDS[@]}"; do
printf ' %s%2d%s %s%-*s%s %s%-*s%s %s%s%s\n' \
"$C_WARN" "$((i + 1))" "$C_OFF" \
"$C_BOLD$C_TEXT" "$CT_W_PROJECT" "${CT_PROJECTS[$i]}" "$C_OFF" \
"$C_LINK" "$CT_W_NAME" "${CT_NAMES[$i]}" "$C_OFF" \
"$C_MUTED" "${CT_ATTACH[$i]}" "$C_OFF"
done
tui_pick "${#CT_IDS[@]}" "allow $domain in"
container="${CT_NAMES[$((TUI_CHOICE - 1))]}"
}
session_wanted=1
if [ -n "$project" ] || [ "$global" -eq 1 ]; then
[ -n "$container" ] || session_wanted=0
@@ -177,15 +242,30 @@ if [ "$session_wanted" -eq 1 ]; then
echo "No running dejima container found." >&2
exit 1
elif [ "$count" -gt 1 ]; then
echo "Multiple running dejima containers found; specify one with --container:" >&2
echo "" >&2
describe_containers "$containers" >&2
echo "" >&2
echo "(a sandboxed agent's own container ID is printed in its startup context," >&2
echo "or by 'cat /etc/hostname' inside that sandbox - don't let it guess one)" >&2
exit 1
collect_containers "$containers"
# A person at a terminal gets to choose; anything else gets the
# list and the flag. Both ends are checked because the prompt is
# useless without somewhere to draw it and something to read the
# answer from, and half of either is how a script ends up hung on
# a read nobody can answer.
if [ -t 0 ] && [ -t 1 ]; then
pick_container
else
echo "Multiple running dejima containers found; specify one with --container:" >&2
echo "" >&2
describe_containers >&2
echo "" >&2
echo "(a sandboxed agent's own container ID is printed in its startup context," >&2
echo "or by 'cat /etc/hostname' inside that sandbox - don't let it guess one)" >&2
exit 1
fi
fi
container="${containers%%$'\t'*}"
# Only when nothing above chose one. This line used to be reachable
# only in the single-container case, because the multi-container
# branch exited - now that the picker returns from it, an
# unconditional assignment here would silently discard the choice and
# act on whichever session docker happened to list first.
[ -n "$container" ] || container="${containers%%$'\t'*}"
fi
# The DNS proxy (dns-proxy.sh) NXDOMAINs anything not in its allowlist, so
+12 -76
View File
@@ -57,55 +57,20 @@ SESSIONS_DIR="$STATE_DIR/sessions"
# gruvbox dark, every value upstream's and tagged with its color.table row:
# https://github.com/gruvbox-community/gruvbox-contrib/blob/master/color.table
# The names are carried so a wrong hex is catchable by reading.
#
# 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.
#
# 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
C_TEXT=$'\033[38;2;235;219;178m' # light1 #ebdbb2
C_MUTED=$'\033[38;2;146;131;116m' # gray_245 #928374
C_SUBTLE=$'\033[38;2;124;111;100m' # dark4 #7c6f64
C_RULE=$'\033[38;2;80;73;69m' # dark2 #504945, separator
C_OK=$'\033[38;2;184;187;38m' # bright_green #b8bb26, success
C_WARN=$'\033[38;2;250;189;47m' # bright_yellow #fabd2f, warning
C_ERR=$'\033[38;2;251;73;52m' # bright_red #fb4934, error
C_LINK=$'\033[38;2;131;165;152m' # bright_blue #83a598
GLYPH_BAR='▏'; GLYPH_DOT='●'
# A literal rather than a repeat of GLYPH_RULE: `tr` is byte-oriented and
# would shred a 3-byte box-drawing character, and every alternative costs
# a loop or a subshell to draw a fixed line. Both are the width of the
# heading they sit under ("dejima | live sessions"), and are next to it so
# they stay that way.
RULE_LINE='──────────────────────'
else
C_OFF=""; C_BOLD=""; C_BADGE=""; C_TEXT=""; C_MUTED=""; C_SUBTLE=""
C_RULE=""; C_OK=""; C_WARN=""; C_ERR=""; C_LINK=""
# Glyphs ride the same switch rather than a separate UTF-8 probe. Not
# because no-colour implies no-UTF-8, but because the two terminals this
# has to stay readable on - a $TERM the host has no entry for, and a pipe -
# are exactly the ones that mangle box drawing, and one flag is easier to
# reason about than two.
GLYPH_BAR='|'; GLYPH_DOT='*'
RULE_LINE='----------------------'
fi
# The palette, the heading and the prompt come from the shared library: this
# and dejima-allow both ask an operator to pick a session, and two copies of
# that would drift apart on the terminal an operator is actually holding.
# shellcheck source=dejima-tui-lib.sh
source "$SCRIPT_DIR/dejima-tui-lib.sh"
# Narrow deliberately. The phone is this script's main front door, and the
# rule underlines the heading instead of spanning the table because the
# table's width follows the longest project name - a rule that chased it would
# wrap on a phone exactly when the rows did.
banner() {
printf '\n %s%sdejima%s %s%s%s %slive sessions%s\n' \
"$C_BOLD" "$C_BADGE" "$C_OFF" "$C_SUBTLE" "$GLYPH_BAR" "$C_OFF" \
"$C_MUTED" "$C_OFF"
printf ' %s%s%s\n\n' "$C_RULE" "$RULE_LINE" "$C_OFF"
}
# Cleared first: this is a front door, and it is navigated to rather than
# scrolled to. dejima-allow's picker shares the banner and deliberately not
# this - see tui_clear.
banner() { tui_clear; tui_banner 'live sessions'; }
# Coloured by meaning, not by name: attachable is the one an operator wants to
# find, and stale/no-tmux are the two that will refuse if picked.
@@ -263,7 +228,7 @@ list_sessions() {
# ever race it. Removing it under a live session is not a leak
# being tidied but a session being broken - Docker recreates the
# bind source root-owned, hooks/dejima-notify.sh then fails on
# every Notification and Stop for the rest of the run, and nothing
# every notification for the rest of the run, and nothing
# rewrites the registry entry to make the session visible again.
continue
elif [ "$rc" -ne 0 ]; then
@@ -438,37 +403,8 @@ picker() {
"$sc" "$w_state" "${states[$i]}" "$C_OFF" \
"$C_SUBTLE" "${containers[$i]}" "$C_OFF"
done
# Numbered rather than arrow-driven: phone soft keyboards handle digits
# far better than arrow keys, and this is the phone's entry point.
printf '\n %sattach to%s %s[1-%d, q to quit]%s: ' \
"$C_MUTED" "$C_OFF" "$C_TEXT" "${#ids[@]}" "$C_OFF"
# `|| exit 0`, because a read that cannot happen is not a refusal: with no
# controlling terminal (`ssh -T`, or an SSH app that requests no pty) this
# returns nonzero immediately, and exiting 1 with nothing printed reads as
# a broken front door. attach() reasons about the same case explicitly.
read -r choice || exit 0
case "$choice" in
q|Q|'') exit 0 ;;
*[!0-9]*) echo "Not a number." >&2; exit 1 ;;
esac
# Normalized to base 10 once, here, because the range check and the
# subscript below do not read a number the same way: `[` is POSIX test and
# decimal-only, while $(( )) reads a leading zero as OCTAL. Without this,
# "010" - which a phone keypad makes easy to type - passes the check as 10
# and indexes as 8, and the caller is attached to a DIFFERENT session than
# the one they picked, with nothing printed to say so.
#
# It fixes the two ugly shapes as well. "09" is a value here rather than
# the "invalid octal constant" arithmetic error it is to $(( )); and a
# number past int64 wraps to one the range check can answer for, where
# `[ 99999999999999999999999 -gt 11 ]` instead exits 2 with "integer
# expression expected" - which makes the `if` false, so "Out of range."
# is never printed and the subscript below dies on an unbound element.
choice="$((10#$choice))"
if [ "$choice" -lt 1 ] || [ "$choice" -gt "${#ids[@]}" ]; then
echo "Out of range." >&2
exit 1
fi
tui_pick "${#ids[@]}" 'attach to'
choice="$TUI_CHOICE"
attach "${ids[$((choice - 1))]}"
}
+5 -1
View File
@@ -966,7 +966,11 @@ tcb_snapshot() {
# here. Both decide what the next launch permits, which is what the
# TCB list is for. A visible edit stays a normal reviewable diff - the
# split above only reports what git cannot see.
for dir in bin hooks docker test forges workflows harnesses; do
# tools/ for the same reason as the provider directories, and it was
# missed when that layer landed: a tool.conf names a tarball URL and
# the sha256 checked against it, and the host's image build installs
# what it points at as root.
for dir in bin hooks docker test forges workflows harnesses tools; do
if [ -d "$ws/$dir" ]; then
while IFS= read -r -d '' f; do
stage_entry "tcb:${f#"$ws"/}" "$f"
+60 -9
View File
@@ -44,25 +44,76 @@ sanitize_body() {
LC_ALL=C tr -d '\000-\010\013-\037\177' | _sanitize_c1_bidi
}
# One palette, and it is the terminal's own.
#
# gruvbox is dejima's brand, and the values below ARE gruvbox on a gruvbox
# terminal - every colour here is an ANSI slot that a gruvbox theme fills with
# the exact hex the marks use (bright green #b8bb26, bright yellow #fabd2f,
# bright red #fb4934, bright blue #83a598, bright black #928374). What it does
# not do is hardcode them.
#
# It used to, in dejima-tui-lib.sh, and that broke on any terminal whose
# background dejima does not control. Measured against a light background:
# light1 #ebdbb2 - the colour the session picker drew project names in - came
# out at 1.21:1, bright yellow at 1.49:1, bright green at 1.82:1. Nothing in
# the palette cleared 3:1 on both a dark and a light background, because a
# palette drawn for #282828 cannot.
#
# An indexed colour cannot have that problem: the theme's author chose it
# against their own background. So dejima gets its brand on the terminals set
# up for it, and legibility on the rest, and those are the same decision.
#
# The one place dejima keeps exact hexes is web/src/page.css, where it sets
# the background itself and so owns both sides of the contrast.
#
# Empty unless the fd is a colour-capable terminal, which keeps machine
# interfaces (dejima-attach --list, which ttyd feeds run ids back from)
# parseable and is how the bats suite reads these scripts.
color_init() {
local fd="${1:-1}"
C_RESET=""; C_CRIT=""; C_WARN=""; C_INFO=""; C_PATH=""
C_KEY=""; C_DIM=""; C_ADD=""; C_DEL=""; C_OK=""
C_OFF=""; C_BOLD=""; C_BADGE=""; C_TEXT=""; C_MUTED=""; C_SUBTLE=""
C_RULE=""; C_ERR=""; C_LINK=""
# Glyphs ride the same switch rather than a separate UTF-8 probe. Not
# because no-colour implies no-UTF-8, but because the two terminals this
# has to stay readable on - a $TERM the host has no entry for, and a pipe -
# are exactly the ones that mangle box drawing, and one flag is easier to
# reason about than two.
GLYPH_SEP='|'; GLYPH_BAR='|'; GLYPH_DOT='*'; GLYPH_RULE='-'
[ -t "$fd" ] || return 0
[ -z "${NO_COLOR+x}" ] || return 0
case "${TERM:-}" in ''|dumb) return 0 ;; esac
C_RESET=$'\033[0m'
C_CRIT=$'\033[1;31m' # bold red - runs unsandboxed host code
C_WARN=$'\033[33m' # yellow
C_INFO=$'\033[36m' # cyan
C_PATH=$'\033[1;36m' # bold cyan - the file or key a finding is about
C_KEY=$'\033[1m' # bold - the keystroke an operator has to press
C_DIM=$'\033[2m' # dim - the audit's own framing around content
C_ADD=$'\033[32m' # green - a diff's added lines
C_DEL=$'\033[31m' # red - a diff's removed lines
C_OK=$'\033[32m' # green
C_OFF="$C_RESET" # the pickers' name for it
C_BOLD=$'\033[1m'
C_KEY="$C_BOLD" # bold - the keystroke an operator has to press
# Default foreground, deliberately empty. This was light1 #ebdbb2, which
# is gruvbox's own foreground - so on a gruvbox terminal it is what the
# terminal already draws with, and on a light one it was invisible.
C_TEXT=""
C_DIM=$'\033[2m'
C_SUBTLE="$C_DIM" # dark4 receded; dim recedes against any background
C_RULE="$C_DIM" # dark2, ditto
C_MUTED=$'\033[90m' # bright black - gruvbox gray_245 #928374
C_OK=$'\033[92m' # bright green - gruvbox #b8bb26
C_ADD="$C_OK"
C_WARN=$'\033[93m' # bright yellow - gruvbox #fabd2f
C_ERR=$'\033[91m' # bright red - gruvbox #fb4934
C_DEL="$C_ERR"
C_CRIT=$'\033[1;91m' # bold bright red - runs unsandboxed host code
C_INFO=$'\033[96m' # bright cyan - gruvbox aqua #8ec07c
C_LINK=$'\033[94m' # bright blue - gruvbox #83a598
C_PATH=$'\033[1;96m' # bold - the file or key a finding is about
# No ANSI slot is orange, and gruvbox's #fe8019 is the wordmark's colour.
# Bold bright yellow is the nearest thing every terminal has, and it is
# the accent rather than the brand exactly.
C_BADGE=$'\033[1;93m'
GLYPH_SEP='▏'; GLYPH_BAR='▏'; GLYPH_DOT='●'; GLYPH_RULE='─'
}
# The colour for a tier name, so the mapping lives in one place rather than
# at each site that prints one.
tier_color() {
+15 -2
View File
@@ -45,6 +45,7 @@ core.command ?identifier
core.identity ?identifier
core.tools ?list:identifier
core.firewall bool on
ui.banner bool on
core.update_check bool on
core.share_dir path ~/.local/share/dejima/shared
core.ssh_key ?path
@@ -418,7 +419,7 @@ _config_extra_section_known() { return 1; }
_config_section_known() {
case "$1" in
core|github|mounts|credentials|logging|audit|remote-access|epitaph) return 0 ;;
core|github|mounts|credentials|logging|audit|remote-access|epitaph|ui) return 0 ;;
*) _config_extra_section_known "$1" ;;
esac
}
@@ -526,7 +527,19 @@ _config_parse() {
return 1
fi
if ! _config_valid_value "$type" "$value"; then
echo "invalid value for [$section] $key in $conf: $value" >&2
# The value is echoed so the operator can see what was wrong with
# it, except for the userinfo of a URL. A url-typed value is the
# one kind here that carries a credential - ntfy takes its
# authentication as https://<user>:<token>@<host>/<topic> - and a
# value only reaches this line by being malformed, so the mistake
# would be what wrote the token into a terminal or a journal.
# Everything after the @ is kept: the host and path are what make
# the message useful.
local shown="$value"
case "$type:$value" in
*url:*://*@*) shown="${value%%://*}://<redacted>@${value#*@}" ;;
esac
echo "invalid value for [$section] $key in $conf: $shown" >&2
return 1
fi
# Last assignment wins, which is why this overwrites rather than
@@ -1,12 +1,24 @@
#!/usr/bin/env python3
# Host-side credential-injecting proxy for sandboxed Claude Code sessions.
# Host-side credential-injecting proxy for sandboxed sessions.
#
# The container only ever holds a placeholder token; this proxy (launched on
# the host by bin/dejima, reached via a bind-mounted unix socket and
# an in-container loopback forwarder) strips the placeholder and injects the
# real OAuth access token from ~/.claude/.credentials.json, so the credential
# itself never enters the container. It also owns token refresh: rotated
# tokens are written back to the real host file atomically, keeping
# host-side sessions logged in. See docs/security.md.
# the host by bin/dejima, reached via a bind-mounted unix socket and an
# in-container loopback forwarder) strips the placeholder and injects the real
# credential, so the credential itself never enters the container. See
# docs/security.md.
#
# NOTHING HERE NAMES A PROVIDER. Which upstream, which credential, how it is
# read, which header carries it and what shape that header takes are all
# arguments (#171). The Anthropic arrangement this started as is one set of
# those arguments and no longer a special case in the code, because the moment
# a second provider exists, a proxy that knows one provider's name has to grow
# a branch for every other.
#
# Two credential kinds, and the difference is refresh. A static key is read and
# sent. An OAuth credential expires, so it is refreshed against an endpoint and
# the rotated token written back to the host file atomically - which is what
# keeps a host-side session logged in rather than being signed out by a sandbox
# run. A provider says which kind it has; this decides nothing on its own.
import argparse
import http.client
import json
@@ -19,8 +31,6 @@ import time
from http.server import BaseHTTPRequestHandler
from urllib.parse import urlsplit
OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
OAUTH_BETA = "oauth-2025-04-20"
# Hop-by-hop headers describe the connection to this proxy rather than the
# request, so they stop here (RFC 9110 7.6.1). Host is deliberately not in
# this set: it has to be rewritten for the upstream, and is replaced in place
@@ -30,28 +40,84 @@ HOP_BY_HOP = {
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
"te", "trailer", "trailers", "transfer-encoding", "upgrade",
}
# Casing for the few headers this proxy has to supply when the client sent
# none of its own. Everything the client did send keeps the client's casing.
# Casing for the headers this proxy has to supply when the client sent none of
# its own. Everything the client did send keeps the client's casing; these are
# only consulted for a header being added rather than replaced. The auth and
# ensure-header names are added to this at startup, in the spelling the
# provider declared, for the same reason.
SUPPLIED_NAMES = {
"host": "Host",
"authorization": "Authorization",
"anthropic-beta": "anthropic-beta",
"content-length": "Content-Length",
}
class Credentials:
def __init__(self, path, oauth_endpoint, margin):
class StaticCredential:
"""A key in a file, sent as-is. No expiry, so nothing to refresh."""
# Trailing newline stripped, because a key file is written by an editor or
# a `>` redirection as often as by a tool, and a newline inside a header
# value is not a cosmetic difference: it would either be rejected or split
# the header block. Refused rather than trimmed if anything is left that
# cannot appear in a header, since a key that needs sanitising is a key
# that was pasted wrong, and guessing at it would send a mangled
# credential upstream and report the provider's rejection as the fault.
def __init__(self, path):
self.path = path
def token(self):
with open(self.path) as f:
value = f.read().strip()
if not value:
raise RuntimeError("%s is empty" % self.path)
bad = [c for c in value if ord(c) < 0x20 or ord(c) == 0x7F]
if bad:
raise RuntimeError("%s contains a control character" % self.path)
return value
def refresh(self, stale_token):
# Nothing to refresh. A 401 from a static key means the key is wrong,
# and retrying with the same bytes would turn one rejection into two.
raise RuntimeError("this credential cannot be refreshed")
@property
def refreshable(self):
return False
def startup_check(self):
self.token()
class OAuthCredential:
def __init__(self, path, oauth_endpoint, client_id, margin):
self.path = path
self.oauth_endpoint = oauth_endpoint
self.client_id = client_id
self.margin = margin
self.lock = threading.Lock()
@property
def refreshable(self):
return True
def startup_check(self):
# A plain read, not token(): this is a startup sanity check and must
# not trigger a refresh (that belongs to the first real request, so an
# external refresher gets a chance to win first).
self._read()["claudeAiOauth"]["accessToken"]
expires = self._read()["claudeAiOauth"].get("refreshTokenExpiresAt")
if expires is not None and expires / 1000 < time.time():
# Concrete, not generic. This class reads Claude Code's own
# credential shape, so the remedy is a command the operator can
# run rather than an instruction to work out.
raise RuntimeError(
"the refresh token has expired - run `claude` on the host "
"and log in again first")
def _read(self):
with open(self.path) as f:
return json.load(f)
def access_token(self):
def token(self):
oauth = self._read()["claudeAiOauth"]
if oauth["expiresAt"] / 1000 - time.time() < self.margin:
self.refresh(stale_token=oauth["accessToken"])
@@ -75,7 +141,7 @@ class Credentials:
req = json.dumps({
"grant_type": "refresh_token",
"refresh_token": oauth["refreshToken"],
"client_id": OAUTH_CLIENT_ID,
"client_id": self.client_id,
})
conn.request("POST", u.path, body=req,
headers={"Content-Type": "application/json"})
@@ -121,6 +187,24 @@ class Credentials:
raise
class AuthShape:
"""How a provider wants its credential presented.
header/format are the injection; strip is what must not travel beside it;
ensure is a comma-list header that has to contain a value, used for the
Anthropic OAuth beta and general enough to need no name of its own here.
"""
def __init__(self, header, fmt, strip, ensure):
self.header = header
self.format = fmt
# The auth header is never also stripped: it is replaced, and listing
# it in both places would drop the injected value instead of sending
# it.
self.strip = frozenset(n.lower() for n in strip) - {header.lower()}
self.ensure = ensure
class BodyTooLarge(Exception):
"""A request body over the cap. Its own type, not ValueError: _read_body
raises ValueError for a genuinely malformed request (a chunk length that
@@ -135,7 +219,7 @@ class Handler(BaseHTTPRequestHandler):
pass # per-request lines would interleave with the session's own UI
def log_error(self, fmt, *args):
sys.stderr.write("dejima-anthropic-proxy: " + fmt % args + "\n")
sys.stderr.write("dejima-credential-proxy: " + fmt % args + "\n")
# Capped, because the client is the sandboxed session and this runs on the
# host as the operator's user. Every other session-controlled size in this
@@ -207,25 +291,34 @@ class Handler(BaseHTTPRequestHandler):
# which is re-emitted in the client's own position instead.
conn.putrequest(self.command, self.path,
skip_host=True, skip_accept_encoding=True)
auth = self.server.auth
supplied = {
"host": u.netloc,
"authorization": "Bearer " + token,
auth.header.lower(): auth.format.replace("{token}", token),
"content-length": str(len(body)),
}
# anthropic-beta is left completely alone when the OAuth value is
# already among the client's own - which it is in the tls transport,
# where Claude Code believes it holds an OAuth credential. Re-joining
# a list that needs no change would normalize the client's spacing,
# which is the same class of tell as the casing.
betas = [b.strip() for v in self.headers.get_all("anthropic-beta", [])
for b in v.split(",")]
if OAUTH_BETA not in betas:
first = self.headers.get("anthropic-beta")
supplied["anthropic-beta"] = first + "," + OAUTH_BETA if first else OAUTH_BETA
# An ensure-header is left completely alone when its value is already
# among the client's own - as anthropic-beta's is in the tls transport,
# where the harness believes it holds an OAuth credential. Re-joining a
# list that needs no change would normalize the client's spacing, which
# is the same class of tell as the casing.
for name, wanted in auth.ensure:
low = name.lower()
present = [v.strip() for header in self.headers.get_all(low, [])
for v in header.split(",")]
if wanted in present:
continue
first = self.headers.get(low)
supplied[low] = first + "," + wanted if first else wanted
seen = set()
for name, value in self.headers.items():
low = name.lower()
if low in HOP_BY_HOP or low == "x-api-key":
# The strip set is the provider's, not a fixed list. It exists so a
# header the client used for a credential cannot travel alongside
# the one this proxy injects: x-api-key beside an Authorization is
# two answers to the same question, and which one an API prefers is
# not this program's to guess.
if low in HOP_BY_HOP or low in auth.strip:
continue
if low in supplied:
# One replacement even for a repeated header, or the injected
@@ -236,6 +329,13 @@ class Handler(BaseHTTPRequestHandler):
value = supplied[low]
seen.add(low)
conn.putheader(name, value)
# Only names registered above are emitted, so a value put in `supplied`
# whose name is not in SUPPLIED_NAMES is computed and then silently
# dropped. That is deliberate - it is what keeps this pass from
# inventing a header the provider never declared - but it means the two
# have to be populated from the same place, which is why main() derives
# SUPPLIED_NAMES from the auth header and the ensure list rather than
# listing them.
for low, name in SUPPLIED_NAMES.items():
if low in supplied and low not in seen:
# Content-Length only when there is a body: a bodyless GET
@@ -257,19 +357,22 @@ class Handler(BaseHTTPRequestHandler):
return self._fail(413, "credential proxy: request body too large",
log_message="refused a request body: %s" % e)
try:
token = self.server.creds.access_token()
token = self.server.creds.token()
except Exception as e:
return self._fail(502, "credential proxy: could not load or refresh the "
"credential - see host terminal",
log_message="cannot load credentials: %s" % e)
try:
conn, resp = self._upstream(token, body)
if resp.status == 401:
if resp.status == 401 and self.server.creds.refreshable:
# Upstream disagrees with expiresAt - refresh once and retry.
# Only for a credential that can be refreshed: retrying a
# static key sends the same bytes and turns one rejection into
# two, with the second one looking like a flapping upstream.
resp.read()
conn.close()
self.server.creds.refresh(stale_token=token)
token = self.server.creds.access_token()
token = self.server.creds.token()
conn, resp = self._upstream(token, body)
except Exception as e:
return self._fail(502, "credential proxy: upstream request failed - "
@@ -315,9 +418,10 @@ class Handler(BaseHTTPRequestHandler):
class ProxyServer(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
daemon_threads = True
def __init__(self, socket_path, upstream, creds):
def __init__(self, socket_path, upstream, creds, auth):
self.upstream = upstream
self.creds = creds
self.auth = auth
super().__init__(socket_path, Handler)
def get_request(self):
@@ -345,34 +449,76 @@ def main():
p = argparse.ArgumentParser()
p.add_argument("--socket", required=True)
p.add_argument("--credentials", required=True)
p.add_argument("--upstream", default="https://api.anthropic.com")
p.add_argument("--oauth-endpoint", default="https://platform.claude.com/v1/oauth/token")
# No default for any of these, deliberately. A default upstream or a
# default credential kind would be one provider's arrangement wearing the
# name of every provider's, which is the shape #171 was filed about: the
# caller says which provider this is, and this program has no opinion.
p.add_argument("--upstream", required=True)
p.add_argument("--credential-kind", required=True,
choices=("static", "anthropic-oauth"))
p.add_argument("--auth-header", default="Authorization")
# "{token}" rather than a scheme name, so a provider that wants something
# other than "Bearer x" - a bare key, a prefix of its own - needs no code
# here.
p.add_argument("--auth-format", default="Bearer {token}")
# Repeatable, and replacing rather than adding to a default: a provider
# states what must not travel beside its credential, and inheriting
# another provider's list would be a guess.
p.add_argument("--strip-header", action="append", default=[],
metavar="NAME")
p.add_argument("--ensure-header", action="append", default=[],
metavar="NAME=VALUE")
p.add_argument("--oauth-endpoint")
p.add_argument("--oauth-client-id")
p.add_argument("--refresh-margin", type=int, default=300)
args = p.parse_args()
creds = Credentials(args.credentials, args.oauth_endpoint, args.refresh_margin)
if "{token}" not in args.auth_format:
sys.stderr.write("dejima-credential-proxy: --auth-format must contain "
"{token}, or the credential is never sent\n")
return 2
ensure = []
for item in args.ensure_header:
if "=" not in item:
sys.stderr.write("dejima-credential-proxy: --ensure-header wants "
"NAME=VALUE, got %r\n" % item)
return 2
name, _, value = item.partition("=")
if not name or not value:
sys.stderr.write("dejima-credential-proxy: --ensure-header wants "
"NAME=VALUE, got %r\n" % item)
return 2
ensure.append((name, value))
if args.credential_kind == "anthropic-oauth":
if not args.oauth_endpoint or not args.oauth_client_id:
sys.stderr.write("dejima-credential-proxy: anthropic-oauth needs "
"--oauth-endpoint and --oauth-client-id\n")
return 2
creds = OAuthCredential(args.credentials, args.oauth_endpoint,
args.oauth_client_id, args.refresh_margin)
else:
creds = StaticCredential(args.credentials)
try:
# A plain read, not access_token(): this is just a startup sanity
# check and must not trigger a refresh (that belongs to the first
# real request, so an external refresher gets a chance to win first).
creds._read()["claudeAiOauth"]["accessToken"]
creds.startup_check()
except Exception as e:
sys.stderr.write(
"dejima-anthropic-proxy: unusable credentials file %s: %s\n"
"dejima-credential-proxy: unusable credential %s: %s\n"
% (args.credentials, e))
return 1
expires = creds._read()["claudeAiOauth"].get("refreshTokenExpiresAt")
if expires is not None and expires / 1000 < time.time():
sys.stderr.write(
"dejima-anthropic-proxy: the refresh token in %s has "
"expired - run `claude` on the host and log in again first\n"
% args.credentials)
return 1
auth = AuthShape(args.auth_header, args.auth_format,
args.strip_header, ensure)
# In the spelling the provider declared, for headers this has to add
# rather than replace. A client that sent its own keeps its own casing.
SUPPLIED_NAMES[auth.header.lower()] = auth.header
for name, _ in ensure:
SUPPLIED_NAMES.setdefault(name.lower(), name)
if os.path.exists(args.socket):
os.unlink(args.socket)
server = ProxyServer(args.socket, urlsplit(args.upstream), creds)
server = ProxyServer(args.socket, urlsplit(args.upstream), creds, auth)
try:
server.serve_forever()
except KeyboardInterrupt:
+46 -2
View File
@@ -83,11 +83,36 @@ fi
# Refusing rather than sanitizing, because the config is operator-written:
# a value that can't be used as intended means they got something wrong, and
# this is the one place they say where their phone notifications go.
#
# The value is not echoed back whole. An ntfy server that wants credentials
# takes them in the URL - https://<user>:<token>@<host>/<topic>, which
# docs/remote-access.md documents - and a misconfiguration should not be the
# thing that writes that token into the journal, where it stays.
redacted_url() {
case "$1" in
*://*@*) printf '%s://<redacted>@%s' "${1%%://*}" "${1#*@}" ;;
*) printf '%s' "$1" ;;
esac
}
require_url() {
case "$2" in
http://*|https://*) ;;
*)
echo "$1 in $(config_path) must be an http:// or https:// URL: $2" >&2
echo "$1 in $(config_path) must be an http:// or https:// URL: $(redacted_url "$2")" >&2
exit 1
;;
esac
# The URL is handed to curl through a config file on stdin rather than as
# an argument (see the push below), and that format is line-oriented with
# its own quoting: a newline in the value would start a second directive,
# and a quote or a backslash would end or escape the string. The `url`
# config type's own character set already excludes all three, so this
# refuses a value that arrived some other way rather than sanitizing one
# that did - the same reading as the scheme check above.
case "$2" in
*[!A-Za-z0-9:/._~?=%@+-]*)
echo "$1 in $(config_path) has a character dejima will not send: $(redacted_url "$2")" >&2
exit 1
;;
esac
@@ -168,12 +193,31 @@ push() {
# stdout is discarded (ntfy answers with a JSON receipt); stderr is not.
# -sS exists precisely to keep transport errors on stderr, and under a
# systemd user unit that is the journal, which is where they belong.
#
# The URL goes in on stdin, not in argv. It is the one value here that can
# carry a credential, and every argument of every process on this host is
# readable by any local account through /proc/<pid>/cmdline - so a token in
# argv is a token published to the machine for as long as the request runs,
# and `ps` is enough to read it. Nothing else on this line is secret: the
# title, the body and the click URL are all composed from host data.
#
# This also retires the `-K/path` hazard the scheme check above was half
# of: a URL that is not a URL can no longer be read as an option, because
# it is no longer in the option list at all. Both checks stay anyway, and
# the second one is now what keeps this format from gaining a directive.
# Redirected from a process substitution rather than piped into. `printf |
# curl` is a pipeline under `set -o pipefail`, so a curl that exits before
# draining stdin leaves printf holding a closed pipe and the pipeline
# reports 141 - a "push failed" line for a push that succeeded. A
# redirection is not a pipeline and has no such status to inherit. It also
# keeps `--config -` in argv rather than a /dev/fd path, which is a fixed
# string a test can compare.
curl -fsS --max-time 10 --retry 2 \
-H "Title: $title" \
-H "Priority: default" \
"${click[@]}" \
-d "$body" \
-- "$NTFY_URL" >/dev/null \
--config - >/dev/null < <(printf 'url = "%s"\n' "$NTFY_URL") \
|| echo "dejima-notify-watch: push failed for $run_id/$event" >&2
}
+306 -54
View File
@@ -28,31 +28,48 @@ MAX_MANIFEST_LINES=200
# filling this on first use meant filling it again in every `$(...)` that
# reached it, which was a fork apiece and hundreds of them per launch.
_PROVIDER_ROOT_CACHE="$(cd "${BASH_SOURCE[0]%/*}/.." && pwd)"
provider_root() {
# Assigns rather than prints, so a caller reaching it in a loop does not pay a
# `$( )` fork per call. provider_root below is the printing form for everyone
# who wants a value in a substitution anyway.
_provider_root_into() {
if [ -n "${DEJIMA_PROVIDER_ROOT:-}" ]; then
printf '%s' "$DEJIMA_PROVIDER_ROOT"
printf -v "$1" '%s' "$DEJIMA_PROVIDER_ROOT"
return 0
fi
# Only the repo path is cached. The test hook is re-read every call, so a
# test that repoints it mid-process still gets the directory it just set.
[ -n "$_PROVIDER_ROOT_CACHE" ] || _PROVIDER_ROOT_CACHE="$(cd "${BASH_SOURCE[0]%/*}/.." && pwd)"
printf '%s' "$_PROVIDER_ROOT_CACHE"
printf -v "$1" '%s' "$_PROVIDER_ROOT_CACHE"
}
_provider_axis_dir() {
case "$1" in
forge) printf 'forges' ;;
workflow) printf 'workflows' ;;
harness) printf 'harnesses' ;;
provider_root() {
local _root
_provider_root_into _root
printf '%s' "$_root"
}
# Same assign-instead-of-print split as _provider_root_into, for the same
# reason: provider_dir is reached once per manifest read.
_provider_axis_dir_into() {
case "$2" in
forge) printf -v "$1" 'forges' ;;
workflow) printf -v "$1" 'workflows' ;;
harness) printf -v "$1" 'harnesses' ;;
# Not an axis in the select-one sense the other three are: a launch
# enables zero or more tools, and none is the default. It is here
# because everything else about it is the same - a manifest that is
# data, validated by the same schema machinery, never sourced.
tool) printf 'tools' ;;
*) echo "unknown provider axis: $1" >&2; return 1 ;;
tool) printf -v "$1" 'tools' ;;
*) echo "unknown provider axis: $2" >&2; return 1 ;;
esac
}
_provider_axis_dir() {
local _sub
_provider_axis_dir_into _sub "$1" || return 1
printf '%s' "$_sub"
}
_provider_manifest_name() {
case "$1" in
forge) printf 'forge.conf' ;;
@@ -72,6 +89,18 @@ valid_provider_name() {
[[ "$1" =~ ^[a-z0-9][a-z0-9-]*$ ]]
}
# `readlink -m` answers memoized per path, because the containment check below
# resolves the same axis directory once per provider and provider_dir is
# reached from inside each `$( manifest_get ... )`. What is cached is one
# path's resolution, not a verdict: every caller still runs the containment
# comparison and the -d check itself.
declare -gA _PROVIDER_REAL_CACHE=()
_provider_resolve_into() {
[ -n "${_PROVIDER_REAL_CACHE[$2]+set}" ] \
|| _PROVIDER_REAL_CACHE["$2"]="$(readlink -m "$2")"
printf -v "$1" '%s' "${_PROVIDER_REAL_CACHE[$2]}"
}
# Resolved once per root+axis+name. The two readlinks are the containment
# check, and it is called on every manifest read; keying the cache on the
# resolved root rather than just the name is what keeps a test that repoints
@@ -79,19 +108,19 @@ valid_provider_name() {
declare -gA _PROVIDER_DIR_CACHE=()
provider_dir() {
local axis="$1" name="$2" sub root axis_root dir_real id
sub="$(_provider_axis_dir "$axis")" || return 1
_provider_axis_dir_into sub "$axis" || return 1
if ! valid_provider_name "$name"; then
echo "invalid $axis name: $name" >&2
return 1
fi
root="$(provider_root)"
_provider_root_into root
id="$root:$axis:$name"
if [ -n "${_PROVIDER_DIR_CACHE[$id]+set}" ]; then
printf '%s' "${_PROVIDER_DIR_CACHE[$id]}"
return 0
fi
axis_root="$(readlink -m "$root/$sub")"
dir_real="$(readlink -m "$root/$sub/$name")"
_provider_resolve_into axis_root "$root/$sub"
_provider_resolve_into dir_real "$root/$sub/$name"
case "$dir_real/" in
"$axis_root"/*) ;;
*) echo "refusing $axis '$name': it resolves outside $axis_root" >&2
@@ -122,17 +151,28 @@ _config_extra_type_valid() {
esac
}
provider_list() {
local axis="$1" sub entry name
sub="$(_provider_axis_dir "$axis")" || return 1
for entry in "$(provider_root)/$sub"/*/; do
# Fills _PROVIDER_LIST_OUT rather than printing, so the warm-up at the bottom
# of this file can walk all three axes without a `$( )` fork apiece.
declare -ga _PROVIDER_LIST_OUT=()
_provider_list_into() {
local axis="$1" sub root entry name
_PROVIDER_LIST_OUT=()
_provider_axis_dir_into sub "$axis" || return 1
_provider_root_into root
for entry in "$root/$sub"/*/; do
[ -d "$entry" ] || continue
name="$(basename "$entry")"
name="${entry%/}"
name="${name##*/}"
valid_provider_name "$name" || continue
printf '%s\n' "$name"
_PROVIDER_LIST_OUT+=("$name")
done
}
provider_list() {
_provider_list_into "$1" || return 1
[ "${#_PROVIDER_LIST_OUT[@]}" -eq 0 ] || printf '%s\n' "${_PROVIDER_LIST_OUT[@]}"
}
# Manifest schema. Same three-column shape as the config table: key, type,
# default. Types are the config file's own, so one validator set covers both.
_manifest_table() {
@@ -151,6 +191,7 @@ issue_command ?command
merge_block ?command
repo_flags ?list:flag
repo_env ?identifier
repo_paths ?list:repopath
identity_keys ?list:identifier
disclose ?disclosures
keys ?list:keyspec
@@ -183,7 +224,7 @@ EOF
requires ?list:identifier
push bool on
blocked_branches ?list:identifier
block_merge bool off
block_merge bool on
requires_worktree bool off
autonomy enum:commit|push-branch|open-pr commit
briefing ?relpath
@@ -303,6 +344,22 @@ _provider_valid_value() {
# subcommand or a value.
[[ "$value" =~ ^--?[A-Za-z0-9][A-Za-z0-9-]*$ ]]
;;
repopath)
# A repository named in a path rather than in a flag:
# `<subcommand>:<first path segment>`, as in `api:repos` for
# `gh api repos/<owner>/<name>/...`.
#
# Scoped to a subcommand because the alternative is comparing every
# positional argument that happens to contain two slashes, and a
# false refusal on an issue title is worse than the miss - this is
# a fast-fail, so a wrong yes costs an honest agent a working
# command. The guard applies it only when the subcommand is argv's
# first word.
case "$value" in *:*) ;; *) return 1 ;; esac
local sub="${value%%:*}" seg="${value#*:}"
[[ "$sub" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]] || return 1
[[ "$seg" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]]
;;
relpath)
# Joined to a provider directory, so no absolute paths and no
# traversal - a manifest may only name a file it ships.
@@ -496,12 +553,45 @@ manifest_validate() {
#
# Failures are ignored: a missing or unreadable directory is not this loop's
# to report, and provider_dir refuses it again when something asks.
for _axis in forge workflow harness; do
for _name in $(provider_list "$_axis" 2>/dev/null); do
provider_dir "$_axis" "$_name" >/dev/null 2>&1 || true
#
# One `readlink -m` for every path rather than two per provider. It takes
# several operands and prints one resolved path per line, so the warm-up costs
# a single process instead of one per directory - which is most of what
# sourcing this file costs, and is paid by every `bin/dejima` invocation and
# every test that drives the library in a fresh shell. The batch only fills
# the resolution cache; provider_dir still does its own containment check
# against what it finds there.
_provider_warm_dirs() {
local axis sub root name path i=0
local -a axes=() names=() paths=() lines=()
_provider_root_into root
for axis in forge workflow harness; do
_provider_axis_dir_into sub "$axis" || continue
paths+=("$root/$sub")
_provider_list_into "$axis" || continue
for name in "${_PROVIDER_LIST_OUT[@]}"; do
axes+=("$axis")
names+=("$name")
paths+=("$root/$sub/$name")
done
done
done
unset _axis _name
mapfile -t lines < <(readlink -m -- "${paths[@]}" 2>/dev/null)
# A path containing a newline would desync the one-line-per-operand
# pairing. Nothing shipped can produce one - names are validated and the
# root comes from `pwd` - but a mismatched count means the pairing cannot
# be trusted, so the batch is dropped rather than cached wrong. provider_dir
# then resolves each path itself, exactly as it would have.
if [ "${#lines[@]}" -eq "${#paths[@]}" ]; then
for path in "${paths[@]}"; do
_PROVIDER_REAL_CACHE["$path"]="${lines[$i]}"
i=$((i + 1))
done
fi
for i in "${!names[@]}"; do
provider_dir "${axes[$i]}" "${names[$i]}" >/dev/null 2>&1 || true
done
}
_provider_warm_dirs
_manifest_parse() {
local axis="$1" name="$2" dir="$3" id="$4" file line key value type line_count
@@ -751,23 +841,198 @@ identity_exists() {
return 1
}
# The credential keys the selected identity leaves to the section beneath it,
# as "<section> <key>" lines, empty when there are none.
#
# Only the selected forge's, because refusing over a credential belonging to a
# forge this launch does not use would be refusing over a value nothing reads.
#
# "Set" means written in the config file, not resolved from a schema default.
# A forge whose credential key carries a default still hands an identity that
# names none the machine's own path, and that is not caught here - see
# docs/configuration.md, which says so rather than leaving it to be found.
identity_borrowed_credentials() {
local name="$1" forge="$2" key
if ! config_is_set "identity.$name" ssh_key && config_is_set core ssh_key; then
printf 'core ssh_key
'
fi
for key in $(manifest_get forge "$forge" identity_keys); do
if ! config_is_set "identity.$name" "$key" \
&& config_is_set "forge.$forge" "$key"; then
printf 'forge.%s %s
' "$forge" "$key"
fi
done
}
# Whether the selected identity, if there is one, is one a session can act
# under without borrowing from anyone.
#
# Two refusals, one reason. A name with no section resolves to nothing and
# falls through to the machine's own credentials; a section that names only
# some of them falls through for the rest. Either way a session launched as
# this identity acts under another account, and nothing says so. Pushing under
# the wrong key fails loudly; pushing under the default key when you asked for
# another one does not fail at all, which is the direction worth being careful
# about.
#
# One function rather than a check at each caller: bin/dejima's --print-config
# needs the same answer, because every value it prints resolves THROUGH the
# identity and printing this machine's credentials under an identity's name is
# the confusion the refusal exists to prevent.
#
# Guarded on the config existing rather than swallowing config_get's stderr:
# with no file at all it says so, and a selection validated without one is a
# legitimate case this must stay silent for.
# The identity in force: [core] identity when it names one, and otherwise the
# reserved `default` section when the config has one.
#
# `default` is what [core] ssh_key was without a name. Giving it one is what
# lets a host state its ordinary account the same way it states any other,
# with a git name and a forge credential beside the key rather than those
# coming from three different places.
#
# Empty when neither applies, which is the pre-identity config and still a
# supported one.
# Assigns rather than prints, and every caller uses this form. identity_get
# runs on every config_get through bin/dejima's wrapper, and `$(...)` around
# an answer this cheap costs a fork per read - which is most of what a launch
# spends on config.
identity_selected_into() {
local __var="$1" __name=""
if config_exists; then
__name="$(config_get core identity)" || __name=""
if [ -z "$__name" ] && _identity_section_present default; then
__name=default
fi
fi
printf -v "$__var" '%s' "$__name"
}
identity_selected() {
local _n
identity_selected_into _n
printf '%s' "$_n"
}
# Whether a section has any key in the parsed config, read straight off the
# parse rather than through identity_exists.
#
# identity_exists walks every forge's manifest to decide which keys count, and
# bin/dejima's config_get wrapper reaches this on every single read - which
# turned a --print-config from three seconds into forty. It does not need the
# manifest: the parser refuses an unknown key outright, so anything present
# under identity.<name> in a config that validated is already one of the keys
# identity_exists would have accepted.
_identity_section_present() {
local name="$1" k
config_exists || return 1
config_validate || return 1
for k in "${!_CFG_SEEN[@]}"; do
case "$k" in "identity.$name".*) return 0 ;; esac
done
return 1
}
identity_validate() {
local forge="$1" name="" borrowed section key
name="$(identity_selected)"
# [core] ssh_key is the default identity under its old name, so a config
# holding both says the same thing twice and dejima would have to pick.
# Refused rather than ranked: which key a session pushed with is not a
# thing to work out from precedence rules.
if identity_exists default && config_is_set core ssh_key; then
echo "[core] ssh_key is set and so is [identity.default], which is the same" >&2
echo "thing under two names - the key a session gets when none is asked for." >&2
echo "Move it into [identity.default] and remove [core] ssh_key." >&2
echo "See docs/configuration.md, \"Identities\"." >&2
return 1
fi
# Deprecated, not removed: it is how every config written before
# identities existed says this, and those still work. Warned on every
# launch that has it set, not only the ones using it - a config with
# [core] ssh_key and a named identity is one migration away from the
# refusal above, and finding that out on the launch that breaks is worse
# than being told now.
#
# Every launch rather than once, because there is nowhere to record
# "once" that a session could not reach, and a deprecation nobody sees
# until removal is not a deprecation.
if config_is_set core ssh_key; then
printf ' %s[core] ssh_key is deprecated%s - it is [identity.default] ssh_key\n' \
"${C_WARN:-}" "${C_RESET:-}" >&2
printf ' under its old name.\n' >&2
if [ -n "$name" ]; then
printf ' This launch runs as %s%s%s, so it is not the key being used here.\n' \
"${C_KEY:-}" "$name" "${C_RESET:-}" >&2
else
printf ' Nothing else names an account, so it is the key this launch uses.\n' >&2
fi
printf ' Move it into an [identity.default] section, which can carry the git\n' >&2
printf ' name and the forge credential belonging to that same account:\n\n' >&2
# The value in the file, not through config_get: in this process
# config_get is bin/dejima's wrapper, so with a named identity in
# force it answers with THAT identity's key - and printing it as the
# thing to move would name a key the operator is not being asked to
# touch.
printf ' [identity.default]\n ssh_key = %s\n\n' \
"$(_config_get_from_file core ssh_key)" >&2
fi
[ -n "$name" ] || return 0
if ! identity_exists "$name"; then
echo "[core] identity is '$name', and no [identity.$name] section sets" >&2
echo "anything in $(config_path). An identity that resolves to nothing would" >&2
echo "quietly use this machine's own credentials instead - refusing rather than" >&2
echo "doing that. See docs/configuration.md, \"Identities\"." >&2
return 1
fi
borrowed="$(identity_borrowed_credentials "$name" "$forge")"
[ -n "$borrowed" ] || return 0
echo "[identity.$name] leaves a credential to the section beneath it, so a" >&2
echo "session launched as '$name' would act under whichever account owns:" >&2
while IFS=$'\t' read -r section key; do
[ -n "$key" ] || continue
echo " [$section] $key" >&2
done <<< "$borrowed"
echo "An identity is how one session's account is kept separate from another's;" >&2
echo "set each of those under [identity.$name], or unset it in the section named." >&2
echo "See docs/configuration.md, \"Identities\"." >&2
return 1
}
identity_get() {
local section="$1" key="$2" name value pname
[ "$section.$key" != "core.identity" ] || return 1
name="$(config_get core identity)" || return 1
[ -n "$name" ] || return 1
# Which keys an identity could own, before working out which identity is
# in force: this runs on every config_get, and all but a handful of keys
# are ones no identity can answer for. Resolving the identity first meant
# doing that work ~40 times a launch to discard it ~35 of those times.
case "$section" in
core) [ "$key" = ssh_key ] || return 1 ;;
forge.*) _forge_owns_identity_key "${section#forge.}" "$key" || return 1 ;;
identity.*) return 1 ;;
*) return 1 ;;
core) [ "$key" = ssh_key ] || return 1 ;;
forge.*) ;;
*) return 1 ;;
esac
identity_selected_into name
[ -n "$name" ] || return 1
# The manifest lookup last, for the same reason: it forks, and by here
# there is an identity that might actually answer.
case "$section" in
forge.*) _forge_owns_identity_key "${section#forge.}" "$key" || return 1 ;;
esac
value="$(config_get "identity.$name" "$key")" || return 1
[ -n "$value" ] || return 1
printf '%s' "$value"
}
# Whether one forge declared this key as a person's rather than a project's.
# Whether one forge declared this key as naming an account rather than the
# forge itself - a credential a session acts under, not a setting like an API
# URL that every session on this host shares.
_forge_owns_identity_key() {
local forge="$1" key="$2" k
for k in $(manifest_get forge "$forge" identity_keys); do
@@ -782,7 +1047,7 @@ _forge_owns_identity_key() {
# globally would be a new way to get the same answer.
identity_git_field() {
local field="$1" name
name="$(config_get core identity)" || return 1
name="$(identity_selected)" || return 1
[ -n "$name" ] || return 1
config_get "identity.$name" "$field"
}
@@ -862,22 +1127,7 @@ provider_validate_selection() {
# quietly absent, because an operator who has them everywhere else would
# otherwise have to read a manifest to find out they are gone here.
#
# An identity that names nothing is refused rather than ignored. Falling
# through to the machine's own credentials would mean pushing under the
# wrong account with nothing saying so, which is the failure this feature
# exists to remove rather than one it may introduce.
# Guarded on the config existing rather than swallowing config_get's
# stderr: with no file at all it says so, and a selection validated
# without one is a legitimate case this must stay silent for.
local _identity=""
config_exists && { _identity="$(config_get core identity)" || _identity=""; }
if [ -n "$_identity" ] && ! identity_exists "$_identity"; then
echo "[core] identity is '$_identity', and no [identity.$_identity] section sets" >&2
echo "anything in $(config_path). An identity that resolves to nothing would" >&2
echo "quietly use this machine's own credentials instead - refusing rather than" >&2
echo "doing that. See docs/configuration.md, \"Identities\"." >&2
return 1
fi
identity_validate "$forge" || return 1
# A name outside this set is a hard error rather than an unknown guard
# treated as absent: a typo would otherwise silently turn a guard off,
@@ -1114,10 +1364,12 @@ the user's machine, with
What this means:
- The workspace directory is bind-mounted read-write and is the real
project. ~/shared is also a real, persistent host directory (not part of
the project) - use it to hand the user a file (a screenshot, a temp
script) without touching the project checkout, or to pick up something
they left there for you. Your harness keeps its own state on the host too,
project. ~/shared is also a real, persistent host directory (this
project's own, and not part of its checkout) - use it to hand the user a
file (a screenshot, a temp script) without touching the project checkout,
or to pick up something they left there for you. It outlives this session
and every session against this project shares it, so treat what is in it
as readable by all of them. Your harness keeps its own state on the host too,
so this session's transcript outlives the container - the work is
recoverable on the host with the command at the end of this
briefing.{{writable_mounts}} Everything else is disposable: installed system
+186
View File
@@ -0,0 +1,186 @@
#!/bin/bash
# Where a project's ~/shared lives on the host, and what is in it.
#
# `[core] share_dir` holds one directory per project, and a launch mounts only
# that project's at ~/shared. This is the host side of that: where to put a
# file so the next session against a project finds it, what every project is
# holding, and how to move files left over from when share_dir was mounted
# whole.
#
# Nothing here is a security control. The boundary is the mount bin/dejima
# sets up; this is the operator's view of what is on the other side of it.
#
# Usage: dejima-share <path|list|adopt> [<path>]
set -euo pipefail
case "${1:-}" in
-h|--help)
cat <<'DEJIMA_SHARE_HELP'
dejima-share - the host side of a project's ~/shared
Usage: dejima-share <path|list|adopt> [path]
path [path] print the host directory a launch against `path` mounts at
~/shared, creating it if it does not exist yet - so a file
copied there is waiting for the next session
list every project's share directory, with its size and when it
was last written, plus anything left loose in share_dir
adopt [path] move whatever is loose in share_dir's top level into `path`'s
share directory, after showing it and asking
path defaults to the current directory.
A launch mounts <share_dir>/projects/<slug> at ~/shared, where slug is the
project's absolute path with every non-alphanumeric character replaced by a
dash - the same key the audit and epitaph stores use. Only that directory
crosses into the container, so one project's session cannot read another's
handoff files.
Files that were in share_dir's top level before this split are still there and
are no longer mounted anywhere. `adopt` is for moving them somewhere a session
will see them; it never overwrites.
Nothing here deletes anything by itself, and dejima never prunes share_dir. It
grows until you clear it out - `list` is what tells you what has gone stale.
See docs/configuration.md.
DEJIMA_SHARE_HELP
exit 0 ;;
esac
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# The provider library rather than the config one directly: [core] harness and
# the other provider-typed keys resolve against the manifests, so config
# validation refuses an ordinary config without it.
# shellcheck source=dejima-provider-lib.sh
source "$SCRIPT_DIR/dejima-provider-lib.sh"
# shellcheck source=dejima-slug-lib.sh
source "$SCRIPT_DIR/dejima-slug-lib.sh"
config_validate || exit 1
action="${1:-}"
[ -n "$action" ] || { echo "Usage: dejima-share <path|list|adopt> [<path>]" >&2; exit 1; }
SHARE_ROOT="$(config_get core share_dir)"
PROJECTS_ROOT="$(share_projects_root "$SHARE_ROOT")"
# A workspace argument becomes a mount path, so it is resolved the way
# bin/dejima resolves the one it is about to mount rather than used as typed.
# A path that does not exist is a typo, and answering it with a share
# directory nothing will ever mount is worse than refusing.
resolve_workspace() {
local given="${1:-$PWD}"
[ -d "$given" ] || { echo "no such directory: $given" >&2; exit 1; }
(cd "$given" && pwd)
}
# Whatever is in share_dir's top level that is not the per-project root. Every
# one of these was visible to every session before the split and is visible to
# none now.
loose_entries() {
find "$SHARE_ROOT" -mindepth 1 -maxdepth 1 ! -name projects -printf '%f\n' \
2>/dev/null | sort
}
case "$action" in
path)
ws="$(resolve_workspace "${2:-}")"
dir="$(share_dir_for "$SHARE_ROOT" "$ws")"
# Created here rather than only at launch, because the point of asking
# is usually to put something in it before the launch happens.
mkdir -p "$dir"
printf '%s\n' "$dir"
;;
list)
if [ ! -d "$PROJECTS_ROOT" ]; then
echo "No project has a share directory yet ($PROJECTS_ROOT does not exist)."
else
found=0
while IFS= read -r d; do
[ -n "$d" ] || continue
found=1
# du and date rather than a stat of the directory itself: the
# directory's own mtime changes when an entry is added or
# removed and not when one is written, which would report a
# share nobody has touched in months as fresh.
size="$(du -sh "$PROJECTS_ROOT/$d" 2>/dev/null | cut -f1)"
newest="$(find "$PROJECTS_ROOT/$d" -type f -printf '%T@\n' 2>/dev/null \
| sort -rn | head -1)"
if [ -n "$newest" ]; then
when="$(date -d "@${newest%.*}" '+%Y-%m-%d' 2>/dev/null || echo '?')"
else
when="empty"
fi
printf '%-10s %-12s %s\n' "${size:-?}" "$when" "$d"
done < <(find "$PROJECTS_ROOT" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' \
2>/dev/null | sort)
[ "$found" = 1 ] || echo "No project has a share directory yet."
fi
loose="$(loose_entries)" || true
if [ -n "$loose" ]; then
echo
echo "Loose in $SHARE_ROOT, mounted into no session:"
while IFS= read -r e; do
[ -n "$e" ] || continue
printf ' %s\n' "$e"
done <<< "$loose"
echo
echo "Move them with 'dejima-share adopt <project path>', or delete them."
fi
echo
echo "Nothing prunes this. Clear out what you no longer need: everything"
echo "in a project's share is readable by every future session against it."
;;
adopt)
ws="$(resolve_workspace "${2:-}")"
dest="$(share_dir_for "$SHARE_ROOT" "$ws")"
loose="$(loose_entries)" || true
if [ -z "$loose" ]; then
echo "Nothing loose in $SHARE_ROOT - every entry is already under projects/."
exit 0
fi
echo "Moving into $ws's share ($dest):"
while IFS= read -r e; do
[ -n "$e" ] || continue
if [ -e "$dest/$e" ]; then
printf ' %-30s SKIP - %s already has one\n' "$e" "$dest"
else
printf ' %s\n' "$e"
fi
done <<< "$loose"
echo
# One question, after showing everything, the way dejima-project
# approve asks - and read from stdin for the same reason it does, so a
# non-interactive caller answering no is the default rather than an
# error about /dev/tty. A move is not undoable by this tool, and the
# files are the operator's.
printf 'Move them? [y/N] '
read -r reply || reply=n
case "$reply" in
y|Y) ;;
*) echo "Nothing moved."; exit 0 ;;
esac
mkdir -p "$dest"
moved=0
while IFS= read -r e; do
[ -n "$e" ] || continue
# Never overwrites: a name that already exists in the destination
# is left where it is and reported, because the two files are both
# the operator's and this cannot know which one they meant.
if [ -e "$dest/$e" ]; then
echo "Skipped $e - $dest already has one" >&2
continue
fi
mv -n "$SHARE_ROOT/$e" "$dest/$e"
moved=$((moved + 1))
done <<< "$loose"
echo "Moved $moved entr$([ "$moved" = 1 ] && echo y || echo ies) into $dest"
;;
*)
echo "Usage: dejima-share <path|list|adopt> [<path>]" >&2
exit 1 ;;
esac
+19 -5
View File
@@ -1,12 +1,15 @@
# Shared library: the ~/.claude/projects slug scheme, and which slugs belong
# to a given workspace. Sourced by bin/dejima (to decide what to mount) and
# bin/dejima-epitaph (to decide whose transcripts to keep). Not on PATH and
# not executable.
# Shared library: the ~/.claude/projects slug scheme, which slugs belong to a
# given workspace, and the share_dir layout keyed by that slug. Sourced by
# bin/dejima (to decide what to mount), bin/dejima-epitaph (to decide whose
# transcripts to keep) and bin/dejima-share. Not on PATH and not executable.
#
# One definition, because the callers must not disagree. A slug the mount
# exposes but a caller treats as foreign is a session whose work is silently
# left behind; a slug a caller treats as the workspace's own but the mount
# does not expose is a hole that nothing reports.
# does not expose is a hole that nothing reports. The share layout is here for
# the same reason: bin/dejima mounts a directory and dejima-share tells an
# operator where to put files in it, and those two naming different places
# would be an operator dropping a file where nothing will ever read it.
# Claude Code's own project key: the absolute cwd with every non-alphanumeric
# replaced by a dash. Matches docker/log-shipper.sh's Loki key and
@@ -14,6 +17,17 @@
# runs inside the container, the other is a separate library).
ws_slug() { printf '%s' "$1" | sed 's/[^a-zA-Z0-9]/-/g'; }
# Every per-project share directory lives under this one subdirectory of
# `[core] share_dir` rather than directly in it, which is what makes "left
# over from before the split" a decidable question: anything at share_dir's
# top level other than this directory predates the layout, because nothing
# writes there any more. It is also the directory to mount over sshfs - one
# entry per project, with no loose files mixed in.
share_projects_root() { printf '%s/projects' "${1%/}"; }
# The host directory mounted at ~/shared for a launch against $2.
share_dir_for() { printf '%s/%s' "$(share_projects_root "$1")" "$(ws_slug "$2")"; }
# The slugs belonging to $1: its own, plus one per live git worktree nested
# underneath it. One per line, sorted, no duplicates.
#
+25
View File
@@ -178,10 +178,35 @@ focus_opt=()
colors_opt=()
[ -z "$button_colors" ] || colors_opt=(-t "buttonColors=$button_colors")
# The sixteen ANSI slots xterm.js draws with, which without this are its own
# defaults - white on black, a bright red, a magenta. dejima's own output
# reaches a terminal in indexed colour precisely so the terminal's theme fills
# those slots (see dejima-color-lib.sh), and here dejima IS the terminal: the
# browser has no gruvbox to inherit, so the front door showed the stock
# palette while the same session over SSH showed the operator's.
#
# So the exact hexes belong here, by the same rule as web/src/page.css: they
# are the two surfaces where dejima owns the background. A static test keeps
# the values they share in step.
#
# Upstream gruvbox dark, by its own names. `background` and `foreground` are
# bg0/light1, which is what a gruvbox terminal is set to.
theme_opt=(-t 'theme={
"background":"#282828","foreground":"#ebdbb2",
"cursor":"#ebdbb2","cursorAccent":"#282828",
"selectionBackground":"#504945",
"black":"#282828","red":"#cc241d","green":"#98971a","yellow":"#d79921",
"blue":"#458588","magenta":"#b16286","cyan":"#689d6a","white":"#a89984",
"brightBlack":"#928374","brightRed":"#fb4934","brightGreen":"#b8bb26",
"brightYellow":"#fabd2f","brightBlue":"#83a598","brightMagenta":"#d3869b",
"brightCyan":"#8ec07c","brightWhite":"#ebdbb2"
}')
exec ttyd --interface 127.0.0.1 --port "$port" --check-origin --url-arg --writable \
--index "$index_html" \
${font_opt[@]+"${font_opt[@]}"} \
${prefix_opt[@]+"${prefix_opt[@]}"} \
${focus_opt[@]+"${focus_opt[@]}"} \
${colors_opt[@]+"${colors_opt[@]}"} \
"${theme_opt[@]}" \
"$SCRIPT_DIR/dejima-attach"
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env bash
# The small terminal interface dejima-attach and dejima-allow share.
#
# Two tools ask an operator to pick one running session out of several, and
# both had to be readable on whatever terminal the operator happened to be
# holding. This is the part that does not depend on which of them is asking:
# the palette, the heading, and the prompt.
#
# What is NOT here is the row. Each tool lists a different thing from a
# different source - the registry of remote-enabled sessions on one side,
# `docker ps` on the other - so the columns are the tool's own, measured by
# the tool against its own data.
#
# Sourced, never run.
# The palette is dejima-color-lib.sh's, and there is only one of it. This file
# used to carry a second, in hardcoded gruvbox truecolor, which collided with
# that one on C_OK and C_WARN and broke on any background dejima does not own -
# see the comment there for the contrast measurements that decided it.
#
# Sourced and initialised here rather than left to the caller, because both
# callers draw to stdout and this file's functions assume the names exist.
# shellcheck source=dejima-color-lib.sh
source "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/dejima-color-lib.sh"
color_init 1
# The rule under the heading, the width of the heading itself. Built rather
# than written out because the heading is now the caller's: two literals kept
# next to two headings stay in step only while nobody adds a third.
#
# A loop rather than `tr`, which is byte-oriented and would shred a 3-byte box
# drawing character into three copies of nothing.
tui_rule() {
local n="$1" i out=""
for ((i = 0; i < n; i++)); do out+="$GLYPH_RULE"; done
printf '%s' "$out"
}
# "dejima | <subtitle>", underlined to its own width.
# A blank screen to draw a front door on, scrollback included.
#
# The picker is reached by navigating to it - the web toolbar's SESS button is
# a full page load - and it drew on top of whatever the previous session had
# left on screen, so the rows arrived interleaved with the tail of the session
# just left. Over SSH it was invisible, because a fresh connection to the
# forced command usually starts on an empty screen (#213).
#
# 3J as well as 2J: without it a swipe up on a phone lands in the middle of
# the previous session's output, which is the same confusion one gesture away.
#
# Its own function rather than part of tui_banner, because dejima-allow's
# picker must NOT do this: that one is a prompt inside a shell the operator is
# already using, and wiping their scrollback to ask one question is worse than
# the interleaving this fixes.
#
# Rides the same switch as the colour and the glyphs, for the reason stated
# above them: one flag is easier to reason about than two, and the terminals
# that mangle box drawing are the ones these bytes should not reach either. So
# NO_COLOR turns this off as well - a reading of it this file has already
# committed to, and the alternative is a picker that answers to two different
# ideas of "plain".
tui_clear() {
[ -n "$C_OFF" ] || return 0
printf '\033[H\033[2J\033[3J'
}
tui_banner() {
local subtitle="$1"
printf '\n %s%sdejima%s %s%s%s %s%s%s\n' \
"$C_BOLD" "$C_BADGE" "$C_OFF" "$C_SUBTLE" "$GLYPH_BAR" "$C_OFF" \
"$C_MUTED" "$subtitle" "$C_OFF"
# 6 for the wordmark, 3 for the bar and the spaces either side.
printf ' %s%s%s\n\n' "$C_RULE" "$(tui_rule $((9 + ${#subtitle})))" "$C_OFF"
}
# Asks for one of 1..$1 and leaves it in TUI_CHOICE. $2 is the verb the
# prompt opens with. Exits the caller outright on anything meaning "not this
# time" - q, an empty line, or no terminal to read from.
#
# The answer comes back in a variable rather than on stdout because those
# exits have to be the caller's. In `$(tui_pick ...)` the function runs in a
# subshell, where `exit 0` ends the subshell and hands the caller an empty
# string it would then use as an index.
#
# Numbered rather than arrow-driven: phone soft keyboards handle digits far
# better than arrow keys, and dejima-attach is the phone's entry point.
TUI_CHOICE=""
tui_pick() {
local count="$1" verb="$2" choice
TUI_CHOICE=""
printf '\n %s%s%s %s[1-%d, q to quit]%s: ' \
"$C_MUTED" "$verb" "$C_OFF" "$C_TEXT" "$count" "$C_OFF"
# `|| exit 0`, because a read that cannot happen is not a refusal: with no
# controlling terminal (`ssh -T`, or an SSH app that requests no pty) this
# returns nonzero immediately, and exiting 1 with nothing printed reads as
# a broken front door.
read -r choice || exit 0
case "$choice" in
q|Q|'') exit 0 ;;
*[!0-9]*) echo "Not a number." >&2; exit 1 ;;
esac
# Normalized to base 10 once, here, because the range check and the
# subscript the caller does with this do not read a number the same way:
# `[` is POSIX test and decimal-only, while $(( )) reads a leading zero as
# OCTAL. Without this, "010" - which a phone keypad makes easy to type -
# passes the check as 10 and indexes as 8, and the caller acts on a
# DIFFERENT session than the one picked, with nothing printed to say so.
#
# It fixes the two ugly shapes as well. "09" is a value here rather than
# the "invalid octal constant" arithmetic error it is to $(( )); and a
# number past int64 wraps to one the range check can answer for, where
# `[ 99999999999999999999999 -gt 11 ]` instead exits 2 with "integer
# expression expected" - which makes the `if` false, so "Out of range."
# is never printed and the caller dies on an unbound element.
choice="$((10#$choice))"
if [ "$choice" -lt 1 ] || [ "$choice" -gt "$count" ]; then
echo "Out of range." >&2
exit 1
fi
TUI_CHOICE="$choice"
}
+11 -5
View File
@@ -67,12 +67,18 @@ REPO_ROOT="$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)"
# often the operator is told.
CHECK_INTERVAL=3600
# Not ~/.local/share/dejima, where the audit store and the epitaphs live: those
# are the operator's records, each with a config key pointing at it. This is a
# timestamp and a hash that can be deleted at any moment with no loss, which is
# what XDG's state directory is for.
# Alongside everything else dejima keeps on the host. This was under
# XDG_STATE_HOME, which is where a throwaway timestamp belongs by the letter
# of the spec - but it made ~/.local/state a fifth place dejima writes, for
# one file, and `mkdir -p` created that base directory on hosts that had no
# other use for it. One root is worth more than the distinction.
#
# No config key, where the four directories beside it have one: those are
# records an operator reads or a directory that gets mounted, and this is a
# hash that can be deleted at any moment with no loss. Nothing is served by
# being able to move it.
_cache_file() {
printf '%s/dejima/update-check' "${XDG_STATE_HOME:-$HOME/.local/state}"
printf '%s/.local/share/dejima/update-check' "$HOME"
}
# Only the transports that cannot name a command to run. `ext::` is refused by
+12 -4
View File
@@ -10,8 +10,17 @@
# reason this is code is the pairing in the second block, which resolves one
# directory tree through the filenames of another.
# Registers the waiting/done notification handlers into dejima's settings
# layer, in the shape Claude Code reads them.
# Registers the `done` notification handler into dejima's settings layer, in
# the shape Claude Code reads them.
#
# Stop only, though the hook and the host-side watcher both understand
# `waiting` as well. Claude Code raises Notification when it needs permission
# to run something and when the prompt has sat idle, and a dejima session runs
# with permission prompts off - so the first never happens, and what is left
# is a timer that expires shortly after the Stop it follows. Registering both
# put two pushes on the operator's phone for one event, a minute apart, the
# second saying nothing the first had not. `waiting` stays in the enum for a
# harness that can say it without having stopped first.
#
# Claude Code merges hook entries across settings layers rather than letting
# the higher one replace the lower, so these are added to whatever the
@@ -25,8 +34,7 @@
harness_register_notify() {
local settings="$1" tmp
tmp="$(mktemp "${TMPDIR:-/tmp}/dejima-settings-remote.XXXXXX.json")"
if jq '.hooks.Notification = ((.hooks.Notification // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/dejima-notify.sh waiting"}]}])
| .hooks.Stop = ((.hooks.Stop // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/dejima-notify.sh done"}]}])' \
if jq '.hooks.Stop = ((.hooks.Stop // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/dejima-notify.sh done"}]}])' \
"$settings" > "$tmp"; then
mv "$tmp" "$settings"
else
+1 -1
View File
@@ -273,7 +273,7 @@ COPY docker/ulogd.conf /etc/ulogd-sandbox.conf
# Into $VIMRUNTIME/plugin, not /etc/xdg/nvim - see the file's own comment.
COPY docker/sandbox-clipboard.lua /usr/local/share/nvim/runtime/plugin/sandbox-clipboard.lua
COPY docker/entrypoint.sh /usr/local/bin/
COPY docker/anthropic-proxy-forwarder.js /usr/local/bin/anthropic-proxy-forwarder.js
COPY docker/credential-proxy-forwarder.js /usr/local/bin/credential-proxy-forwarder.js
COPY docker/harness-wrapper.sh /usr/local/bin/dejima-harness-wrapper
# The two guards that do not need the harness's help. git-guard shadows git on
# PATH; forge-guard is linked over a forge CLI by entrypoint.sh when a workflow
@@ -1,6 +1,6 @@
// TCP front door for the host-side credential proxy, which listens on a
// bind-mounted unix socket (no host TCP exposure - see
// bin/dejima-anthropic-proxy). Deliberately no HTTP awareness in either
// bin/dejima-credential-proxy). Deliberately no HTTP awareness in either
// mode: bytes in, bytes out.
//
// tls (default): terminates TLS for api.anthropic.com on :443, which
@@ -29,7 +29,7 @@ const port = parseInt(
10,
);
if (!socketPath) {
console.error("anthropic-proxy-forwarder: SANDBOX_ANTHROPIC_PROXY_SOCKET not set");
console.error("credential-proxy-forwarder: SANDBOX_ANTHROPIC_PROXY_SOCKET not set");
process.exit(1);
}
@@ -47,7 +47,7 @@ if (transport === "tls") {
const keyPath = process.env.SANDBOX_ANTHROPIC_TLS_KEY;
if (!certPath || !keyPath) {
console.error(
"anthropic-proxy-forwarder: SANDBOX_ANTHROPIC_TLS_CERT/SANDBOX_ANTHROPIC_TLS_KEY not set",
"credential-proxy-forwarder: SANDBOX_ANTHROPIC_TLS_CERT/SANDBOX_ANTHROPIC_TLS_KEY not set",
);
process.exit(1);
}
+7 -1
View File
@@ -34,7 +34,13 @@ services:
# $SANDBOX_IMAGE lets bin/dejima point a run at a derived,
# per-project image instead, without this build ever producing one
# under that name itself - see bin/dejima.
image: ${SANDBOX_IMAGE:-dejima:${SANDBOX_USER:-node}}
# localhost/ so this can never resolve to Docker Hub: Docker reads a first
# component containing a dot or colon, or exactly "localhost", as a
# registry host, and a bare `dejima:node` is `docker.io/library/dejima:node`
# to anything that pushes (#53). bin/dejima holds the same prefix in
# SANDBOX_IMAGE_REPO and passes it in; this default is for a compose
# invocation by hand.
image: ${SANDBOX_IMAGE:-localhost/dejima:${SANDBOX_USER:-node}}
# Run tini as PID 1 so orphaned processes get reaped. Without it PID 1 is
# the session's own command (claude, or nvim under SANDBOX_NVIM_CONFIG),
# which never reaps: every process-substitution child the test suite and
+3 -3
View File
@@ -348,8 +348,8 @@ if [ -n "${SANDBOX_HARNESS_BRIEFING_ARGS:-}" ]; then
fi
# TCP front door for the host-side credential proxy (see bin/dejima and
# bin/dejima-anthropic-proxy): the proxy listens on a bind-mounted unix
# socket, and anthropic-proxy-forwarder.js pipes bytes into it. Only started
# bin/dejima-credential-proxy): the proxy listens on a bind-mounted unix
# socket, and credential-proxy-forwarder.js pipes bytes into it. Only started
# when this session actually has a proxy socket.
#
# In the default tls transport the harness's proxied_api_domain is pointed at
@@ -438,7 +438,7 @@ if [ -n "${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}" ]; then
# the single most ordinary variable a corporate environment sets.
export NODE_EXTRA_CA_CERTS="$tls_dir/cert.pem"
fi
gosu "$SANDBOX_USER" node /usr/local/bin/anthropic-proxy-forwarder.js &
gosu "$SANDBOX_USER" node /usr/local/bin/credential-proxy-forwarder.js &
# Node's own startup (V8 init, module load, then finally binding the
# listen socket) isn't instant - without this wait, a request that
# arrives right after this script proceeds (an automated test's
+64 -9
View File
@@ -28,8 +28,10 @@
# to a forge here.
# DEJIMA_FORGE_REPO the "<owner>/<name>" of the repository the session
# was launched against, and DEJIMA_FORGE_REPO_FLAGS /
# DEJIMA_FORGE_REPO_ENV the spellings this forge's CLI
# accepts for naming a different one.
# DEJIMA_FORGE_REPO_ENV / DEJIMA_FORGE_REPO_PATHS the
# spellings this forge's CLI accepts for naming a
# different one: a flag, an environment variable, and a
# path segment under a named subcommand.
# DEJIMA_FORGE_REAL_DIR where the real CLIs live, behind the shadow.
set -u
@@ -44,7 +46,8 @@ fi
# Outside a dejima container, or with nothing to do, this is a pass-through.
if [ -z "${DEJIMA:-}" ] ||
{ [ -z "${DEJIMA_MERGE_BLOCK:-}" ] && [ -z "${DEJIMA_DISCLOSE:-}" ] &&
[ -z "${DEJIMA_FORGE_REPO_FLAGS:-}" ] && [ -z "${DEJIMA_FORGE_REPO_ENV:-}" ]; }; then
[ -z "${DEJIMA_FORGE_REPO_FLAGS:-}" ] && [ -z "${DEJIMA_FORGE_REPO_ENV:-}" ] &&
[ -z "${DEJIMA_FORGE_REPO_PATHS:-}" ]; }; then
exec "$real" "$@"
fi
@@ -120,7 +123,8 @@ fi
# Refused rather than skipped when the launch resolved no repository at all: a
# workspace with no origin has nothing for --repo to legitimately name, and
# "cannot confirm" is not a reason to let it through.
if [ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ] || [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ]; then
if [ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ] || [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ] ||
[ -n "${DEJIMA_FORGE_REPO_PATHS:-}" ]; then
refuse_repo() {
echo "Blocked by dejima's forge guard: this session was launched against ${DEJIMA_FORGE_REPO:-no repository}, and $1 names '$2'. Act on the repository the sandbox mounts." >&2
exit 1
@@ -154,15 +158,33 @@ if [ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ] || [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ];
;;
"$flag"=*) value="${arg#*=}" ;;
*)
# A one-dash flag may carry its value with no
# separator - `-Rowner/name` - which two-dash flags do
# not do.
# One-dash flags only; a two-dash flag carries its
# value with `=` or as the next argument, both above.
case "$flag" in
--*) continue ;;
-?) ;;
*) continue ;;
esac
case "$arg" in "$flag"?*) value="${arg#"$flag"}" ;; *) continue ;; esac
# Short flags cluster, so the letter can sit anywhere
# in one: `-Rowner/name`, `-wR owner/name` and
# `-wRowner/name` all name a repository and none of
# them is `-R` as its own argument (#243). Split at the
# letter the way getopt does - what follows it in the
# same argument is the value, and nothing following it
# means the next argument is.
letter="${flag#-}"
# A cluster, not a value that happens to start with a
# dash: one dash, then the letter somewhere in a run of
# letters. Without the second check, `--search foo-R/x`
# style arguments would be mined for flag letters.
case "$arg" in -[!-]*) ;; *) continue ;; esac
rest="${arg#-}"
case "$rest" in *"$letter"*) ;; *) continue ;; esac
case "${rest%%"$letter"*}" in *[!A-Za-z]*) continue ;; esac
value="${rest#*"$letter"}"
if [ -z "$value" ]; then
[ "${#args_left[@]}" -gt 0 ] || continue
value="${args_left[0]}"
fi
;;
esac
[ -n "$value" ] || continue
@@ -170,6 +192,39 @@ if [ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ] || [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ];
done
done
fi
# A repository named in a path rather than in a flag - `gh api
# repos/<owner>/<name>/issues` reaches any repository the token does,
# without a --repo anywhere in it (#243).
#
# Only when the declared subcommand is argv's first word. The alternative
# is inspecting every positional that contains two slashes, which would
# refuse `gh issue create --title repos/a/b` - and a false refusal costs an
# honest agent a working command, which is the wrong way for a fast-fail to
# be wrong.
if [ -n "${DEJIMA_FORGE_REPO_PATHS:-}" ] && [ "$#" -gt 0 ]; then
read -ra repo_paths <<< "$DEJIMA_FORGE_REPO_PATHS"
for spec in "${repo_paths[@]}"; do
[ "$1" = "${spec%%:*}" ] || continue
segment="${spec#*:}"
for arg in "$@"; do
case "$arg" in -*) continue ;; esac
# gh substitutes {owner} and {repo} from the checkout, so a
# path carrying them names this repository by construction and
# comparing the literal braces would refuse it.
case "$arg" in *'{'*) continue ;; esac
path="${arg#/}"
case "$path" in "$segment"/*/*) ;; *) continue ;; esac
path="${path#"$segment"/}"
owner="${path%%/*}"
path="${path#*/}"
name="${path%%/*}"
[ -n "$owner" ] && [ -n "$name" ] || continue
same_repo "$owner/$name" \
|| refuse_repo "the path '$arg'" "$owner/$name"
done
done
fi
fi
# --- the authorship disclosure ---------------------------------------------
+251 -172
View File
@@ -2,10 +2,10 @@
[← dejima README](../README.md)
Day-to-day configuration once dejima is installed and running: what a
project declares versus what you approve, config parity with your host agent
harness setup, a different editor, writable host directories, shared
scratch space, frontend testing, and what the sandbox knows about itself.
Day-to-day configuration once dejima is installed and running. The reference
table below is every key there is; the sections after it cover what a project
declares versus what you approve, what carries over from your host, mounts and
scratch space, and what the sandbox knows about itself.
## Configuration reference
@@ -26,12 +26,13 @@ config file behind them.
| `[core] forge` | `none` | forge integration: `gitea` (`gitea-pr`/`gitea-issue`), `github` (`gh`/`github-pr`, 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` | *(the selected harness's own `command`)* | container's main process (e.g. `nvim`), also settable per launch with `--cmd`. Overriding it changes what dejima launches, not which harness is wrapped |
| `[core] identity` | unset | which `[identity.<name>]` section supplies this project's credentials and git name; also settable per launch with `--identity`. A name with no section is refused rather than falling back |
| `[core] identity` | unset | which `[identity.<name>]` section supplies the credentials and git name this session acts under; also settable per launch with `--identity`, which is how one host runs a session per account. Unset selects `[identity.default]` where the config has one. A name with no section is refused rather than falling back |
| `[core] tools` | unset | optional tools to put in the image, colon-separated; also `.dejima/tools` in a project (approved first) and `--include` per launch. None by default. See [docs/tools.md](tools.md) |
| `[core] update_check` | `on` | at startup, say when this checkout is behind the branch it tracks. One `ls-remote` to your own remote, at most hourly, never fetching; `off` makes dejima contact nothing of its own accord |
| `[core] update_check` | `on` | at startup, say when this checkout is behind the branch it tracks. One `ls-remote` to your own remote, at most hourly, never fetching; `off` makes dejima contact nothing of its own accord. The answer is cached in `~/.local/share/dejima/update-check`, which is safe to delete |
| `[ui] banner` | `on` | the three-line summary at the top of a launch - project and branch, the selection, and the switches whose state changes what a session can do. `off` prints nothing. Never drawn when output is redirected |
| `[core] firewall` | `on` | egress firewall; `off` disables it (debugging only), also settable per launch with `--no-firewall` |
| `[core] share_dir` | `~/.local/share/dejima/shared` | host-visible read/write scratch dir, mounted at `~/shared` |
| `[core] ssh_key` | unset | dedicated deploy key for headless/unattended git push |
| `[core] share_dir` | `~/.local/share/dejima/shared` | host-visible read/write scratch dir. One directory per project underneath it, and only that project's is mounted at `~/shared`. `dejima-share` is the host side of it |
| `[core] ssh_key` | unset | **deprecated** - the pre-identity spelling of `[identity.default] ssh_key`. Still read when the config has no `[identity.default]`; refused when it has one, since the two say the same thing |
| `[core] known_hosts` | `~/.ssh/known_hosts` | known-hosts file used for git-over-ssh |
| `[harness.claude-code] projects_dir` | `~/.claude/projects` | where Claude Code session transcripts live |
| `[harness.claude-code] file_history_dir` | `~/.claude/file-history` | Claude Code's edit-undo record, mounted per session rather than whole |
@@ -49,7 +50,7 @@ config file behind them.
| `[mounts] read_write` | unset | host paths (colon-separated) mounted **read-write** at their own path: for directories meant to outlive the session; refused if they overlap anything dejima needs out of a session's reach |
| `[mounts] nvim_config` | unset | nvim config dir, used when `[core] command = nvim` |
| `[credentials] proxy` | `on` | host-side credential proxy; `off` mounts the real OAuth credential into the container directly |
| `[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)) |
| `[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](security.md)) |
| `[logging] loki_url` | unset | ship DNS/firewall logs to a Loki endpoint; `--no-log` clears it for one launch, which silences everything downstream of it |
| `[epitaph] enabled` | `on` | keep the summary a harness writes when a session compacts; `--no-epitaph` skips it for one launch |
| `[epitaph] dir` | `~/.local/share/dejima/epitaphs` | where they are kept; never mounted into a container |
@@ -78,12 +79,11 @@ opting into when you pick one.
## Global and per-project domains and dependencies
Allowed domains and dependencies each stack through their own layers. The
layers **add** to each other, so a narrower layer can only widen what a
broader one already permits: nothing at the project layer can withdraw a
built-in domain, and nothing at the container layer can withdraw a project
one. Ordering them least specific first is how they are listed below and how
they are applied, and it means the set a session gets is the union of every
layer that applies to it, never an override of one by another.
layers **add** to each other, so a narrower layer can only widen what a broader
one already permits: the set a session gets is the union of every layer that
applies to it, never an override of one by another. Nothing at the project
layer can withdraw a built-in domain, and nothing at the container layer can
withdraw a project one.
| Config | Layers, least specific first |
| --- | --- |
@@ -102,61 +102,79 @@ A project can declare what it needs in two committed files:
# tarball:<name>@<sha256>:<url> release archive
```
### Declaring that the history is evidence
**Declaring grants nothing.** The project directory is bind-mounted
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:
A third file, approved the same way as the two above:
```
.dejima/corpus # one rule per line; today, commit-per-step
```sh
dejima-project status ~/myproject # declared vs approved vs missing
dejima-project approve ~/myproject # review and approve the declared set
dejima-allow --project ~/myproject crates.io
dejima-allow --global crates.io # applies to every project
dejima-dep add --project ~/myproject cargo:cargo-nextest
dejima-dep add --global cargo:cargo-nextest
```
An approved `commit-per-step` adds a paragraph to the session briefing
requiring **one commit per plan step that writes a file**, rather than one
commit per task, and explaining why. Absent or unapproved, nothing is added
and nothing changes, which is the default for every project that has not asked
for it.
Two of those tools also write the declaration, so an approval you make by
hand does not stay invisible to everyone else on the project:
`dejima-allow --project` appends to `.dejima/allowed-domains`, and
`dejima-dep add --project` appends to `.dejima/dependencies`. Both write the
approval first - that is the part that grants - and report separately on the
declaration, so a project you can't write to still gets you the approval.
`--global` writes no declaration at all: there is no project to declare in.
It goes through approval despite granting no capability, and that is
deliberate. `.dejima/` lives in the workspace, which is session-writable, so a
session able to turn this on by writing a file would be writing the *next*
session's system prompt. `bin/dejima` reads the approval store and never a
project's declarations - a static test holds that it does not so much as name
that directory.
The declaration those two write still grants nothing, and writing to it does
not promote it. That it is session-writable is exactly why both tools refuse to
write a `.dejima` directory or a `.dejima/<kind>` file that is a **symlink**.
The refusal is the `open` itself rather than a check before it, so it holds even
against a session still running while you type the command - see
[Declaration files are session-writable](security.md#declaration-files-are-session-writable).
The same write reads the file back to skip an already-declared entry, and that
read is capped, since a session can make its own `.dejima/<kind>` arbitrarily
large for free.
An unknown rule is reported but never offered for approval. One that nothing
implements would approve cleanly, sit in the store and do nothing, which reads
as being in force when it is not.
Neither writer has an inverse for the declaration. `dejima-dep remove
--project` withdraws the approval and deliberately leaves the declaration
alone (it says so, since `dejima-project approve` will otherwise offer the
entry again); `dejima-allow --project` has no `remove` at all. Taking
something back from the project itself means editing `.dejima/` and
committing that.
**Check the forge's merge policy before relying on this.** The briefing tells
the agent how to commit, but the setting that can undo all of it is yours: a
squash-merge is survivable, because a clone fetches remote branches and the
step commits stay reachable through the branch ref, but deleting the branch
afterwards removes the last thing pointing at them. Several forges offer
"delete branch on merge" and some default it on. Measured both ways on a
throwaway repository: squash-merge with the branch retained left every step
commit reachable in a fresh clone; the same merge with the branch deleted first
left none.
`dejima-project status`/`approve` classify a declared entry against
the union of global and project approvals, so something already approved
globally shows as approved, not pending. Unlike `dejima-allow`,
`dejima-dep`'s `--global` and `--project` are mutually exclusive -
an entry belongs to exactly one scope. `dejima-dep` has no `--container`
scope either, and refuses the flag rather than ignoring it: a dependency
needs an image rebuild, so there is nothing to patch in a live session.
So a project can adopt the rule, write every plan correctly, and still yield
nothing, with no error anywhere - a squash-merged `main` looks perfectly
healthy. dejima's own history is intact because it merges with merge commits.
Approved domains join the allowlist at the next launch. `dejima-allow` can also
patch a running session immediately, but only when run bare or with
`--container` - approving here (`--project`/`--global`) never touches one; see
[Adding a domain to a running session](security.md#adding-a-domain-to-a-running-session).
A domain that resolves to a private address has no live patch at all. Approved
dependencies are baked into a derived image built `FROM` the shared base -
first `dejima:<username>-global` if the global store has any dependency
approved, then `dejima:<username>-<slug>` on top of that if the project store
does - so they take effect on the **next** launch. Because of that rebuild, the
usual moment to approve is *after* leaving the sandbox, and persistence never
requires a running container.
The reason is recoverability rather than tidiness. A step is only legible
afterwards if the file's state immediately before it and immediately after it
can both be read back from history: before from the commit's parent, after
from the commit. One commit carrying several steps that touch the same file
destroys that, because no step in it can be isolated any more, and
reconstructing one would mean building a file state the repository never held.
Nothing recovers that later, so it has to be recorded while the work happens.
`dejima-project approve` never attempts to patch a running session: it only
ever calls `dejima-allow` without `--container`. If an individual approval
can't be recorded - an unwritable store, a slug collision, a corrupt approval
list - it reports that entry as not approved and keeps going, so one bad entry
can't cost you the rest of the set. It then exits non-zero if anything failed,
so `dejima-project approve ~/myproject && dejima` won't launch a session
missing the approvals you just confirmed.
It lives in the project rather than in `~/.config/dejima/config` because it is
a fact about the repository. Anyone who clones it and runs dejima gets the same
commit discipline, which is what makes the history uniformly usable: a rule
half the contributors follow produces a history nothing can be taken from.
What dejima does not do is say what the history is *for*. Collecting it, and
deciding whether to, is the operator's business; the briefing states the rule
the project already adopted and stops there.
A session whose project declares something it doesn't have is told so in its
startup context, with the exact command to hand you. Project approvals are
inherited by subdirectories, so a git worktree under the project gets its
parent's; global approvals apply everywhere regardless.
### Tarball dependencies
@@ -205,88 +223,53 @@ interpolated into a `RUN` line that runs as root at build time, so anything
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 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:
### Declaring that the history is evidence
```sh
dejima-project status ~/myproject # declared vs approved vs missing
dejima-project approve ~/myproject # review and approve the declared set
dejima-allow --project ~/myproject crates.io
dejima-allow --global crates.io # applies to every project
dejima-dep add --project ~/myproject cargo:cargo-nextest
dejima-dep add --global cargo:cargo-nextest
A third file, approved the same way as the two above:
```
.dejima/corpus # one rule per line; today, commit-per-step
```
Two of those tools also write the declaration, so an approval you make by
hand does not stay invisible to everyone else on the project:
`dejima-allow --project` appends to `.dejima/allowed-domains`, and
`dejima-dep add --project` appends to `.dejima/dependencies`. Both write the
approval first - that is the part that grants - and report separately on the
declaration, so a project you can't write to still gets you the approval.
`--global` writes no declaration at all: there is no project to declare in.
An approved `commit-per-step` adds a paragraph to the session briefing
requiring **one commit per plan step that writes a file**, rather than one
commit per task, and explaining why. Absent or unapproved, nothing is added
and nothing changes, which is the default for every project that has not asked
for it.
The declaration those two write still grants nothing; it is the same
untrusted file the section above describes, and writing to it does not
promote it. That it is session-writable is exactly why both tools refuse to
write a `.dejima` directory or a `.dejima/<kind>` file that is a **symlink**.
The refusal is the `open` itself rather than a check before it, so it holds
even against a session that is still running while you type the command. See
[Declaration files are session-writable](security.md#declaration-files-are-session-writable)
for what that defends against and how. The same write
reads the file back to skip an already-declared entry, and that read is
capped - a session can make its own `.dejima/<kind>` arbitrarily large for
free, and an oversized one is refused rather than loaded into your memory.
It goes through approval despite granting no capability, and that is
deliberate. `.dejima/` lives in the workspace, which is session-writable, so a
session able to turn this on by writing a file would be writing the *next*
session's system prompt. `bin/dejima` reads the approval store and never a
project's declarations - a static test holds that it does not so much as name
that directory.
Neither writer has an inverse for the declaration. `dejima-dep remove
--project` withdraws the approval and deliberately leaves the declaration
alone (it says so, since `dejima-project approve` will otherwise offer the
entry again); `dejima-allow --project` has no `remove` at all. Taking
something back from the project itself means editing `.dejima/` and
committing that.
An unknown rule is reported but never offered for approval. One that nothing
implements would approve cleanly, sit in the store and do nothing, which reads
as being in force when it is not.
`dejima-project status`/`approve` classify a declared entry against
the union of global and project approvals, so something already approved
globally shows as approved, not pending. Unlike `dejima-allow`,
`dejima-dep`'s `--global` and `--project` are mutually exclusive -
an entry belongs to exactly one scope. `dejima-dep` has no `--container`
scope either, and refuses the flag rather than ignoring it: a dependency
needs an image rebuild, so there is nothing to patch in a live session.
The reason is recoverability rather than tidiness. A step is legible afterwards
only if the file's state immediately before and immediately after it can both
be read back: before from the commit's parent, after from the commit. One
commit carrying several steps that touch the same file destroys that, and
nothing recovers it later, so it has to be recorded while the work happens.
Approved domains join the allowlist at the next launch. `dejima-allow` can
also patch a running session immediately, but only when it is run bare or
with `--container` - approving here (`--project`/`--global`, with no
`--container`) never touches one; see
[Adding a domain to a running session](security.md#adding-a-domain-to-a-running-session).
A domain that resolves to a private address has no live patch at all - an
internally-routed host can only be approved and picked up at the next launch.
Approved dependencies are baked into a derived image built `FROM` the shared
base - first `dejima:<username>-global`, if the global store has any
dependency approved, then `dejima:<username>-<slug>` on top of that,
if the project store does - so they need an image rebuild: they take effect
on the **next** launch.
**Check the forge's merge policy before relying on this.** The setting that can
undo all of it is yours. A squash-merge is survivable, because the step commits
stay reachable through the branch ref, but "delete branch on merge" - which
several forges offer and some default on - removes the last thing pointing at
them. Measured both ways on a throwaway repository: squash-merge with the
branch retained left every step commit reachable in a fresh clone; the same
merge with the branch deleted first left none. So a project can adopt the rule,
write every plan correctly, and still yield nothing, with no error anywhere.
dejima's own history is intact because it merges with merge commits.
Because of that rebuild, the usual moment to approve is *after* leaving the
sandbox - so persistence (`--global` or `--project`) never requires a running
container.
`dejima-project approve` never attempts to patch a running session: it only
ever calls `dejima-allow` without `--container`, so recording the approval
is the only thing it does. If an individual approval can't be recorded - an
unwritable store, a slug collision, a corrupt approval list - it reports
that entry as not approved and keeps going, rather than stopping at the
first failure; that holds for dependencies and domains alike, so one bad
entry can't cost you the rest of the set. It then exits non-zero if anything
failed, so `dejima-project approve ~/myproject && dejima` won't launch a
session missing the approvals you just confirmed.
A session whose project declares something it doesn't have is told so in its
startup context, with the exact command to hand you. Project approvals are
inherited by subdirectories, so a git worktree under the project gets its
parent's; global approvals apply everywhere regardless.
It lives in the project rather than in `~/.config/dejima/config` because it is
a fact about the repository: anyone who clones it gets the same commit
discipline, and a rule half the contributors follow produces a history nothing
can be taken from. What dejima does not do is say what the history is *for*.
Collecting it is the operator's business; the briefing states the rule the
project adopted and stops there.
## Config parity with your host agent harness setup
@@ -427,39 +410,78 @@ Dockerfile directly puts it outside that record.
## Identities
One machine, several accounts. A `[identity.<name>]` section groups the things
that belong to a **person** rather than to a project - the SSH key, the forge
token, the name on the commits - so that selecting an account is one word
rather than four edits.
**Several accounts on one machine, and none of them yours.** An
`[identity.<name>]` section groups what one session acts as - the SSH key, the
forge token, the name on the commits - so that pointing a launch at a
different account is one word rather than four edits.
The rest of these documents ask you to give a session an account of its own -
the README under [Using this responsibly](../README.md#using-this-responsibly),
and security.md's [Give the sandbox its own
identity](security.md#give-the-sandbox-its-own-identity). This is how you
have more than one of them, which you will: an account is scoped to what it
may reach, and one account scoped to everything any session might ever need
is the thing separate accounts existed to avoid.
So the sections are per **agent account**, not per person:
```ini
[identity.work]
ssh_key = ~/.ssh/id_work
user_name = A Name
user_email = name@work.example
token_file = ~/.config/gitea/work-token
# Pushes to the gitea forge, and can reach nothing on GitHub.
[identity.agent-gitea]
ssh_key = ~/.ssh/id_agent_gitea
user_name = dejima agent (gitea)
user_email = agent-gitea@example.invalid
token_file = ~/.config/gitea/agent-token
[identity.oss]
ssh_key = ~/.ssh/id_oss
user_email = name@personal.example
push_token_file = ~/.config/gh/oss-push
# A GitHub account, with push rights to the repositories this host works on.
[identity.agent-github]
ssh_key = ~/.ssh/id_agent_github
user_name = dejima agent (github)
user_email = agent-github@example.invalid
push_token_file = ~/.config/gh/agent-push
# A second GitHub account, for a repository the one above may not touch.
[identity.agent-vendor]
ssh_key = ~/.ssh/id_agent_vendor
user_name = dejima agent (vendor)
user_email = agent-vendor@example.invalid
push_token_file = ~/.config/gh/vendor-push
```
Select one with `[core] identity = work`, or per launch with
`--identity oss`.
Select one with `[core] identity = agent-gitea`, or per launch with
`--identity agent-github` - which is how one host runs a session against each
of these at the same time, each pushing as itself.
**The separation is the point.** Three accounts means three things you can
scope, revoke and read in a log independently: a session that turns out to
have been prompt-injected reached what its own account could reach, and
answering "what did it touch" does not start with "everything I can touch".
**What an identity may set** is deliberately narrow: `ssh_key`, `user_name`,
`user_email`, and whichever keys the forges declare as a person's. A forge
`user_email`, and whichever keys the forges declare as an account's. A forge
says which of its own keys those are (`identity_keys` in its manifest), so
`token_file` belongs to an identity on the gitea path and `push_token_file`
does on the GitHub one, and neither is a list core keeps its own copy of.
**A key an identity does not set falls through to the file.** The `oss`
identity above names no `token_file`, so `[forge.gitea] token_file` answers
for it. An identity is an override for what it names, not a replacement for
the section.
**`default` is the account a session gets when none is named.** With `[core]
identity` unset, an `[identity.default]` section is what a launch acts under -
the ordinary account, given a name so it can carry the git name and the forge
credential too.
**A name with no section is refused.** `[core] identity = wrok` does not
**`[core] ssh_key` is that key under its old name, and is deprecated.** A
config with no `[identity.default]` reads it as before; a config with both is
refused rather than ranked, because which key a session pushed with should not
depend on precedence rules. Every launch that sets it says so and prints the
section to move it into - including launches running as some other identity,
which are one `[identity.default]` away from that refusal.
**A credential does not fall through.** An identity that names some and leaves
the rest to the section beneath it is refused, naming each one: an identity is
how one session's account stays separate from another's. "Set" means written
in the file, so a forge key carrying a schema default - `[forge.gitea]
token_file` - still answers for an identity that names none.
**A name with no section is refused.** `[core] identity = agent-gihub` does not
quietly use this machine's own credentials - it stops the launch and says so.
Pushing under the wrong key fails loudly; pushing under your default key when
you asked for another one does not fail at all, which is the direction worth
@@ -469,10 +491,16 @@ being careful about.
the copy dejima already makes of your `~/.gitconfig`. Your real one is not
touched, which was already true and is what the copy exists for.
This is the half of per-project configuration that a project cannot carry: a
project's `.dejima/` is committed and shared with everyone working on it, and
a key path is yours and your machine's. A project can say which identity it
wants; only you can say where that identity's key lives.
**Selection is the host's, not the project's.** There is no `.dejima/` kind
for an identity and a project cannot name one: a project's `.dejima/` is
committed and shared with everyone working on it, and both the credential and
which account this host holds are that host's own. `[core] identity` and
`--identity` are the whole of it today.
A project-side layer would be a different thing than it sounds - not "this
project's credentials", which cannot be shared, but "this project wants
whichever account you have called X". That does not exist yet, and this
document will say so until it does.
## Headless git push without agent forwarding
@@ -502,15 +530,15 @@ the public half as a Gitea deploy key scoped to just the repos that need
push access, then set it in the config:
```
[core]
[identity.default]
ssh_key = ~/.ssh/dejima_ed25519
```
`entrypoint.sh` loads it into a container-local `ssh-agent` at startup and
deletes the plaintext copy immediately after - the sandbox user itself never
has a path to the raw key, only the agent socket (see
[security notes](security.md) for the full mount rationale). Setting
`[core] ssh_key` means a live forwarded agent is no longer required at all.
[security notes](security.md) for the full mount rationale). Setting it means
a live forwarded agent is no longer required at all.
## Using a different editor/application
@@ -612,13 +640,54 @@ 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.
## Suspending a session
Ctrl-Z backgrounds all of dejima - the container, the credential proxy, the
conversation shipper - and `fg` brings back a working session, with the terminal
put back the way the harness had it.
That takes doing, because a container run holds the terminal in raw mode, where
Ctrl-Z is not a signal at all: it is a byte, delivered to whatever is running
inside. A harness that implements Ctrl-Z itself then stops itself, and nothing
in a container can continue it - there is no job-control shell in there. So
dejima takes that one character back for the host while a session runs
(`bin/dejima-tty-suspend`). Ctrl-C and Ctrl-\ are deliberately left alone and
still reach the session, which is how a harness expects to be interrupted.
Suspending is not detaching. A suspended session is stopped, so it makes no
progress until you resume it, and it belongs to the terminal you started it
from. For a session that keeps working while you are away, or that you want to
pick up from somewhere else, run dejima inside tmux and use
[`dejima-attach`](remote-access.md).
## Shared scratch space
`~/shared` is a read-write bind mount of `[core] share_dir` (default
`~/.local/share/dejima/shared`, created automatically) - separate
from the project mount, for passing files (screenshots, temp scripts) in and
out of a session without touching the actual checkout. Persists across
sessions since it's a real directory, not a container volume.
`~/shared` is a read-write bind mount of one project's directory under
`[core] share_dir` (default `~/.local/share/dejima/shared`, created
automatically) - separate from the project mount, for passing files
(screenshots, temp scripts) in and out of a session without touching the actual
checkout. Persists across sessions since it's a real directory, not a container
volume.
**One directory per project, not one for the host.** A launch mounts
`<share_dir>/projects/<slug>` at `~/shared`, where the slug is the project's
absolute path with every non-alphanumeric character replaced by a dash - the
same key the audit and epitaph stores use. So a session sees its own project's
handoff files and no other project's, and nothing an agent has been told
changes, because inside the container the path is still `~/shared`.
`dejima-share` is the host side of that:
```sh
dejima-share path # the host directory for this project, created if new
dejima-share path ~/code/x # …for another project
dejima-share list # every project's share, with size and last write
dejima-share adopt # move files left loose in share_dir into this project's
```
**To hand a file *to* a session,** put it in `dejima-share path` before
launching. That is what makes the inbound direction work without a mount that
every project can read.
Treat it as a handoff point, not as general-purpose storage. If your own
project instructions tell agents to write somewhere at a fixed host path -
@@ -632,10 +701,20 @@ being rewritten to point at `~/shared`.
only writable host paths in the container. Everything else a session writes
goes away with it.
**Clear it out periodically.** It persists across sessions by design, which
means anything you left in it is readable by every session after the one you
left it for. A credential dropped in there to get one job done, and then
forgotten, is a credential you have handed to every session since.
**Clear it out periodically.** The per-project split bounds which sessions can
read what you left, and bounds nothing about how long. A project's share
persists by design, so anything in it is readable by every future session
against that project: a credential dropped in there to get one job done, and
then forgotten, is a credential you have handed to every session against that
project since. Nothing prunes `share_dir` - `dejima-share list` prints each
project's size and the date of its last write, and deleting is yours.
**Files from before the split.** `share_dir` used to be mounted whole, so
anything at its top level was visible to every session. Those files are still
there and are now mounted into nothing; dejima says so at every launch while
that is true, rather than moving an operator's files on its own.
`dejima-share adopt <project>` moves them into one project's share after
showing what it will move and asking, and it never overwrites.
## Reading what a session left behind
+18 -4
View File
@@ -25,7 +25,7 @@ run them.
| `hooks/git-push-guard.sh` | `PreToolUse` hook: blocks pushes outside the allowed host(s)/branches |
| `hooks/deny-dangerous-bash.sh` | `PreToolUse` hook: blocks obviously destructive Bash commands |
| `hooks/merge-guard.sh` | `PreToolUse` hook: blocks the configured forge's merge command (`DEJIMA_MERGE_BLOCK`) in the sandbox: merging is a human decision |
| `hooks/dejima-notify.sh` | `Notification`/`Stop` hook: drops an empty `waiting`/`done` marker for a remote-enabled session - see [docs/remote-access.md](remote-access.md) |
| `hooks/dejima-notify.sh` | Drops an empty `waiting`/`done` marker for a remote-enabled session. Registered as a `Stop` hook only; `waiting` is there for a harness that can say it without having stopped - see [docs/remote-access.md](remote-access.md) |
| `bin/dejima-attach` | Lists live sessions and attaches to one; the shared core behind both remote front doors |
| `bin/dejima-audit` | Advisory exit-time diff of host-persistence surfaces (git config, `.claude/`, etc.). `dejima-audit show` / `dejima-audit ack` default to the enclosing repo |
| `bin/dejima-dep` | Review and approve project dependencies declared in `.dejima/dependencies` |
@@ -40,6 +40,8 @@ run them.
| `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-conversation-ship` | Host-side: ships session transcripts to Loki when `[logging] conversation` is on. Reads them where they already are, so the session is not in the path of its own oversight log |
| `bin/dejima-color-lib.sh` | The one terminal palette, and the glyphs that ride the same switch. Indexed colour, not 24-bit: gruvbox is the brand and an ANSI slot **is** gruvbox on a gruvbox terminal, but hardcoding the hexes assumes a background dejima does not own. `web/src/page.css` is the exception, since the page sets its own. Not on PATH |
| `bin/dejima-tui-lib.sh` | The palette, heading and numbered prompt `dejima-attach` and `dejima-allow` share. Rows stay with each tool, which lists a different thing from a different source. Not on PATH |
| `bin/dejima-update-check` | Host-side: says at startup when this checkout is behind the branch it tracks. Reaches the remote by URL from outside the repository, so the checkout's own git config never names a command it runs |
| `bin/dejima-epitaph` | Host-side: keeps the summary a session leaves when its context is compacted away, in a store nothing mounts. `save` runs from `bin/dejima`'s exit trap; `list`/`search`/`show` are the operator's, and only ever read |
| `bin/dejima-harness-lib.sh` | Loads the selected harness's plugin and provides the `emit_*` functions it declares through; the guarded-store refusal runs in `emit_mount`. Not on PATH |
@@ -149,11 +151,23 @@ attached: read what `gitea-pr` printed.
### Running the tests
```sh
bats test/ # everything
bats test/audit.bats # one suite - the suites are independent, see .dejima.md
bash test/run-confined.sh # every confined suite - the pull-request gate
bash test/run-confined.sh --changed # only the suites your diff calls for
bats test/audit.bats # one suite - the suites are independent
SKIP_BUILD=1 bats test/runtime.bats # the one suite needing Docker; reuse the current image
```
`test/run-confined.sh` runs the files concurrently and, within each file, the
tests too, via bats' own `--jobs`. Adding `--jobs $(nproc)
--no-parallelize-across-files` to a bare `bats` invocation gets the same effect
for a single suite; both halves of that are needed, because bats otherwise
requires GNU parallel for `--jobs` and only the across-files half actually does.
`--changed` is for iterating. It maps the diff to suites through `suites_for()`
in that script, prints which lane it ran, and says plainly that it is not the
gate - a change is tested when the whole set has run, not when the selected
subset has.
`runtime.bats` is the only suite that needs a Docker daemon: it is the one
that builds the image and boots real containers to assert against.
Everything else, `audit.bats`, `ci-runner.bats`, `config.bats`,
@@ -487,7 +501,7 @@ harness-specific path resolving to nothing is the honest answer.
| Command | there is no default anywhere shared. `SANDBOX_CMD` comes from the manifest's `command` via `bin/dejima`, and `entrypoint.sh` refuses an empty one rather than naming a harness a given image may not contain | `harnesses/<name>/harness.conf`, `docker/entrypoint.sh` |
| Proxied endpoint | which name the in-container TLS terminator answers for, and what `/etc/hosts` pins to loopback. The manifest's `proxied_api_domain`; a credential proxy with no domain declared is refused rather than started answering for nothing | `harnesses/<name>/harness.conf`, `docker/entrypoint.sh` |
| Egress allowlist | `platform.claude.com` always, `api.anthropic.com` only when no credential proxy is in front of it. `api_domains` and `proxied_api_domain` in the manifest | `harnesses/<name>/harness.conf` |
| Credentials | the whole proxy exists because Claude Code authenticates from `~/.claude/.credentials.json` rather than an env var; the entrypoint seeds a well-formed fake so it has something to read | `bin/dejima-anthropic-proxy`, `docker/anthropic-proxy-forwarder.js`, `docker/entrypoint.sh` |
| Credentials | the whole proxy exists because Claude Code authenticates from `~/.claude/.credentials.json` rather than an env var; the entrypoint seeds a well-formed fake so it has something to read | `bin/dejima-credential-proxy`, `docker/credential-proxy-forwarder.js`, `docker/entrypoint.sh` |
| Hook protocol | the event payload arrives on stdin; `SessionStart` returns `hookSpecificOutput.hookEventName`/`additionalContext` as JSON, while a `PreToolUse` hook blocks by exiting 2 with its reason on stderr | `hooks/*.sh` |
| Hook registration | four hooks written into `~/.claude/settings.json` in Claude Code's schema, one `SessionStart` with a `startup\|resume\|clear\|compact` matcher and three `PreToolUse` matching `Bash`. In the plugin, gated per declared guard | `bin/harness/claude-code.sh` |
| Briefing delivery | **by which of three routes**, not whether. Claude Code takes the briefing as a system prompt, through the flag its manifest names in `briefing_args`, because a `SessionStart` payload past roughly nine kilobytes is persisted and replaced with a preview - so the hook route silently under-delivered a briefing carrying a project's `.dejima.md`. Cline takes no such flag and registers no hook, so it declares a `briefing_path` and reads a file `entrypoint.sh` writes root-owned and read-only. A harness doing neither still gets it through the hook. What the briefing *says* is none of theirs: composing it host-side is core's, and the container-side part - this container's id, the declared-versus-installed gap, the project's `.dejima.md` - is one script every route calls | `hooks/dejima-briefing-render.sh`, `hooks/sandbox-context.sh`, `docker/entrypoint.sh` |
+9 -3
View File
@@ -25,9 +25,15 @@
# 2. Authentication. ttyd is started with no credential of its own (see
# bin/dejima-ttyd), because it is bound to 127.0.0.1 where only this
# host can reach it - this proxy is the only gate in front of it. A
# vhost without auth_basic (or something stronger in its place) is an
# unauthenticated host shell on your LAN. Put every listen behind it:
# a second server block without auth is the same open door.
# vhost that authenticates nothing is an unauthenticated host shell on
# your LAN. Put every listen behind it: a second server block without
# auth is the same open door.
#
# This file uses HTTP Basic because it is the shortest thing to show.
# One shared credential, no per-operator identity, no second factor,
# revocation by editing htpasswd. docs/remote-access.md, under
# "Authenticating the front door", is what that costs and what else
# meets the requirement.
#
# Authentication alone does not stop a page in your browser from
# opening a WebSocket to this vhost and having the browser attach your
+29 -7
View File
@@ -76,6 +76,7 @@ value never does:
| `export` | `NAME=key` or `NAME=@key` for the `env_export` forge key - see [Forge](#forge) below. |
| `template` | Prose that may contain `{{placeholder}}` markers; the placeholder names are checked against the briefing whitelist at composition time, not here. |
| `keyspec` | One element of a `keys` list - see [The `keys` field](#the-keys-field-a-providers-own-config-schema) below. |
| `repopath` | `<subcommand>:<first path segment>` for the `repo_paths` forge key - where a repository appears in a path rather than behind a flag. |
| `list:TYPE` | Space-separated (not colon-separated, unlike a config-file list) elements, each checked against `TYPE`. |
A leading `?` on any type (`?hostname`, `?list:identifier`, ...) makes the
@@ -123,9 +124,10 @@ operator can set, resolved the same way as any other config key
| `pr_command` | `?command` | (empty) | The PR-management command name the forge's briefing prose and `env_export`/`merge_block` refer to. |
| `issue_command` | `?command` | (empty) | The issue-management command name, same idea. |
| `merge_block` | `?command` | (empty) | The command prefix refused inside the sandbox - only when the workflow's `block_merge = on`. Enforced by `docker/forge-guard.sh`, which shadows the named CLI on PATH and compares argv, so it holds for a harness that cannot intercept a tool call; `hooks/merge-guard.sh` applies the same rule to a command string, for the harnesses that can. Matched as a run of consecutive non-flag arguments, so a flag before the subcommand does not slip past it. |
| `repo_flags` | `?list:flag` | (empty) | The option names this forge's CLI accepts for naming a repository other than the one the session was launched against - `--repo -R` for `gh`. `docker/forge-guard.sh` refuses a value that is not that repository, in every spelling (`--repo x`, `--repo=x`, and a one-dash flag with its value attached). Fast-fail, not a boundary, for the same reason `merge_block` is not one: the session holds the token. Leave it empty for a CLI that resolves the repo itself and refuses another host, as the gitea ones do - a forge is not required to be aimable. |
| `identity_keys` | `?list:identifier` | (empty) | Which of this forge's own `keys` belong to a **person** rather than to the project - the credential paths. One declaration, two rules: a key named here is one an `[identity.<name>]` section may set, and one a project may not declare in its own `.dejima/`. Core keeps no list of its own, so a forge whose credential is spelled differently needs no change here. The identity inherits the type from this forge's `keys` entry, not a second copy of it. |
| `repo_flags` | `?list:flag` | (empty) | The option names this forge's CLI accepts for naming a repository other than the one the session was launched against - `--repo -R` for `gh`. `docker/forge-guard.sh` refuses a value that is not that repository, in every spelling of the flag itself: `--repo x`, `--repo=x`, `-R x`, `-Rx`, and the letter inside a short-flag cluster (`-wR x`, `-wRx`). Fast-fail, not a boundary, for the same reason `merge_block` is not one: the session holds the token. Leave it empty for a CLI that resolves the repo itself and refuses another host, as the gitea ones do - a forge is not required to be aimable. |
| `identity_keys` | `?list:identifier` | (empty) | Which of this forge's own `keys` name an **account** rather than the forge itself - the credential paths, which differ per session, where an API URL does not. One declaration, two rules: a key named here is one an `[identity.<name>]` section may set, and one a project may not declare in its own `.dejima/`. Core keeps no list of its own, so a forge whose credential is spelled differently needs no change here. The identity inherits the type from this forge's `keys` entry, not a second copy of it. |
| `repo_env` | `?identifier` | (empty) | The environment variable this forge's CLI reads for the same thing - `GH_REPO`. Declared separately because it takes no argument and would otherwise be the way around every flag above. |
| `repo_paths` | `?list:repopath` | (empty) | A repository this forge's CLI names in a path rather than in a flag, as `<subcommand>:<first segment>` - `api:repos` for `gh api repos/<owner>/<name>/...`. Scoped to a subcommand on purpose: comparing every positional argument that contains two slashes would refuse an issue title that happens to look like one, and a false refusal costs an honest agent a working command. Paths carrying `gh`'s own `{owner}`/`{repo}` placeholders are left alone, since those resolve to this repository by construction. |
| `disclose` | `?disclosures` | (empty) | Which of this forge's commands send a body the session authored, as comma-separated `kind:command prefix` entries - `pr:gh pr create, issue:gh issue create, ...`. `docker/forge-guard.sh` prepends a marker to the body of a matching command, so a PR, issue or comment an agent wrote says so. The three kinds are `pr`, `issue` and `comment`; the wording of each marker is core's, not the manifest's, so that dejima says one thing about agent authorship whichever forge is selected. Unlike `merge_block` this does not depend on the workflow: what a workflow permits is a policy, who wrote the text is a fact. |
| `keys` | `?list:keyspec` | (empty) | This forge's own `[forge.<name>]` config schema - see above. |
| `requires` | `?list:identifier` | (empty) | Which of this forge's own config keys an operator is expected to set for it to actually work - checked at launch by two refusal rules (partly-configured, and none-configured-with-a-fixed-host) when the workflow pushes; see [Validation](#validation). A key with a non-empty schema default never counts as missing for either check. |
@@ -172,7 +174,7 @@ as environment variables.
| `requires` | `?list:identifier` | (empty) | Forge capabilities this workflow needs (checked against the selected forge's `capabilities` at launch). |
| `push` | `bool` | `on` | Whether this workflow pushes at all. When `off`, `DEJIMA_PUSH_HOSTS` is empty regardless of the forge, so `git-push-guard.sh` refuses every push. |
| `blocked_branches` | `?list:identifier` | (empty) | Branch names `git-push-guard.sh` refuses a direct push to, even on an allowed host. |
| `block_merge` | `bool` | `off` | When `on`, resolves the selected forge's `merge_block` command into `DEJIMA_MERGE_BLOCK` for `hooks/merge-guard.sh`. |
| `block_merge` | `bool` | `on` | When `on`, resolves the selected forge's `merge_block` command into `DEJIMA_MERGE_BLOCK` for `hooks/merge-guard.sh`. Defaults on so a workflow that omits it refuses a merge rather than permitting one; every shipped workflow sets it anyway. |
| `requires_worktree` | `bool` | `off` | When `on`, this workflow's briefing fragment must contain a `<!-- core: worktree -->` marker, which the core worktree-containment paragraph is spliced into - see below. |
| `autonomy` | `enum:commit\|push-branch\|open-pr` | `commit` | Gates `<!-- requires: autonomy=VALUE -->` spans in this workflow's own briefing fragment (see below); also read by the forge/harness resolvers where autonomy-conditioned prose applies. |
| `briefing` | `?relpath` | (empty) | This workflow's briefing fragment file, relative to its own directory. |
@@ -509,13 +511,33 @@ knowing about because they are what a second forge costs:
- **`gh` can be aimed past the sandboxed repository, and is stopped.** The
gitea CLIs refuse an origin that is not on the configured host, so they can
only act on the repo the sandbox was launched against. `gh` takes `--repo`
on nearly every subcommand and reads `GH_REPO`, so this forge declares both
(`repo_flags`, `repo_env`) and `docker/forge-guard.sh` refuses a value that
is not the launched-against repository. Fast-fail, not a boundary, exactly
as `merge_block` is: the session holds the token and could call the API
on nearly every subcommand, reads `GH_REPO`, and names repositories in `gh
api` paths, so this forge declares all three (`repo_flags`, `repo_env`,
`repo_paths`) and `docker/forge-guard.sh` refuses a value that is not the
launched-against repository. Fast-fail, not a boundary, exactly as
`merge_block` is: the session holds the token and could call the API
directly. What actually bounds it is the token's scope, so a per-repository
fine-grained PAT is the expected shape of `push_token_file`.
**What it sees, and what it does not.** Worth stating both, because a guard
described only by what it catches reads like a boundary. It compares, case
-insensitively: `--repo x` and `--repo=x`; `-R x` and `-Rx`; the same letter
inside a short-flag cluster, `-wR x` and `-wRx`; `GH_REPO`; and
`gh api repos/<owner>/<name>/...`, with or without a leading slash. A launch
that resolved no repository at all refuses every one of those rather than
waving them through.
It does not see a GraphQL `mutation` sent through `gh api graphql`, a
repository named in an `--jq` expression or a `-F`/`-f` field, an API path
under a segment other than `repos/`, or `gh` invoked through something else
that passes the repository along. Nor can it know that the workspace's
`origin` is the repository you meant: it compares against whatever origin
the checkout has, so a workspace pointing somewhere unexpected shifts what
counts as "this repository" rather than being caught by this. That list is
not exhaustive either, and it is not meant to be - widening it further would
mean parsing each subcommand's flags, which is the coupling this guard
exists to avoid. The token's scope is what bounds the ones it misses.
So: if you have a choice, `gitea` is the better-worn path. Choose `github`
because that is where your repositories are, which is a good reason - and
expect to be the person who finds the next crack.
+450 -217
View File
@@ -36,9 +36,9 @@ attached. The two things holding that line are:
- **[The bind address](#the-bind-address-is-the-one-thing-to-get-right).**
`ttyd` binds `127.0.0.1` and your proxy must bind one specific LAN
address - never the wildcard, never a Docker bridge.
- **Authentication on the proxy.** `ttyd` carries no credential of its own,
so a `server` block without `auth_basic` is an unauthenticated host shell
for everyone on that network.
- **[Authentication on the proxy](#authenticating-the-front-door).** `ttyd`
carries no credential of its own, so a `server` block that authenticates
nothing is an unauthenticated host shell for everyone on that network.
What it does **not** add is a way *out* of a sandbox. Configured as
described below, a compromised session cannot reach `ttyd`, cannot reach
@@ -50,194 +50,8 @@ front door opening *inward*, and that is a full sandbox escape rather than a
degradation. [Verifying it](#verifying-it) is two commands, and worth
running.
## `[remote-access]`
A section of the single config file, at `~/.config/dejima/config`
(`$DEJIMA_CONFIG_DIR` relocates the whole config tree, same as it does for
approvals):
| Key | Default | Purpose |
| --- | --- | --- |
| `enabled` | `off` | the switch - nothing below runs until this is `on` |
| `ntfy_url` | unset | ntfy topic to push notifications to. `dejima-notify-watch` refuses to start without it; nothing else reads it |
| `base_url` | unset | public base URL of the web front door, used to build the notification's deep link. Absent, pushes still go out - just with no link on them |
| `ttyd_port` | `7681` | loopback port `ttyd` listens on. The reverse proxy has to `proxy_pass` to the same one |
| `ttyd_font_size` | unset | terminal font size in px, 8-72. Applies to *every* client, phone and desktop alike - see "Reading it on a phone". Unset passes no option at all and leaves `ttyd` on its own default |
| `ttyd_button_colors` | unset | whether the toolbar is colour-coded by what each button does, in the same gruvbox palette the session picker uses. Unset means yes; `off` restores the grey every key had before. See "Reading it on a phone" |
| `ttyd_focus_on_tap` | unset | whether tapping the terminal raises the soft keyboard. Unset means no on a touch device and yes on a desktop, which is almost always what you want; `on` or `off` forces it. Overridable per client with `?focus=1` / `?focus=0` |
| `ttyd_tmux_prefix` | unset | the tmux prefix the terminal page's `PREFIX`, `◀W`, `W▶` and `ZOOM` buttons send, written as a control chord: `C-a`, `C-b`, `C-Space`, `C-@`, `C-_`, `C-?`. Unset leaves the page on stock tmux's `C-b`. See "Reading it on a phone" |
**If this host sits behind a firewall it does not control**, the push has to
be permitted outbound before any of this works. `dejima-notify-watch` runs on
the host, not in the container, so the container's own allowlist has nothing
to do with whether it can reach your ntfy server - and a self-hosted one on a
private address is exactly the case a host-level egress filter drops.
That failure is the quiet kind. Nothing errors, the config is correct,
`systemctl --user status dejima-notify-watch` looks healthy, and the phone
simply never buzzes. If you administer that filter, the rule is yours to add;
if someone else does, this is the thing to ask them about before re-reading
your config a third time.
`ntfy_url` and `base_url` must carry an explicit `http://` or `https://`
scheme; a value without one is refused rather than guessed at, because
`curl` reads a leading `-K/path` as an *option* that takes the request's URL
from that file instead. Values are restricted to
`A-Za-z0-9:/._~?=%@+-`: no whitespace and nothing shell-active, since these
end up in an HTTP header and in a URL handed to a phone.
**A malformed config is a hard failure, not a warning.** The whole file is
validated on every read, not just the key being asked for: an unknown key, a
value outside that character set, or a line that doesn't parse makes
`dejima-ttyd` and `dejima-notify-watch` refuse to start and say which line
was wrong. A `ttyd_port` that isn't a number in 1-65535 is refused the same
way, as is a `ttyd_font_size` outside 8-72 - a size big enough to make one
glyph wider than the viewport renders a terminal that shows nothing, which
reads as a broken front door rather than as a typo three sections away. A
`ttyd_tmux_prefix` that is not a control chord is refused here too, rather
than on the phone, hours later, by whoever presses `PREFIX` and gets nothing.
`dejima-ttyd` and `dejima-notify-watch` are the two scripts that read a
*value* out of the config, so a typo anywhere in the file refuses their
start. `dejima` reads values too (`audit dir` among them) and refuses the
same way. `dejima-attach` alone only asks `remote_enabled` - deliberately
bypassing the whole-file validation, so a typo elsewhere in the config can't
lock you out of the terminal you'd fix it from.
```
# ~/.config/dejima/config - substitute your own topic and hostname
[remote-access]
enabled = on
ntfy_url = https://ntfy.example.invalid/your-topic-here
base_url = https://dejima.example.invalid
ttyd_port = 7681
# ttyd_font_size = 20 # optional, and applies to the desktop too
# ttyd_tmux_prefix = C-a # optional; the page assumes stock C-b without it
```
**Editing this changes nothing until the services restart.** Every value
is read once, at process start - `dejima-ttyd` reads `ttyd_port`,
`ttyd_font_size` and `ttyd_tmux_prefix` and then `exec`s `ttyd` with them
baked into its argv. So:
```sh
systemctl --user restart dejima-ttyd.service
systemctl --user restart dejima-notify-watch.service # if ntfy_url changed
pgrep -a ttyd # confirm the argv is what you just configured
```
Skipping this is quiet rather than loud: nothing warns, and the front door
keeps serving with the old settings, which reads as "the setting had no
effect" rather than as "the process is older than the file".
## How it works
Almost none of this is in the sandbox. The container writes one empty file
and knows nothing else about the feature; everything that turns that into a
notification, and everything that puts a terminal in front of you
afterwards, runs on the host as your own user.
### The session asks you something
```
┌─ the sandbox container ────────────────────────────────────────────┐
│ claude code, at a Notification or Stop hook │
│ │ │
│ │ hooks/dejima-notify.sh writes an empty file named │
│ ▼ `waiting` or `done` - no content, no stdin, no network │
└───────┼────────────────────────────────────────────────────────────┘
════════╪═══ container boundary - one bind-mounted directory ═════════
┌───────▼─── your machine, as your user ─────────────────────────────┐
│ ${XDG_RUNTIME_DIR}/dejima/notify/<run-id>/waiting │
│ │ │
│ │ inotify, or a ~6s poll where inotify-tools is absent │
│ ▼ │
│ dejima-notify-watch (systemd --user) │
│ │ consumes the marker by NAME; never reads its content │
│ │ every byte of the push is composed from host-side data │
│ ▼ │
└───────┼────────────────────────────────────────────────────────────┘
════════╪═══════════════ off your machine ════════════════════════════
│ curl
▼
your ntfy topic ──── push ────► your phone
"dejima: <project> needs input"
link: <base_url>/?arg=<run-id>
```
The marker directory is the sandbox's entire surface here, and it is
one-way: a name, out. Nothing the session writes reaches the push, which is
why the notification says a project needs input and never what it is asking.
[Notifications](#notifications) has the detail, including where that
guarantee actually lives.
### You come back
```
browser, on the link from the push any plain SSH client
│ │
│ https://<base_url>/?arg=<run-id> │ ssh dejima-box
│ HTTP Basic over TLS │ key with a
▼ ▼ forced command
════════╪══════════════════ your LAN ══════════════════╪══════════════
┌─ your machine, as your user ───────────────────────────────────────┐
│ nginx, listening on ONE LAN address sshd │
│ TLS + auth_basic, plus a default_server authorized_keys with │
│ that refuses every other name a forced command │
│ │ │ │
│ │ proxy_pass http://127.0.0.1:7681 │ │
│ ▼ │ │
│ ttyd --interface 127.0.0.1 --check-origin --url-arg │ │
│ │ │ │
│ └──────────────────────┬───────────────────────┘ │
│ ▼ │
│ dejima-attach [<run-id>] │
│ │ a grouped tmux session │
│ ▼ │
│ YOUR TMUX SERVER │
│ the sandbox's window - and every other │
│ window on it, one prefix key away │
└────────────────────────────────────────────────────────────────────┘
═════════════════════ container boundary ═════════════════════════════
┌─ the sandbox container ────────────────────────────────────────────┐
│ routes to the Docker bridge gateway - your host - and to nothing │
│ else of yours. Neither front door is on that address, which is │
│ the whole reason `ttyd` is on loopback and nginx is on one │
│ named LAN interface. A proxy on `0.0.0.0` or on a bridge puts a │
│ host shell in here. │
└────────────────────────────────────────────────────────────────────┘
```
The web door is the one the notification links into: the browser hits your
reverse proxy, which authenticates and forwards to a `ttyd` that only this
host can open a socket to, and `ttyd` runs `dejima-attach` with the run id
from `?arg=`. The SSH door skips all of that. It is a key in your own
`authorized_keys` pinned to a forced command, so a plain SSH app on the
phone runs `dejima-attach` and nothing else no matter what it asks to run,
and lands on a numbered picker of live sessions. It needs no proxy, no
`ttyd`, and no notification to have been sent.
Both converge on `dejima-attach`, and both end at *your* tmux server. That
convergence is the point: the controls worth spending effort on are the two
front doors, because behind them there is no further boundary to cross - a
grouped tmux session shares every window on the server, and the prefix key
opens new ones regardless.
### What `off` means
With `[remote-access] enabled` off (the default), `dejima` builds a
`docker compose` invocation with nothing remote-access in it: no
session state is written, no marker directory is created or mounted, and no
notification hook is registered. `dejima-attach`, `dejima-ttyd` and
`dejima-notify-watch` each refuse to run at all. `install.sh` symlinks those
three onto `PATH` and stops there - it runs no `systemctl`, copies no unit,
and starts nothing.
## The bind address is the one thing to get right
This is the step that carries the whole feature's security, and the only one
where a plausible-looking choice is a full sandbox escape.
`docker/init-firewall.sh` deliberately ACCEPTs container OUTPUT to the
container's default-route address - the Docker bridge gateway, which is your
host. That is the one host address a sandbox can reach at all. Combine it
@@ -320,6 +134,72 @@ asserts this same containment property against a live container (with a
positive control on the same path, so a passing result can't come from the
container reaching nothing), and it passes on a real Docker host.
## `[remote-access]`
A section of the single config file, at `~/.config/dejima/config`
(`$DEJIMA_CONFIG_DIR` relocates the whole config tree, same as it does for
approvals):
| Key | Default | Purpose |
| --- | --- | --- |
| `enabled` | `off` | the switch - nothing below runs until this is `on` |
| `ntfy_url` | unset | ntfy topic to push notifications to. `dejima-notify-watch` refuses to start without it; nothing else reads it |
| `base_url` | unset | public base URL of the web front door, used to build the notification's deep link. Absent, pushes still go out - just with no link on them |
| `ttyd_port` | `7681` | loopback port `ttyd` listens on. The reverse proxy has to `proxy_pass` to the same one |
| `ttyd_font_size` | unset | terminal font size in px, 8-72. Applies to *every* client, phone and desktop alike - see "Reading it on a phone". Unset passes no option at all and leaves `ttyd` on its own default |
| `ttyd_button_colors` | unset | whether the toolbar is colour-coded by what each button does, in the same gruvbox palette the session picker uses. Unset means yes; `off` restores the grey every key had before. See "Reading it on a phone" |
| `ttyd_focus_on_tap` | unset | whether tapping the terminal raises the soft keyboard. Unset means no on a touch device and yes on a desktop, which is almost always what you want; `on` or `off` forces it. Overridable per client with `?focus=1` / `?focus=0` |
| `ttyd_tmux_prefix` | unset | the tmux prefix the terminal page's `PREFIX`, `◀W`, `W▶` and `ZOOM` buttons send, written as a control chord: `C-a`, `C-b`, `C-Space`, `C-@`, `C-_`, `C-?`. Unset leaves the page on stock tmux's `C-b`. See "Reading it on a phone" |
```
# ~/.config/dejima/config - substitute your own topic and hostname
[remote-access]
enabled = on
ntfy_url = https://ntfy.example.invalid/your-topic-here
base_url = https://dejima.example.invalid
ttyd_port = 7681
# ttyd_font_size = 20 # optional, and applies to the desktop too
# ttyd_tmux_prefix = C-a # optional; the page assumes stock C-b without it
```
**Editing this changes nothing until the services restart.** Every value is
read once, at process start, and baked into `ttyd`'s argv. Skipping the restart
is quiet rather than loud: the front door keeps serving with the old settings,
which reads as "the setting had no effect".
```sh
systemctl --user restart dejima-ttyd.service
systemctl --user restart dejima-notify-watch.service # if ntfy_url changed
pgrep -a ttyd # confirm the argv is what you just configured
```
**If this host sits behind a firewall it does not control**, the push has to be
permitted outbound before any of this works. `dejima-notify-watch` runs on the
host, not in the container, so the container's own allowlist has nothing to do
with whether it can reach your ntfy server - and a self-hosted one on a private
address is exactly the case a host-level egress filter drops. That failure is
the quiet kind: nothing errors, `systemctl --user status dejima-notify-watch`
looks healthy, and the phone simply never buzzes.
**A malformed config is a hard failure, not a warning.** The whole file is
validated on every read, not just the key being asked for, so an unknown key or
an unparseable line makes `dejima-ttyd` and `dejima-notify-watch` refuse to
start and say which line was wrong. So does an out-of-range `ttyd_port`
(1-65535) or `ttyd_font_size` (8-72), or a `ttyd_tmux_prefix` that is not a
control chord - refused here rather than on the phone, hours later, by whoever
presses `PREFIX` and gets nothing.
`ntfy_url` and `base_url` must carry an explicit `http://` or `https://` scheme
and are restricted to `A-Za-z0-9:/._~?=%@+-`. A value without a scheme is
refused rather than guessed at, because `curl` reads a leading `-K/path` as an
*option* that takes the request's URL from that file instead; the character set
is because these end up in an HTTP header and in a URL handed to a phone.
`dejima` itself refuses the same way, since it reads values too.
`dejima-attach` alone reads only `remote_enabled`, deliberately bypassing the
whole-file validation, so a typo elsewhere in the config can't lock you out of
the terminal you'd fix it from.
## SSH front door
Generate a key that exists only for this, and give it a forced command:
@@ -441,8 +321,10 @@ and forgotten. What matters in it:
- **Authentication on every server block.** `ttyd` is started with no
credential of its own, because the loopback bind means only this host can
reach it - the proxy is the entire gate. A block without `auth_basic` is
an unauthenticated host shell on your network.
reach it - the proxy is the entire gate. A block that authenticates nothing
is an unauthenticated host shell on your network. The example uses HTTP
Basic; [Authenticating the front door](#authenticating-the-front-door) is
what that costs and what else meets the requirement.
- **A `default_server` that refuses.** nginx picks a server block by
SNI/`Host`, and when nothing matches it serves the **default** for that
listen address - which, if the front door is the only block on it, is the
@@ -465,6 +347,163 @@ and forgotten. What matters in it:
- **A long `proxy_read_timeout`.** An attached terminal sits idle for hours;
nginx's default is 60 seconds.
**The page carries its own colours.** dejima's terminal output uses indexed
colour so your terminal's theme fills the slots - but a browser has no theme
to inherit, so the front door is handed gruvbox explicitly: all sixteen ANSI
slots plus background, foreground and cursor. Without it xterm.js draws with
its own defaults, and the same session looks one way over SSH and another in
the browser. `bin/dejima-ttyd` sets it, and `web/src/page.css` carries the
same values for the chrome around it.
## Authenticating the front door
`ttyd` binds loopback and carries no credential, so whatever sits in front of
it is the entire gate. The requirement is that it authenticates **every
request to every server block, the default one included** - not that it is any
particular software. These are the shapes that meet it, roughly in order of
how much they change.
**A network the public internet cannot reach.** Tailscale, WireGuard, or any
VPN that puts the listener on an interface nothing outside can route to.
First because it removes the exposure rather than authenticating it, because
it composes with everything below, and because for a lot of readers it is both
the simplest answer and the strongest. It does not replace [the bind
address](#the-bind-address-is-the-one-thing-to-get-right): a VPN interface is
still one specific address to bind, and the wildcard is still wrong.
**HTTP Basic**, which is what the example config shows. One shared credential,
no per-operator identity, no second factor, revocation that means editing
`htpasswd`, and an access log that cannot tell two people apart. For a solo
operator that is a reasonable trade, and worth saying that it is one; for
anyone else it is the first thing to replace. It also sends the password on
every request, which is why TLS is not optional here.
**Client certificates.** `ssl_client_certificate` and `ssl_verify_client on`
in the same block. Per-device identity, revocable through a CRL, and nothing
worth stealing sent on every request. The cost is distribution and renewal,
and a phone that has to have the certificate installed - the part that will
bite, and the part that differs per platform.
**A forward-auth proxy.** oauth2-proxy or Authelia via nginx `auth_request`:
OIDC, per-user identity, and a session cookie rather than a credential on
every request. Also where passkeys realistically arrive, since the proxy
handles WebAuthn and `ttyd` never learns about it. The cost is another moving
piece in the path to a host shell, which is worth naming given what is on the
other side of it.
**Something you already run.** An identity-aware gateway, or an
authenticating proxy already in the path. The requirement above is the whole
of what this document asks of it.
**None of these stops a browser that has already authenticated.** Whatever is
in front, a page loaded in a session that has passed it can act - which is the
same property that makes the front door usable from a phone you unlocked an
hour ago. Authentication decides who gets a shell, not what an already-open
tab does with one.
## How it works
Almost none of this is in the sandbox. The container writes one empty file
and knows nothing else about the feature; everything that turns that into a
notification, and everything that puts a terminal in front of you
afterwards, runs on the host as your own user.
### The session asks you something
```
┌─ the sandbox container ────────────────────────────────────────────┐
│ claude code, at a Stop hook │
│ │ │
│ │ hooks/dejima-notify.sh writes an empty file named │
│ ▼ `done` - no content, no stdin, no network │
└───────┼────────────────────────────────────────────────────────────┘
════════╪═══ container boundary - one bind-mounted directory ═════════
┌───────▼─── your machine, as your user ─────────────────────────────┐
│ ${XDG_RUNTIME_DIR}/dejima/notify/<run-id>/done │
│ │ │
│ │ inotify, or a ~6s poll where inotify-tools is absent │
│ ▼ │
│ dejima-notify-watch (systemd --user) │
│ │ consumes the marker by NAME; never reads its content │
│ │ every byte of the push is composed from host-side data │
│ ▼ │
└───────┼────────────────────────────────────────────────────────────┘
════════╪═══════════════ off your machine ════════════════════════════
│ curl
▼
your ntfy topic ──── push ────► your phone
"dejima: <project> finished"
link: <base_url>/?arg=<run-id>
```
The marker directory is the sandbox's entire surface here, and it is
one-way: a name, out. Nothing the session writes reaches the push, which is
why the notification says a project has stopped and never what it is asking.
[Notifications](#notifications) has the detail, including where that
guarantee actually lives.
### You come back
```
browser, on the link from the push any plain SSH client
│ │
│ https://<base_url>/?arg=<run-id> │ ssh dejima-box
│ HTTP Basic over TLS │ key with a
▼ ▼ forced command
════════╪══════════════════ your LAN ══════════════════╪══════════════
┌─ your machine, as your user ───────────────────────────────────────┐
│ nginx, listening on ONE LAN address sshd │
│ TLS + auth, plus a default_server authorized_keys with │
│ that refuses every other name a forced command │
│ │ │ │
│ │ proxy_pass http://127.0.0.1:7681 │ │
│ ▼ │ │
│ ttyd --interface 127.0.0.1 --check-origin --url-arg │ │
│ │ │ │
│ └──────────────────────┬───────────────────────┘ │
│ ▼ │
│ dejima-attach [<run-id>] │
│ │ a grouped tmux session │
│ ▼ │
│ YOUR TMUX SERVER │
│ the sandbox's window - and every other │
│ window on it, one prefix key away │
└────────────────────────────────────────────────────────────────────┘
═════════════════════ container boundary ═════════════════════════════
┌─ the sandbox container ────────────────────────────────────────────┐
│ routes to the Docker bridge gateway - your host - and to nothing │
│ else of yours. Neither front door is on that address, which is │
│ the whole reason `ttyd` is on loopback and nginx is on one │
│ named LAN interface. A proxy on `0.0.0.0` or on a bridge puts a │
│ host shell in here. │
└────────────────────────────────────────────────────────────────────┘
```
The web door is the one the notification links into: the browser hits your
reverse proxy, which authenticates and forwards to a `ttyd` that only this
host can open a socket to, and `ttyd` runs `dejima-attach` with the run id
from `?arg=`. The SSH door skips all of that. It is a key in your own
`authorized_keys` pinned to a forced command, so a plain SSH app on the
phone runs `dejima-attach` and nothing else no matter what it asks to run,
and lands on a numbered picker of live sessions. It needs no proxy, no
`ttyd`, and no notification to have been sent.
Both converge on `dejima-attach`, and both end at *your* tmux server. That
convergence is the point: the controls worth spending effort on are the two
front doors, because behind them there is no further boundary to cross - a
grouped tmux session shares every window on the server, and the prefix key
opens new ones regardless.
### What `off` means
With `[remote-access] enabled` off (the default), `dejima` builds a
`docker compose` invocation with nothing remote-access in it: no
session state is written, no marker directory is created or mounted, and no
notification hook is registered. `dejima-attach`, `dejima-ttyd` and
`dejima-notify-watch` each refuse to run at all. `install.sh` symlinks those
three onto `PATH` and stops there - it runs no `systemctl`, copies no unit,
and starts nothing.
## Reading it on a phone
`dejima-ttyd` launches `ttyd` with `--index web/index.html`, which replaces
@@ -474,15 +513,13 @@ there is nothing to turn on.
`ttyd`'s own page is close to unusable on a phone for anything beyond typing,
and not because of a setting anyone missed. xterm.js turns a tap into a mouse
click, but a touch *drag* is consumed by the browser as viewport scrolling and
never becomes a mouse drag - so nothing downstream ever sees one. tmux binds
`MouseDrag1Border` to `resize-pane -M` by default and nvim resizes splits from
separator drags whenever `mouse` is on; both have always worked, and simply
never received a drag event from a touch client. The same missing drag is why
selecting text is impossible, and scrolling an application's output with it.
Then there is no Ctrl key, the tmux status bar's window names are a few pixels
wide, and `ttyd` serves no viewport meta tag at all - so a phone lays the page
out at a virtual desktop width and scales the result down, which no font size
can rescue.
never becomes a mouse drag, so nothing downstream ever sees one: tmux's
`resize-pane -M` and nvim's split-separator drags have always worked and simply
never received the event. The same missing drag is why selecting text and
scrolling an application's output are impossible. Then there is no Ctrl key,
the tmux status bar's window names are a few pixels wide, and `ttyd` serves no
viewport meta tag at all, so a phone lays the page out at a virtual desktop
width and scales the result down, which no font size can rescue.
### Gestures
@@ -525,7 +562,10 @@ PREFIX ◀W W▶ SESS ZOOM DRAG PASTE KBD
without closing the tab. It reloads the page without the run-id, which is
what makes `dejima-attach` show the list again. The session you leave is
unaffected: each client gets its own tmux session grouped onto the real one,
and only that view is reaped when you detach.
and only that view is reaped when you detach. The picker arrives on a
cleared screen, scrollback included - it is a page load rather than a
scroll, so otherwise it draws over the tail of the session you just left and
a swipe up lands in the middle of it.
- `ZOOM` is prefix + `z`, toggling tmux's pane zoom - the fastest way to make
one pane usable on a small screen.
- `PASTE` reads the phone's clipboard and sends it, wrapped in bracketed-paste
@@ -705,8 +745,8 @@ cost of losing the correction everywhere else.
Terminating TLS somewhere else and forwarding here is a supported shape, but
read this first: it moves the front door out of the LAN threat model
everything above is written against. If the outer proxy is internet-facing,
`auth_basic` over TLS is now the only thing between the public internet and
a host shell. Put something stronger in front of it, and take the
whatever authenticates it is now the only thing between the public internet
and a host shell, and HTTP Basic is a thin thing for that to be. Put something stronger in front of it, and take the
[security notes](security.md) as the floor rather than the ceiling.
The mechanical part has four requirements at the outer hop, and each one
@@ -758,14 +798,24 @@ 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 - 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.
`dejima` registers `hooks/dejima-notify.sh` as a `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 `done` into
this run's marker directory: no content, no stdin, no network.
**One push per stop, and not a second one a minute later.** The hook and the
watcher both understand a `waiting` event as well, and Claude Code's
`Notification` hook is where it would come from - but that hook fires when
Claude needs permission to run something and when the prompt has sat idle,
and a dejima session runs with permission prompts off. Only the timer is
left, and it expires shortly after the Stop it follows, so registering both
sent two pushes for one event with the second saying nothing the first had
not. What is given up is being told about a session that has gone idle
without having stopped first, which Claude Code cannot currently distinguish;
a harness that can may register `waiting` and get its own notification.
On the host, `dejima-notify-watch` turns those markers into ntfy pushes.
Same installation shape as above:
@@ -811,8 +861,9 @@ 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`
(or `dejima: <project> finished`), a fixed one-line body, and - when
**What a notification contains.** A title of `dejima: <project> finished`
(or `dejima: <project> needs input`, for a harness that registers `waiting`),
a fixed one-line body, and - when
`base_url` is set - a click link of `<base_url>/?arg=<run-id>` that opens the
web front door already attached to that session. `<project>` is the
workspace directory's name, taken from the host-side registry and reduced to
@@ -860,9 +911,9 @@ the next one is worth checking.
and `echo $DEJIMA_NOTIFY_DIR`. Both empty means `dejima` started this
container with remote access off - `[remote-access] enabled` was off at
launch, and no later change affects a session already running.
2. **Is the hook registered?** The `Notification` and `Stop` entries live in
the throwaway override layer, not in your real `settings.json`:
`grep -c dejima-notify /etc/dejima-settings.json` from inside should be 2.
2. **Is the hook registered?** The `Stop` entry lives in the throwaway
override layer, not in your real `settings.json`:
`grep -c dejima-notify /etc/dejima-settings.json` from inside should be 1.
That file is passed to the harness by `harness-wrapper.sh`, as whatever
its manifest's `settings_args` names (`--settings` for Claude Code);
if it is missing entirely, this container was not started by `dejima`.
@@ -883,7 +934,12 @@ the next one is worth checking.
with a 404 usually means `ntfy_url` names the server but no topic; a 401
or 403 means the instance wants credentials, which the URL can carry as
`https://<user>:<token>@<host>/<topic>` - nothing here sends an auth
header of its own.
header of its own. That URL is handed to `curl` on stdin rather than as an
argument, because every argument of every process on a host is readable by
any local account through `/proc/<pid>/cmdline`, and a token in a URL is a
token in `ps` output for as long as the request runs. Worth knowing if you
reach for `curl -sv "$ntfy_url"` yourself in the next step: that one does
put it there, briefly, on a machine you are already logged into.
5. **Can this host reach the ntfy server at all?** `curl -sv "$ntfy_url"`
from the host, not from a session. A host-level egress filter this machine
does not control drops the push with nothing in any log here, and every
@@ -954,3 +1010,180 @@ from shrinking the desktop's view, and the option's only scopes are the
window - which a grouped session *shares* with yours, so setting it there
would permanently overwrite your choice with nothing to restore afterwards -
and global. An operator who deliberately set `smallest` keeps it.
## Mounting the box's files
The front doors above get you a terminal. The other half of working from
another machine is the files - the screenshots, specs and scratch output a
session leaves behind - and `sshfs` puts those directories in your own file
manager and editor over the SSH access you already have.
Read the next section before setting it up. What it costs is not obvious from
the fact that it is only a filesystem mount, and it is not a cost dejima can
carry for you: nothing dejima runs is in the path.
### What mounting it costs you
**Every byte and every name in those directories was written by a sandboxed
session.** That is what they are for - a handoff directory a session cannot
write to is not a handoff directory - so this is not a defect to fix but the
surface to know about. dejima sanitises session-authored bytes before
printing them to your terminal, in `dejima-audit` and `dejima-epitaph`,
because that is the one surface it controls. A file manager on another
machine is not that surface, and nothing carries the sanitising there.
**Mounting removes the deciding.** Copying a file off the box when you want
it is a decision per file. A mount, and especially one a login unit brings up
for you, means the directory is simply present whenever you are logged in -
and a desktop does not wait to be asked. A file indexer walks new directories and
opens what it finds to extract text from PDFs, archives and office documents.
A thumbnailer decodes images and video frames. A previewer parses on
selection. Those are image and document parsers, running as you, unsandboxed,
reached with no click. dejima's whole premise is that the session may be
hostile; none of that software was written with that assumption about a
directory on your own disk. This is the [threat
model](threat-model.md#ranked-by-what-is-actually-likely)'s fifth-ranked item
with its one bound removed - it is still the operator's side of the handoff,
but no person decides per file any more.
**Names are session-chosen too, and a desktop renders names.** Control
characters, bidi overrides, and a long name whose visible tail is not its real
extension all survive into a file listing. The rule for a terminal applies
unchanged to a file manager: what is displayed is not evidence of what it is.
**Opening the directory as a project is code execution.** Editors act on
configuration they find beside the files - `.envrc` for direnv, a `.vscode`
directory, a project-local `exrc`, a `.git/config` that any later `git`
command run in that directory honours. A session that can write the directory
chooses those files as freely as it chooses the screenshots. Pull files out of
the mount; do not point a project-opening tool at it.
**The mount is read-write, and your desktop uses that.** Thumbnail caches,
trash directories, editor swap and undo files land in the share, where every
later session reads them. An editor swap file can hold buffer content from
the file you had open, and deleting something in a file manager usually moves
it to a trash directory inside the same mount rather than removing it.
**Symlinks resolve on your machine.** `sshfs` passes a symlink through as a
symlink, so one a session planted resolves against *your* filesystem: a
backup job, an indexer, `cp -r` or `rsync -L` pointed at the mount can be led
out of it and into your own files. `-o follow_symlinks` inverts that by
resolving them on the box instead, which hands you the box's files rather
than yours. Neither is what you want, so do not add that option and do not
point recursive tools at the mount.
**What it does not add is a route inward.** A session cannot see the mount,
cannot cause one, and gains nothing from the fact that one exists. The ssh
credential involved already gets you a shell on that box, so it is not a new
grant either. Everything above is about where session-authored bytes end up,
which is a machine that appears nowhere in dejima's blast-radius table.
**Keeping it small** is most of the answer, and unmounting is the biggest
lever: none of the above happens to a directory that is not currently there.
Then mount the handoff directories rather than the box's home, add `-o ro` for
one you only ever pull from, exclude the mount point from your indexer's and
your backup tool's scope, and [clear `share_dir` out
periodically](configuration.md#shared-scratch-space) - which now means stale
files on two machines rather than one.
### Setting it up
**Use your ordinary key, not the front-door one.** A `command=` in
`authorized_keys` replaces whatever the client asked to run, and a
`subsystem sftp` request is not an exception, so a client offering the phone
key gets `dejima-attach` where it expected the SFTP protocol and fails
without explaining itself. Do not relax that key to make this work: the
forced command is the whole of what makes it safe to leave on a phone. The
key that already gets you a normal shell on the box is the right one, and it
grants nothing new here - you had the shell.
**Find the host path first.** `~/shared` is where the *container* sees it; on
the host it is one project's directory under `[core] share_dir`, which defaults
to `~/.local/share/dejima/shared` and is not named `shared` at all.
`dejima --print-config | grep share_dir` on the box prints the resolved root,
and `dejima-share list` prints what is under it. Mount `<share_dir>/projects`
to get one directory per project with no loose files mixed in, or a single
project's directory - `dejima-share path <project>` - to keep the mount to the
one you actually pull from.
Anything in `[mounts] read_write` needs no lookup, because those mount at the
host path they name: `~/specs` in the container is `~/specs` on the box.
```sh
sshfs box:.local/share/dejima/shared/projects ~/mnt/dejima-shared \
-o IdentityFile=/home/<you>/.ssh/id_ed25519,IdentitiesOnly=yes \
-o PasswordAuthentication=no \
-o reconnect,ServerAliveInterval=15,ServerAliveCountMax=3 \
-o Compression=no
```
`IdentitiesOnly=yes` stops ssh offering every key your agent holds ahead of
the one you named, which can exhaust the server's `MaxAuthTries` and fail as
though the key were wrong. `PasswordAuthentication=no` makes an unattended
mount fail immediately rather than block on a prompt nobody is there to
answer. The keepalives are what make `reconnect` worth having: they give up
after about 45 seconds, where a laptop that slept or changed network
otherwise leaves every `ls` hanging on the kernel's TCP timeout.
`Compression=no` because screenshots and PDFs are already compressed.
Two additions worth knowing rather than copying. `-o
Ciphers=aes128-gcm@openssh.com` is faster when both ends have AES-NI, and
slower when they do not - ssh's default puts `chacha20-poly1305` first
precisely because it wins on hardware without it. And `-o idmap=user` if your
account has a different UID on the two machines, or every file arrives owned
by whoever holds that number locally.
A `Host box` block in `~/.ssh/config` carrying the identity and keepalive
options keeps all of this to `sshfs box:... ~/mnt/...`, which is worth doing
once rather than pasting the long form wherever you need it.
**Mount it when you want it, and `fusermount3 -u` when you are done.** Two
commands is not much friction, and it buys back the thing the section above
says a mount costs: while it is unmounted there is no directory for an
indexer to walk or a previewer to open, so the window in which your machine
acts on session output is one you opened on purpose. Every automatic form
below trades that away. There is deliberately no `/etc/fstab` line here -
those run as root, which means a key root can read, root's `~/.ssh/config`
instead of yours, and `allow_other` before your own account can see inside
the mount, at which point every account on the machine can too. That is a
worse deal in every direction than typing `sshfs`.
### On login
**Prefer mounting on demand.** This is here because a mount you have to
remember is a mount you stop using, not because it is the better answer: an
always-present directory is exactly the case the cost section is about. Worth
it for a directory you genuinely work out of, like `~/specs`; not worth it
for one you look at twice a week.
A **user** service runs as you, so the key in your home directory works
without being made readable by anyone else. In
`~/.config/systemd/user/dejima-shared.service`:
```ini
[Unit]
Description=dejima shared directory
Wants=network-online.target
After=network-online.target
[Service]
ExecStart=/usr/bin/sshfs -f box:.local/share/dejima/shared %h/mnt/dejima-shared \
-o IdentityFile=%h/.ssh/id_ed25519,IdentitiesOnly=yes \
-o PasswordAuthentication=no \
-o reconnect,ServerAliveInterval=15,ServerAliveCountMax=3
ExecStop=/bin/fusermount3 -u %h/mnt/dejima-shared
Restart=on-failure
RestartSec=10
[Install]
WantedBy=default.target
```
`-f` is the load-bearing flag: without it `sshfs` daemonises, systemd watches
the parent exit and calls the mount a failure. `fusermount3` is the libfuse3
name, `fusermount` the libfuse2 one. Then
`systemctl --user enable --now dejima-shared`, and one unit per directory -
`%h` expands to your home, so the same file with two paths changed covers
`~/specs`.
`systemctl --user` needs `loginctl enable-linger <you>` if you want the mount
to survive you logging out, the same requirement `dejima-notify-watch` has.
+83 -117
View File
@@ -2,9 +2,8 @@
[← dejima README](../README.md)
The README states each of these ten rules in a sentence or two. This is the
part that didn't fit: why each one matters, what goes wrong without it, and
the detail behind the three the README could only gesture at.
The README states each of these rules in a sentence or two. This is the part
that didn't fit: why each one matters, and what goes wrong without it.
## Check whether they want it first.
@@ -33,45 +32,37 @@ The concrete workflow, step by step:
6. Open the upstream pull request by hand from there, once you would have
been glad to have written it yourself.
The reason for the extra hop is what happens without it. An auto-push
workflow aimed straight at someone else's repository puts a session's raw
output directly into that project's pull request queue, unreviewed by
An auto-push workflow aimed straight at someone else's repository puts a
session's raw output into that project's pull request queue, unreviewed by
anyone, the moment it opens. Maintainers already spend real time triaging a
flood of low-effort AI-generated pull requests; a Godot maintainer's account
of what that actually costs is worth reading in full:
<https://bsky.app/profile/akien.bsky.social/post/3meyerixvhs2p>. Forking and
reviewing first doesn't change what the session is capable of writing. It
changes who reads the output before anyone else has to: you, not a stranger
who owns the project.
flood of low-effort AI-generated pull requests; a Godot maintainer's account of
what that costs is worth reading in full:
<https://bsky.app/profile/akien.bsky.social/post/3meyerixvhs2p>. Forking first
doesn't change what the session is capable of writing. It changes who reads the
output before anyone else has to: you, not a stranger who owns the project.
## Don't point it at proprietary code.
dejima narrows the paths code has to leave through; it doesn't close them,
and "point it only at code you could survive losing" is the practical test
for that gap. Ask it plainly before you start a session, not after: if this
checkout's contents ended up somewhere they shouldn't, would that be a bad
week or the end of the business? Code you could survive losing may be worth
the convenience of unattended agentic work. Code you couldn't shouldn't go in
at all.
dejima narrows the paths code has to leave through; it doesn't close them.
Ask it plainly before you start a session, not after: if this checkout's
contents ended up somewhere they shouldn't, would that be a bad week or the end
of the business? Code you could survive losing may be worth the convenience of
unattended agentic work. Code you couldn't shouldn't go in at all.
That second one is not a judgement about how good the containment is, and it
does not get better with a version bump. There is no containment that fully
prevents exfiltration: a determined attacker with code execution and any
outbound path will find a way. So the question is not whether you trust
dejima enough, but whether you can accept the risk of the contents leaving.
If you cannot, do not hand them to an agent that could send them.
That is not a judgement about how good the containment is, and it does not get
better with a version bump. No containment fully prevents exfiltration: a
determined attacker with code execution and any outbound path will find a way.
The question is not whether you trust dejima enough, but whether you can accept
the risk of the contents leaving.
## Look at what else is in the directory.
The bind mount doesn't know which files you meant to hand over. It exposes
the whole checkout, and everything a working tree accumulates over its life
sits there with the same reachability as the source files you actually care
about: a `.env` a teammate left uncommitted, a database dump pulled down for
local debugging and never deleted, a credential baked into a fixture file,
an old key still sitting in the history of a branch that got checked out.
None of that is code in any sense that matters to the agent; all of it is
just bytes in a directory the session can read and any tool call it makes
can touch.
The bind mount doesn't know which files you meant to hand over. Everything a
working tree accumulates sits there with the same reachability as the source
you actually care about: a `.env` a teammate left uncommitted, a database dump
pulled down for local debugging and never deleted, a credential baked into a
fixture, an old key on a branch that got checked out. All of it is bytes in a
directory the session can read and any tool call it makes can touch.
Auditing a working tree by eye is not a minute's work, and it is the kind of
check that succeeds every time until the once it matters. **Point dejima at a
@@ -82,25 +73,22 @@ directory not to hand over.
## An allowlist entry is a grant, not a convenience.
For every allowlist entry you give a dejima agent, consider what an attacker
could do with it. The answer varies enormously between entries that look
similar, and what it turns on is what happens if the call is wrong, whether
wrong means a prompt-injected instruction or an ordinary agent mistake.
For every allowlist entry, consider what an attacker could do with it. The
answer varies enormously between entries that look similar, and what it turns
on is what happens if the call is wrong, whether wrong means a prompt-injected
instruction or an ordinary agent mistake.
A package registry or a docs site can be wrong without being expensive. A
session that fetches the wrong package version, or scrapes a stale or
misleading page, produces bad input to more code, code that still has to
pass through your own review before any of it ships. The mistake is
contained by the same diff you were always going to read.
session that fetches the wrong package version, or scrapes a misleading page,
produces bad input to more code that still has to pass your own review. The
mistake is contained by the same diff you were always going to read.
A deploy webhook, a production database API, and a payment provider are a
different shape entirely. A single call against any of them is the action,
not a step toward one you'll review later: the deployment already ran, the
row is already written or gone, the charge already happened. There is no
diff downstream that catches it, because nothing about it passes through
your repository at all. Allowlisting one of these gives an unattended process
standing authority to act on the world, the same authority you would think
twice about handing a person.
A deploy webhook, a production database API and a payment provider are a
different shape entirely. A single call against any of them *is* the action,
not a step toward one you'll review later: the deployment already ran, the row
is already gone, the charge already happened. Nothing about it passes through
your repository, so no diff downstream catches it. Allowlisting one gives an
unattended process standing authority to act on the world.
Ask, before adding an entry: if this call happened by mistake, is the result
something I fix in the next commit, or something I have to explain to
@@ -114,21 +102,14 @@ contained perfectly and still produce something that should not be merged,
and the sandbox has no opinion either way.
This point is about the merge, not about code quality in general. What makes
`main` (or `master`, or whatever your default branch is called) different
from any other branch is that other things read it and believe it. It is
worth being precise about why, rather than saying "the released version" in
some vaguer sense. In this repository, dejima-ci-hardener resolves the
CI-side egress
allowlist by reading `.dejima/allowed-domains` from `origin/main`, never
from a branch or a local checkout, and treats whatever it finds there as
already reviewed. That trust is load-bearing: the only way content lands on
`main` is through a pull request you merged, so reading from `main` is how
the tool avoids asking a branch what it's allowed to do. So merging onto
`main` ships it to users and feeds a trust anchor that other tooling, this
one and potentially future ones, is entitled to rely on without re-checking.
Keeping insecure or broken code off `main` is what makes that reliance
justified, and that job is yours; nothing about running the agent in a
sandbox does it for you.
your default branch different from any other is that other things read it and
believe it. Concretely: dejima-ci-hardener resolves the CI-side egress
allowlist by reading `.dejima/allowed-domains` from `origin/main`, never from a
branch or a local checkout, and treats whatever it finds there as already
reviewed. That trust is load-bearing, and it is only justified because the one
way content lands on `main` is through a pull request you merged. Keeping
insecure or broken code off it is your job; nothing about running the agent in
a sandbox does it for you.
## Give it its own identity, then own what you merge.
@@ -152,32 +133,27 @@ it accordingly.
## Understand what you ship.
The risk here is habituation, not a single lapse. dejima makes iterating
cheap enough that reading every diff starts to feel like the slow part of
the loop, and skipping it works fine, repeatedly, until the one time it
doesn't. A reviewer who reads every change catches most of what's wrong with
it. A reviewer who only reads the ones that felt risky catches none of the
ones that didn't feel risky, which is the shape a subtle bug, or a
prompt-injected change dressed up as something ordinary, takes. Nothing
about a faster loop changes what engineering judgment is for.
The risk here is habituation, not a single lapse. dejima makes iterating cheap
enough that reading every diff starts to feel like the slow part of the loop,
and skipping it works fine, repeatedly, until the one time it doesn't. A
reviewer who only reads the changes that felt risky catches none of the ones
that didn't feel risky, which is the shape a subtle bug, or a prompt-injected
change dressed up as something ordinary, takes. Nothing about a faster loop
changes what engineering judgement is for.
## This is not a blastproof box.
Read this rule as a statement about what kind of failure to plan for.
dejima's containment is Linux namespaces and a small capability set around
the session, the same kind of isolation a container gives any process, not a
virtual machine boundary; it shares the host kernel with everything else on
the machine.
That buys real things (see [docs/security.md](security.md) for the specific
list: no working `sudo`, a deny-by-default firewall, read-only mounts over
the surfaces that matter most), and every one of them assumes the container
itself stays intact. A determined attacker, or a single container-escape
vulnerability, doesn't erode that protection gradually; it removes every
layer described in that document at once, because they all sit on the same
kernel. So the assumption to hold is not that the walls will probably hold.
It is that if this container is ever compromised at the kernel level,
everything reachable from this host account should be treated as compromised
with it, which is a very different thing to plan around than an ordinary bug.
This is a statement about what kind of failure to plan for. dejima's
containment is Linux namespaces and a small capability set around the session,
not a virtual machine boundary; it shares the host kernel with everything else
on the machine. That buys real things (see [docs/security.md](security.md): no
working `sudo`, a deny-by-default firewall, read-only mounts over the surfaces
that matter most), and every one of them assumes the container stays intact. A
container-escape vulnerability doesn't erode that protection gradually, it
removes every layer at once, because they all sit on the same kernel. So the
assumption to hold is not that the walls will probably hold: it is that if this
container is ever compromised at the kernel level, everything reachable from
this host account should be treated as compromised with it.
What "everything reachable from this host account" amounts to is not fixed,
and it is the one part of this that can be narrowed from outside. Bounding
@@ -190,34 +166,24 @@ would have to be bounded.
## dejima's security says nothing about your software's.
These are different axes, not different degrees of the same thing. dejima's
threat model is about the session: what a process running inside the
sandbox can reach, exfiltrate, or execute while it runs. None of that has
anything to say about the software that session writes once it's on disk.
dejima can contain a session perfectly and that session can still write a
SQL injection, hand-roll a broken authentication check, hardcode a secret,
or pull in a compromised dependency, because those are properties of the
code itself, decided at the moment it's written, not something a sandbox
sitting around the process that wrote it can retrofit afterward. If what got
built inside dejima turns out to be insecure, containment was never the
control that was supposed to catch it.
threat model is about the session: what a process running inside the sandbox
can reach, exfiltrate, or execute while it runs. It can contain a session
perfectly and that session can still write a SQL injection, hand-roll a broken
authentication check, hardcode a secret, or pull in a compromised dependency.
Those are properties of the code, decided when it is written.
**It is not something a future agent can retrofit either.** "We will do a
security pass later" was already a weak plan before agents made it cheap to
say. Early architectural decisions
close off the fixes for whole classes of problem: where trust boundaries sit,
what is authenticated and where, what data crosses which process, what a
single compromised component reaches. A later pass can find a missing bounds
check. It cannot move a boundary you built a system on top of. And insecure
implementation decisions sit in software for years without announcing
themselves, which is exactly why they are not found by a pass that goes
looking once.
**It is not something a future agent can retrofit either.** Early
architectural decisions close off the fixes for whole classes of problem: where
trust boundaries sit, what is authenticated and where, what data crosses which
process, what a single compromised component reaches. A later pass can find a
missing bounds check. It cannot move a boundary you built a system on top of.
And insecure implementation decisions sit in software for years without
announcing themselves, which is exactly why a pass that goes looking once does
not find them.
So security has to be considered at every point in the work, not scheduled
as a phase, and dejima will not help you do that. It makes writing a lot of
code fast, which makes writing a lot of insecure code fast if nobody is
holding that concern. That responsibility is yours at every step, and it is
the one thing here no tooling in this repository is aiming at.
The same practices you'd apply to code from any other source, review,
linting, dependency scanning, tests aimed at the failure modes you care
about, still have to happen here. dejima does not stand in for any of them.
So security has to be considered at every point in the work rather than
scheduled as a phase, and dejima will not help you do that. It makes writing a
lot of code fast, which makes writing a lot of insecure code fast if nobody is
holding that concern. Review, linting, dependency scanning and tests aimed at
the failure modes you care about all still have to happen; dejima stands in for
none of them.
+227 -176
View File
@@ -44,6 +44,14 @@ than the thing inferred from silence.
session that loops until someone notices is not prevented, and this is a
deliberate omission rather than an oversight - see
[Why there are no timeouts](#why-there-are-no-timeouts).
- **A suspended job. Ctrl-Z stops the observer, not the session.** It
suspends `bin/dejima` on the host; the container keeps running, with its
mounts, its firewall grants and its forge token, and keeps making requests
while the terminal looks stopped. That is deliberate - SIGSTOP mid-tool-call
leaves a session wherever it was, and the feature exists to get the terminal
back rather than to pause the work - but a stopped job reads as a paused
agent, and the difference matters most to whoever suspends one because they
are uneasy about what it is doing.
- **The first layer's controls against an agent that has stopped
cooperating.** The briefing and `.dejima.md` are advice. They shape what a
cooperating agent does and stop nothing that has decided otherwise. Only the
@@ -250,13 +258,27 @@ checkout itself and needs no symlink to edit, so a session with write access
to this repo's own checkout can change what the next launch composes and
hands to whichever session starts against it.
The provider directories are in the exit-time audit's TCB list for that
reason, alongside `bin/`, `hooks/`, `docker/` and `test/`. They hold no code
and nothing executes them, but what they hold decides what the next launch
permits: a forge's `fixed_hosts` reaches `DEJIMA_FORGE_HOSTS`, which
`entrypoint.sh` joins into the domains `init-firewall.sh` allowlists, so a
line added to a manifest widens the egress boundary on the next run. An
ordinary edit is a diff you are about to read and is not reported; one git
**Which requires dejima's own checkout to be the workspace, and almost never
is.** Nothing mounts `forges/`, `workflows/`, `harnesses/`, `tools/` or the
rest of the checkout into a container - the resolved values go in, the files
do not - so for every launch pointed at some other project, a session cannot
read these paths, let alone write them. The rest of this section is about the
one configuration where it can: dejima sandboxing the repository it is itself
installed from, which is how dejima is developed. `bin/dejima` says so at
launch when it notices.
In that configuration the provider directories are in the exit-time audit's
TCB list, alongside `bin/`, `hooks/`, `docker/`, `test/` and `tools/`. They
hold no code and nothing executes them, but what they hold decides what the
next launch permits: a forge's `fixed_hosts` reaches `DEJIMA_FORGE_HOSTS`,
which `entrypoint.sh` joins into the domains `init-firewall.sh` allowlists, so
a line added to a manifest widens the egress boundary on the next run.
`tools/*/tool.conf` is the same class, and is read by the host rather than the
container: it names a tarball URL, the sha256 checked against it and the
layout it is unpacked with, all consumed by the image build - so a session
that edits one chooses what the next launch downloads and installs as root.
An ordinary edit is a diff you are about to read and is not reported; one git
cannot see - hidden by `.gitignore`, `.git/info/exclude`, `assume-unchanged`
or `skip-worktree` - is a CRITICAL finding. It is still a change a session
can make; it is one the audit reports. See
@@ -297,178 +319,180 @@ can make; it is one the audit reports. See
either, but only after the session ends - it doesn't stop them.
## The exit-time audit
- `bin/dejima-audit` snapshots host-persistence surfaces before each
run and diffs them after the container exits: `.git/config` (and worktree
`config.worktree`) keys that run a command on the next `git` -
`core.hooksPath`, `core.fsmonitor`, `core.sshCommand`,
`credential.helper`, `core.pager`, `pager.*`, `core.editor`,
`sequence.editor`, `gpg.program`, `diff.external`, `merge.*.driver`,
`diff.*.command`, `filter.*.smudge`/`clean`/`process`, `diff.*.textconv`,
`include.path`/`includeif.*.path` (config indirection - these can point at
a planted file that itself sets an exec key, so the exec key never
appears in `.git/config` directly), `!`-shell aliases - plus content
changes under the workspace's `.claude/`, `.mcp.json`, `.envrc`,
`.vscode/`, and `.git/hooks/` (symlinks included - a repointed symlink is
hashed by its target, not just its content). When the mounted workspace is
this repo itself, it also hashes the sandbox's own trusted computing base -
`bin/`, `hooks/`, `docker/`, `install.sh`, `test/`.
`bin/dejima-audit` snapshots host-persistence surfaces before each run and
diffs them after the container exits. It is **advisory**: 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. It is
silent when nothing changed and never affects the session's exit code.
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.
**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.
**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.
### What it watches
**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
other content re-fires. Ignoring records the surface: `spam <id>`, so this
path or key is fine whatever it later contains. Approve a hook you have
read; ignore a key some tool rewrites on every run and which you have
decided you will never care about. The review offers both, and says which
is which rather than leaving you to infer it:
`.git/config` (and worktree `config.worktree`) keys that run a command on the
next `git` - `core.hooksPath`, `core.fsmonitor`, `core.sshCommand`,
`credential.helper`, `core.pager`, `pager.*`, `core.editor`, `sequence.editor`,
`gpg.program`, `diff.external`, `merge.*.driver`, `diff.*.command`,
`filter.*.smudge`/`clean`/`process`, `diff.*.textconv`,
`include.path`/`includeif.*.path` (config indirection - these can point at a
planted file that itself sets an exec key, so the exec key never appears in
`.git/config` directly), `!`-shell aliases - plus content changes under the
workspace's `.claude/`, `.mcp.json`, `.envrc`, `.vscode/`, and `.git/hooks/`
(symlinks included - a repointed symlink is hashed by its target, not just its
content). When the mounted workspace is this repo itself, it also hashes the
sandbox's own trusted computing base: `bin/`, `hooks/`, `docker/`,
`install.sh`, `test/`.
```
[a] approve this exact content [i] ignore this key from now on [s] skip (ask again next time) [q] quit
```
Coverage is a fixed list rather than a general integrity check, so a config
surface it has not been taught about goes unreported. It does not cover
`[core] share_dir` (see
[Shared scratch space](configuration.md#shared-scratch-space)).
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 would leave the report
unresolvable: `ack` opens CRITICAL reports first, so every other report
would hide behind one no decision could clear.
**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 says so, and gives `git -C <path> config`. That is 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.
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.
### Approving and ignoring are different decisions
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.
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
other content re-fires. Ignoring records the surface (`spam <id>`), so this path
or key is fine whatever it later contains. Approve a hook you have read; ignore
a key some tool rewrites on every run and which you have decided you will never
care about. The review offers both, and says which is which:
- `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
pre-session content against the current content; for one created during
the session it prints the whole current content. To make that possible,
`snapshot` stages a copy of each monitored surface's bytes for the
duration of the session, and `compare` turns those into **bounded
diffs** stored in a `<report>.d/` directory beside the report. What
persists after the session is only the diff of a *modified* surface -
never a whole file. A created surface stores a marker (its content is
live on disk); a removed one stores nothing at all, because a deletion
diff *is* the whole former file.
```
[a] approve this exact content [i] ignore this key from now on [s] skip (ask again next time) [q] quit
```
What it prints is chosen by what changed, which is not always what you
need in order to judge it - three changed lines in a hook script are only
judgeable by reading the script. `[o]` at the prompt opens the file in
`$VISUAL`/`$EDITOR`, read-only where the editor understands how, and
returns to the same decision. It opens the file **as it is now**, not the
text the report captured, for the same reason an approval pins the content
that is on disk at the moment it is recorded: approving something nobody
read would be the one outcome worse than not asking.
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.
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 report walked to the end stops being counted as waiting, but stays on disk as
the record of what that session did. `[audit] keep` decides how many are
retained; rotation evicts one, `ack` never does.
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.
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 would leave the report unresolvable, since `ack` opens
CRITICAL reports first.
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
diff is never mistaken for an absent change - and still shows the live
current content on your terminal, because withholding is about the
persistent store, not about what you are shown while deciding. Surfaces
that hold credentials by convention are never staged in the first
place, so their bytes are never even copied transiently: `.envrc`,
`.mcp.json` and `.claude/settings.local.json` by exact path, and
`.env`/`.env.*` by *basename*, at any depth - so a nested `.claude/.env`
is caught too, not just one at the workspace root. A surface that
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 - 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, 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.
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.
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.
### What `ack` shows you
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.
For a surface that changed during the session it prints a unified diff of the
pre-session content against the current content; for one created during the
session it prints the whole current content. To make that possible, `snapshot`
stages a copy of each monitored surface's bytes for the duration of the session,
and `compare` turns those into **bounded diffs** in a `<report>.d/` directory
beside the report. What persists afterwards is only the diff of a *modified*
surface, never a whole file. A created surface stores a marker (its content is
live on disk); a removed one stores nothing, because a deletion diff *is* the
whole former file.
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.
What it prints is chosen by what changed, which is not always what you need in
order to judge it: three changed lines in a hook script are only judgeable by
reading the script. `[o]` at the prompt opens the file in `$VISUAL`/`$EDITOR`,
read-only where the editor understands how, and returns to the same decision. It
opens the file **as it is now**, for the same reason an approval pins the content
on disk at the moment it is recorded: approving something nobody read would be
the one outcome worse than not asking.
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.
### When content is withheld from the store
In every case below `ack` prints the reason in place of the diff - so an absent
diff is never mistaken for an absent change - and still shows the live current
content on your terminal, because withholding is about the persistent store, not
about what you are shown while deciding.
Surfaces that hold credentials by convention are never staged in the first
place, so their bytes are never even copied transiently: `.envrc`, `.mcp.json`
and `.claude/settings.local.json` by exact path, and `.env`/`.env.*` by
*basename*, at any depth - so a nested `.claude/.env` is caught too.
A surface that 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 cap, and 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 (bounded
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
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 has real
false negatives; the denylist is the reliable half.
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.
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, since 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, so 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 reach your terminal as untrusted text
They name a path or config key the session may have chosen. 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.
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.
## One project's session cannot reach another's
- `~/.claude/projects` (session transcripts and per-project memory) is
@@ -531,6 +555,23 @@ can make; it is one the audit reports. See
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.
- `[core] share_dir` is **not** mounted whole. A launch mounts
`<share_dir>/projects/<slug>` at `~/shared`, so a session sees its own
project's handoff files and no other project's - the same slicing as
`projects/` above, applied to the directory that was the remaining way
around it. A handoff file is session-authored by design, and one project's
session writing a plausible-looking note that a session in another project
then picks up is #104's channel by a different route.
The per-project split bounds *which* sessions, not *how long*: a project's
share persists, so everything in it is readable by every later session
against that project. Nothing prunes it, which is why
[docs/configuration.md](configuration.md#shared-scratch-space) says to clear
it out and `dejima-share list` prints what each project is holding.
`<share_dir>/projects` is in the guarded list, so an operator cannot hand
the whole of it back through `[mounts] readonly` or `[mounts] read_write`.
The session's own share arrives at `~/shared` without going through either.
## The model API credential never enters the container
**This assumes the host has one.** dejima authenticates nothing: the proxy
@@ -547,7 +588,7 @@ session, where the credential deliberately never goes.
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
(`bin/dejima-credential-proxy`) on a per-session unix socket and mounts a
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
@@ -555,7 +596,7 @@ session, where the credential deliberately never goes.
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
(`credential-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
@@ -564,7 +605,13 @@ session, where the credential deliberately never goes.
`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
`Authorization`, and `anthropic-beta` if the OAuth value isn't there.
Neither of those is a name the proxy holds: which header carries the
credential, what shape it takes, what must not travel beside it and which
comma-list value has to be present are all arguments, and `bin/dejima`
supplies the Anthropic ones (#171). The credential kind is an argument too -
a refreshing OAuth file, or a static key that is read and sent - so a
provider that cannot be refreshed is not a branch here. 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
@@ -773,10 +820,11 @@ session, where the credential deliberately never goes.
reach the daemon - that is daemon access rather than any process at all,
and it is not something the launcher can change.
- The container runs with `no-new-privileges` and an explicit capability
floor - `cap_drop: ALL` plus the seven it actually uses. Six are the
entrypoint's own; the seventh is `KILL`, which nothing in the entrypoint
needs and `dejima-allow --container` does, because adding a domain to a
running session signals dnsmasq and dnsmasq runs as its own user. The first means a setuid binary or a file capability cannot raise
floor - `cap_drop: ALL` plus the ones it actually uses: what the
entrypoint's root phase needs, and `KILL`. `KILL` is there for
`dejima-allow --container`, not for the entrypoint - adding a domain to a
running session signals dnsmasq, and dnsmasq runs as its own user. The list
itself is in `docker/docker-compose.yml`, which is where it stays correct. The first means a setuid binary or a file capability cannot raise
privileges on exec, which is the shape any escalation from the session user
would have to take; the second means the capabilities nothing here uses were
never in the bounding set to be regained. Both are asserted against a running
@@ -981,12 +1029,15 @@ is precisely when nothing is running to probe in the first place. Pass
`dejima-allow --global --container <id> crates.io` records the approval and
patches that session immediately, in that order, so the approval is never
lost to a live-session failure. If more than one dejima container is
running, the bare form above refuses and asks you to disambiguate with
`--container <name>` - `docker ps` finds it, or use the
container's own ID, which `sandbox-context.sh` already hands the agent in
its startup context (and which is always available inside the sandbox as
`cat /etc/hostname`), so there's no need for it to guess one and risk
"successfully" updating someone else's session instead.
running, the bare form above refuses rather than guessing. On a terminal it
offers the running sessions to pick from, by project; anywhere else - a
script, a pipe, a session asking its operator to run this - it prints the same
rows and stops, because a prompt nobody can answer is worse than a refusal.
Either way the answer is in front of you instead of in a separate `docker ps`.
A session has its own ID regardless: `sandbox-context.sh` hands it to the
agent in its startup context, and `cat /etc/hostname` has it inside the
sandbox, so there is no need for it to guess one and risk "successfully"
updating someone else's session instead.
A domain allowed this way stays allowed for the rest of the session even
if its CDN later rotates edge IPs: `dns-proxy.sh` tops up the firewall's
+85 -11
View File
@@ -2,16 +2,16 @@
[← dejima README](../README.md)
What dejima defends, what it does not, and where its boundary passes out of
its hands. [security.md](security.md) is the control-by-control account; this
is the shape of the thing those controls are arranged against.
**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).
What dejima defends, what it does not, and where its boundary passes out of
its hands. [security.md](security.md) is the control-by-control account; this
is the shape of the thing those controls are arranged against.
## What this tool assumes
**The agent is hostile, or will be.** Not badly behaved: captured, mid-session,
@@ -79,8 +79,29 @@ The ordering is the point. Nothing above the last item requires an exploit.
Item 5 is not an argument for making the handoff harder; a session that cannot
ask is a session that works around you instead. It is an argument for being
able to answer, afterwards, what that actually did to the machine - which is
the layer below this repository's, and the reason the sister project is
looking at detection rather than at more enforcement.
the layer below this repository's, and the reason `dejima-guard` exists and
looks at detection rather than at more enforcement.
**The handoff directories are item 5 with the person taken out.** `[core]
share_dir` and everything in `[mounts] read_write` exist so that session
output survives the container, so every byte and every filename in them is
session-authored by design. On the box that is bounded by how rarely anyone
looks. Mount one on a workstation - over sshfs, or by any sync that runs on
its own - and the directory is simply present, at which point a desktop opens
what appears in it without being asked: an indexer parsing documents and
archives to extract text, a thumbnailer decoding images, a previewer, an
editor honouring project configuration it finds beside the files. Each is a
parser running as the operator, outside the container, reached with no
decision per file. Item 5's last sentence is that a person decides; here
nobody does.
It stays item 5 rather than becoming a row of its own, because the shape is
the same: the session initiates none of it. It cannot see the mount, cannot
cause one, and the ssh credential involved already gave that operator a shell
on the box. What changes is where session-authored bytes get opened, and the
machine they get opened on is not a row in the table below.
[remote-access.md](remote-access.md#what-mounting-it-costs-you) has the
specifics and what shrinks them.
## Blast radius by layer
@@ -122,8 +143,31 @@ can see.
## Where dejima's boundary ends
dejima runs inside the machine it is protecting the rest of, which is the
whole reason there are two other repositories. It cannot bound its own host's
network position, and it cannot bound a runner it never touches.
whole reason there are three other repositories. It cannot bound its own host's
network position, it cannot bound a runner it never touches, and it cannot tell
anyone that something changed after the session ended.
### The stack, and what each piece buys
**Only the first row is required.** Everything under it is what the layer
below the container costs, and every one of them can be substituted or left
out - the last two are categories rather than products.
| Piece | Runs on | What it buys | Without it |
| --- | --- | --- | --- |
| **dejima** | the devbox | The container boundary: what a session reaches on disk, and deny-by-default egress. | An agent with your home directory and the open internet. |
| **dejima-devbox-hardener** | the same devbox, enforced from outside the guest | Bounds where an escape *spreads*: LAN reach cut to what is declared, and root in the VM cannot flush the rule set. | An escape reaches the LAN, and the row below the container is unbounded. |
| **dejima-guard** | a different machine | Notices the devbox changed: file hashes, enabled units and listening ports, diffed against a baseline held off the box. Detection, not enforcement. | Anything a session leaves behind survives unremarked, because nobody is looking at the devbox between sessions. |
| **self-hosted CI + dejima-ci-hardener** | a runner VM | A runner that executes a session's pull request without that being LAN access, and a rollback that expires what an escape gained. | CI runs attacker-controlled code with the network position of whatever host it sits on. |
| **or third-party hosted CI** | someone else's machine | The same isolation, bought rather than run - and on their network rather than yours. | - |
| **ntfy, or any push service** | anywhere both can reach | The channel dejima's "session needs input" and the guard's alerts both arrive on. | A session that stopped to ask, and an alert that fired, both wait for someone to think to look. |
| **Uptime Kuma, or any uptime monitor** | off the devbox | The dead man's switch: it is what notices the *guard* has stopped reporting. | Silence is indistinguishable from health, which is the failure mode every watcher has. |
The last row is the one most easily skipped and the one that decides whether
the rest is real. A watcher that has died looks exactly like a watcher with
nothing to report.
### Each project's own boundary
**[dejima-devbox-hardener](https://gitbox.branderson.io/dejima/dejima-devbox-hardener)**
covers the two VM rows: what an escape spreads to, rather than whether it can
@@ -138,14 +182,40 @@ Nothing there tries to stop the code running. Its
[threat model](https://gitbox.branderson.io/dejima/dejima-ci-hardener/src/branch/main/docs/threat-model.md#blast-radius-by-layer)
has the two tiers and where each one's boundary sits.
Neither is required to run dejima, and the container row holds without them.
What they change is what the row below it costs.
**[dejima-guard](https://gitbox.branderson.io/dejima/dejima-guard)** watches a
devbox from a machine that is not the devbox and alerts on what changed. It
enforces nothing: everything it reports has already happened. It collects over
SSH, diffs against a baseline held on the watching machine, and alerts from
there. That placement is the point, since a baseline stored on the box being
watched is a baseline the thing you are watching for can edit, and it
complements devbox-hardener rather than overlapping it - one bounds where a
compromise spreads, the other notices that one happened.
**It is the only piece here that adds a machine holding credentials rather than
taking capability away.** Every other row removes something: what a session
reaches, where an escape spreads, what a runner can talk to. This one
introduces a host holding an SSH key to the devbox and a token for the alert
channel, and its exposure is bounded by the hardening of a machine this stack
does not describe or require anything of. That is the thing to weigh before
running it.
**Its detection ceiling is root on the watched box.** The collector runs on the
devbox, so root there can return whatever it likes, including the baseline
unchanged. What the guard catches is accidents, non-root compromise, and
payloads that did not think to look, which is most of them. The heartbeat
proves the guard ran, not that it saw the truth.
[Its own `SECURITY.md`](https://gitbox.branderson.io/dejima/dejima-guard/src/branch/main/SECURITY.md)
has the rest, including the ways it is deliberately narrower than dejima.
None of the three is required to run dejima, and the container row holds
without them. What they change is what the row below it costs, and whether
anyone finds out.
**A survey of comparable projects found none with rows below the container at
all** - their boundary is between the agent and the host, and what is under
the host is outside their threat models. The same survey found nothing
treating a forge or a CI runner as part of an agent's blast radius, which is
what the second sister project exists for: a session that can open a pull
what dejima-ci-hardener exists for: a session that can open a pull
request can execute code on a machine dejima never touches.
Recorded as an observation from one day of secondary sources rather than as a
@@ -212,6 +282,10 @@ this section. Nothing in dejima can bound its own host.
- **That a finding from the exit-time audit was caused by the session.** It
reports that a surface changed while the session ran. A concurrent session
or the operator's own editing lands in the same diff.
- **That what a session leaves in a handoff directory is safe for another
machine to open.** `share_dir` and `[mounts] read_write` carry
session-authored bytes and session-chosen filenames out of the container on
purpose. Sanitising happens where dejima does the printing, and stops there.
- **That the allowlist bounds exfiltration once a session has push access.**
A commit is an outbound channel to a host that is on the list by definition.
See [ci.md](ci.md).
+5
View File
@@ -38,6 +38,11 @@ merge_block = gh pr merge
# repository-scoped fine-grained PAT is the expected shape.
repo_flags = --repo -R
repo_env = GH_REPO
# `gh api repos/<owner>/<name>/...` names a repository with no --repo anywhere
# in it, and is the ordinary way to reach an endpoint the subcommands do not
# cover. Scoped to `api` so the guard is not comparing every positional
# argument that contains two slashes (#243).
repo_paths = api:repos
disclose = pr:gh pr create, pr:gh pr edit, issue:gh issue create, issue:gh issue edit, comment:gh issue comment
# See the gitea manifest's note: this is the key an identity supplies and a
# project may not. The read-only [github] token_file is core's rather than
+4 -4
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env bash
# Notification/Stop hook. bin/dejima registers it, per session, into the
# throwaway settings.json copy it generates for a session that has remote
# access enabled, and nowhere else. Every sandbox gets such a copy, derived
# from the operator's real settings.json; registering it in that real file
# Notification hook. The selected harness's plugin registers it, per session,
# into the throwaway settings.json copy bin/dejima generates for a session
# that has remote access enabled, and nowhere else. Every sandbox gets such a
# copy, derived from the operator's real settings.json; registering it there
# instead would put this hook into all of them - and into the host's own
# Claude Code, which reads that file directly - where there is no marker
# directory to write into. The copy is mounted read-only (see
+7 -3
View File
@@ -2,9 +2,9 @@
# Idempotent installer for dejima. Registers the selected harness's guard
# hooks wherever that harness reads them from, and puts
# bin/dejima, bin/dejima-allow, bin/dejima-dep, bin/dejima-project,
# bin/dejima-audit and the opt-in remote-access scripts on PATH. Safe to
# re-run - every step is a no-op if already done, and every change is
# printed as it happens.
# bin/dejima-audit, bin/dejima-share and the opt-in remote-access scripts on
# PATH. Safe to re-run - every step is a no-op if already done, and every
# change is printed as it happens.
#
# It never enables anything: the remote-access scripts do nothing without
# [remote-access] enabled = on in ~/.config/dejima/config, and the user units
@@ -76,6 +76,10 @@ link_bin dejima-project
# operator to run `dejima-audit ack`, which is only true once it is on PATH.
link_bin dejima-audit
link_bin dejima-epitaph
# ~/shared is one directory per project now, so "where do I put a file for the
# next session" has an answer only this can give - and the launch notice about
# files left over from the old layout tells the operator to run it.
link_bin dejima-share
link_bin dejima-update-check
# Remote access (opt-in, see docs/remote-access.md). All three are inert
+157 -21
View File
@@ -502,7 +502,8 @@ _identity_fixture() {
"token_file = $ID_DIR/default-token" \
'[identity.work]' "ssh_key = $ID_DIR/work-key" 'user_name = A Name' \
'user_email = name@work.example' "token_file = $ID_DIR/work-token" \
'[identity.oss]' "ssh_key = $ID_DIR/oss-key"
'[identity.oss]' "ssh_key = $ID_DIR/oss-key" \
'[identity.keyless]' "token_file = $ID_DIR/work-token"
}
@test "an identity supplies the ssh key and the forge credential" {
@@ -515,15 +516,59 @@ _identity_fixture() {
rm -rf "$ID_DIR"
}
@test "a key the identity does not name falls through to the file" {
# An identity overrides what it names. Treating it as a replacement for
# the whole section would make a half-filled one silently unset the rest.
@test "an identity that leaves a credential to the section beneath it is refused" {
# This used to fall through: oss names no token_file, so [forge.gitea]
# answered for it, and a session launched as oss talked to the forge as
# whichever account owns that token. An identity is how one session's
# account is kept separate from another's, so a credential crossing that
# line is the feature not working rather than a convenience it offers.
_identity_fixture
run "$REPO_ROOT/bin/dejima" --identity oss --print-config
[ "$status" -ne 0 ] || { echo "the borrowed token was accepted: $output"; return 1; }
# Which credential, by name - "something is wrong with your identity" is
# not something an operator can act on.
[[ "$output" == *"[forge.gitea] token_file"* ]] \
|| { echo "the refusal does not name the credential: $output"; return 1; }
# And nothing resolved is printed, for the same reason the missing-section
# refusal prints nothing: it would show this machine's token under oss.
[[ "$output" != *"forge.gitea.token_file=$ID_DIR/default-token"* ]] \
|| { echo "printed the borrowed credential anyway: $output"; return 1; }
rm -rf "$ID_DIR"
}
@test "an identity that leaves the ssh key to [core] is refused" {
# The other half, and the one the ambiguity was about: [core] ssh_key is
# the key a session gets when no identity is named, so an identity that
# names none is asking to push as itself with the default account's key.
_identity_fixture
run "$REPO_ROOT/bin/dejima" --identity keyless --print-config
[ "$status" -ne 0 ] || { echo "the borrowed key was accepted: $output"; return 1; }
[[ "$output" == *"[core] ssh_key"* ]] \
|| { echo "the refusal does not name the key: $output"; return 1; }
rm -rf "$ID_DIR"
}
@test "a credential belonging to a forge this launch does not use is not borrowed" {
# Only the selected forge's keys. Refusing over a value nothing on this
# launch reads would make an unrelated section able to block a launch.
_identity_fixture
printf '[forge.github]\npush_token_file = %s\n' "$ID_DIR/default-token" \
>> "$CONFIG_DIR/config"
run "$REPO_ROOT/bin/dejima" --identity work --print-config
[ "$status" -eq 0 ] \
|| { echo "an unused forge's credential blocked the launch: $output"; return 1; }
rm -rf "$ID_DIR"
}
@test "with no identity named, the config's own credentials are the ones used" {
# The unnamed default, which is what [core] ssh_key means. Nothing about
# the refusals above touches a host that never names an identity.
_identity_fixture
run "$REPO_ROOT/bin/dejima" --print-config
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qx "core.ssh_key=$ID_DIR/oss-key" <<< "$output" || { echo "$output"; return 1; }
grep -qx "core.ssh_key=$ID_DIR/default-key" <<< "$output" || { echo "$output"; return 1; }
grep -qx "forge.gitea.token_file=$ID_DIR/default-token" <<< "$output" \
|| { echo "oss has no token_file and did not fall through: $output"; return 1; }
|| { echo "$output"; return 1; }
rm -rf "$ID_DIR"
}
@@ -1377,13 +1422,11 @@ EOF
docker_log="$(mktemp)"
# A launch runs dejima-update-check, which would ls-remote this checkout's
# own origin. Off here rather than in each fixture's config, because the
# launch is what triggers it. XDG_STATE_HOME is stated for the mirror
# reason: left alone the cache lands under the test's HOME, so every test
# misses and calls out again; exported by whoever ran the suite, it lands
# in their real state directory.
# launch is what triggers it. Its cache needs nothing further: it lands
# under $HOME, which every launch fixture already points somewhere of its
# own.
printf '[core]\nupdate_check = off\n' >> "$CONFIG_DIR/config"
HOME="$RW_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK="${RW_AGENT_SOCK-}" \
XDG_STATE_HOME="$RW_HOME/.local/state" \
SANDBOX_TEST_DOCKER_LOG="$docker_log" \
run "$REPO_ROOT/bin/dejima" "$1"
}
@@ -1506,7 +1549,6 @@ EOF
# Same as _rw_launch: the launch would otherwise reach the network.
printf '[core]\nupdate_check = off\n' >> "$CONFIG_DIR/config"
HOME="$RW_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
XDG_STATE_HOME="$RW_HOME/.local/state" \
SANDBOX_TEST_DOCKER_LOG="$docker_log" \
run "$REPO_ROOT/bin/dejima" "$@" "$ws"
}
@@ -2476,12 +2518,12 @@ STUB
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"harness-claude-code"* ]] \
|| { echo "no harness layer was built:"; echo "$output"; return 1; }
grep -qxF "dejima:$(id -un)-harness-claude-code" "$docker_log" \
grep -qxF "localhost/dejima:$(id -un)-harness-claude-code" "$docker_log" \
|| { echo "the harness layer was not tagged as expected:"; cat "$docker_log"; return 1; }
# The LAST compose invocation is the run; the first builds the base.
local ran
ran="$(tail -1 "$image_log")"
[ "$ran" = "dejima:$(id -un)-harness-claude-code" ] \
[ "$ran" = "localhost/dejima:$(id -un)-harness-claude-code" ] \
|| { echo "the session ran on '$ran', not the harness layer"; cat "$image_log"; return 1; }
rm -rf "$workspace" "$RW_HOME" "$docker_log" "$image_log"
}
@@ -2623,9 +2665,9 @@ _upd_fixture() {
UPD_DIR="$(mktemp -d)"
UPD_ORIGIN="$UPD_DIR/origin"
UPD_CHECKOUT="$UPD_DIR/checkout"
# Stated, not inherited: the cache lands under XDG_STATE_HOME, and a test
# that left it unset would write into whoever ran it.
export XDG_STATE_HOME="$UPD_DIR/state"
# Stated, not inherited: the cache lands under $HOME, and a test that left
# that alone would write into whoever ran it.
export HOME="$UPD_DIR/home"
# -b trunk so the fixture does not depend on the host's
# init.defaultBranch, and so no guard has an opinion about the name.
git init -q -b trunk "$UPD_ORIGIN"
@@ -2638,6 +2680,9 @@ _upd_fixture() {
_upd_check() { "$UPD_CHECKOUT/bin/dejima-update-check" 2>&1; }
# Where _cache_file puts it, named once rather than at each assertion.
_upd_cache() { printf '%s/.local/share/dejima/update-check' "$HOME"; }
@test "a checkout level with its remote says nothing" {
_upd_fixture
run _upd_check
@@ -2670,7 +2715,7 @@ _upd_check() { "$UPD_CHECKOUT/bin/dejima-update-check" 2>&1; }
|| { echo "$output"; return 1; }
git -C "$UPD_CHECKOUT" fetch -q origin
rm -rf "$XDG_STATE_HOME"
rm -f "$(_upd_cache)"
run _upd_check
[[ "$output" == *"has 1 commit this checkout does not"* ]] \
|| { echo "no count once the commit is present: $output"; return 1; }
@@ -2701,7 +2746,7 @@ _upd_check() { "$UPD_CHECKOUT/bin/dejima-update-check" 2>&1; }
# Silence alone would also be produced by asking and saying nothing. The
# cache is written on every lookup, including a failed one, so its absence
# is what shows no lookup happened.
[ ! -e "$XDG_STATE_HOME/dejima/update-check" ] \
[ ! -e "$(_upd_cache)" ] \
|| { echo "it asked anyway"; return 1; }
}
@@ -2769,13 +2814,13 @@ _upd_check() { "$UPD_CHECKOUT/bin/dejima-update-check" 2>&1; }
# Written into the file directly: `git remote set-url` rejects a value
# starting with a dash itself, which would leave that case untested.
sed -i "s#\turl = .*#\turl = $url#" "$UPD_CHECKOUT/.git/config"
rm -rf "$XDG_STATE_HOME"
rm -f "$(_upd_cache)"
run _upd_check
[ "$status" -eq 0 ] || { echo "$url exited $status"; return 1; }
[ ! -e "$marker" ] || { echo "$url ran"; return 1; }
# Nothing recorded either, so a refused URL does not also poison the
# cache for the hour after someone fixes it.
[ ! -e "$XDG_STATE_HOME/dejima/update-check" ] \
[ ! -e "$(_upd_cache)" ] \
|| { echo "$url was attempted"; return 1; }
done
}
@@ -2896,3 +2941,94 @@ EOF
[ "$output" -eq 0 ] || { echo "the launch called out:"; grep ls-remote "$git_log"; return 1; }
rm -rf "$workspace" "$GIT_WRAP"
}
@test "[identity.default] is what a launch acts under when none is named" {
# The unnamed default gets a name, which is what lets it carry a git name
# and a forge credential beside the key rather than those coming from
# three different places.
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/default-key"; : > "$ID_DIR/tok"
write_config '[core]' 'forge = gitea' \
'[forge.gitea]' 'ssh_host = git.example' 'api = https://git.example/api/v1' \
'[identity.default]' "ssh_key = $ID_DIR/default-key" \
"token_file = $ID_DIR/tok" 'user_name = dejima agent'
run "$REPO_ROOT/bin/dejima" --print-config
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qx "core.ssh_key=$ID_DIR/default-key" <<< "$output" \
|| { echo "the default identity did not supply the key: $output"; return 1; }
rm -rf "$ID_DIR"
}
@test "[core] ssh_key alongside [identity.default] is refused, not ranked" {
# Two spellings of one thing. Which key a session pushed with is not
# something to work out from precedence rules.
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/old-key"; : > "$ID_DIR/new-key"
write_config '[core]' 'forge = none' "ssh_key = $ID_DIR/old-key" \
'[identity.default]' "ssh_key = $ID_DIR/new-key"
run "$REPO_ROOT/bin/dejima" --print-config
[ "$status" -ne 0 ] || { echo "both spellings were accepted: $output"; return 1; }
[[ "$output" == *"[core] ssh_key"* ]] && [[ "$output" == *"[identity.default]"* ]] \
|| { echo "the refusal does not name both: $output"; return 1; }
rm -rf "$ID_DIR"
}
@test "[core] ssh_key on its own still works, and says what replaced it" {
# Deprecated, not removed: it is how every config written before
# identities existed says this.
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/old-key"
write_config '[core]' 'forge = none' "ssh_key = $ID_DIR/old-key"
run "$REPO_ROOT/bin/dejima" --print-config
[ "$status" -eq 0 ] || { echo "a pre-identity config stopped working: $output"; return 1; }
grep -qx "core.ssh_key=$ID_DIR/old-key" <<< "$output" || { echo "$output"; return 1; }
[[ "$output" == *"[identity.default]"* ]] \
|| { echo "nothing said what replaced it: $output"; return 1; }
rm -rf "$ID_DIR"
}
@test "a named identity still beats [identity.default]" {
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/default-key"; : > "$ID_DIR/bot-key"
write_config '[core]' 'forge = none' \
'[identity.default]' "ssh_key = $ID_DIR/default-key" \
'[identity.bot]' "ssh_key = $ID_DIR/bot-key"
run "$REPO_ROOT/bin/dejima" --identity bot --print-config
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qx "core.ssh_key=$ID_DIR/bot-key" <<< "$output" || { echo "$output"; return 1; }
rm -rf "$ID_DIR"
}
@test "the [core] ssh_key deprecation is warned even when a launch does not use it" {
# A config with [core] ssh_key and a named identity is one migration away
# from the refusal - adding [identity.default] would stop it starting.
# Finding that out on the launch that breaks is worse than being told now,
# so the warning is about the config rather than about this launch.
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/old-key"; : > "$ID_DIR/bot-key"
write_config '[core]' 'forge = none' "ssh_key = $ID_DIR/old-key" \
'identity = bot' '[identity.bot]' "ssh_key = $ID_DIR/bot-key"
run "$REPO_ROOT/bin/dejima" --print-config
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"[core] ssh_key is deprecated"* ]] \
|| { echo "no migration warning: $output"; return 1; }
# And says it is not the key in play, so the reader is not left thinking
# this launch pushed with it.
[[ "$output" == *"runs as bot"* ]] \
|| { echo "the warning does not say which account is in force: $output"; return 1; }
# The path, because "move it" is not actionable without knowing what.
[[ "$output" == *"$ID_DIR/old-key"* ]] \
|| { echo "the warning does not name the key: $output"; return 1; }
rm -rf "$ID_DIR"
}
@test "a migrated config is warned about nothing" {
# The other half of a deprecation warning: it has to stop. One that fires
# after the migration is one an operator learns to ignore, which is how
# the next warning gets missed too.
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/key"
write_config '[core]' 'forge = none' \
'[identity.default]' "ssh_key = $ID_DIR/key" 'user_name = dejima agent'
run "$REPO_ROOT/bin/dejima" --print-config
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" != *"deprecated"* ]] \
|| { echo "still warning after the migration: $output"; return 1; }
grep -qx "core.ssh_key=$ID_DIR/key" <<< "$output" \
|| { echo "the migrated key is not the one in force: $output"; return 1; }
rm -rf "$ID_DIR"
}
+26 -7
View File
@@ -106,6 +106,23 @@ await_shipped() {
return 1
}
# Blocks until the shipper has finished a poll and is asleep between polls.
# The two SIGTERM tests below need exactly that state - the trap installed, one
# scan done - and both used to state it by sleeping for a second. That is a
# statement about the machine rather than about the shipper: under load the
# startup that precedes the trap takes longer than the sleep, the TERM lands
# before there is a trap to catch it, and the process dies flushing nothing.
# The nap is a child `sleep` of the shipper, which makes the state observable.
await_napping() {
local i
for i in $(seq 1 200); do
pgrep -P "$SHIP_PID" -x sleep >/dev/null 2>&1 && return 0
sleep 0.05
done
echo "the shipper never reached its first nap" >&2
return 1
}
@test "a line is shipped to Loki while the session runs" {
start_loki
write_config
@@ -156,10 +173,11 @@ await_shipped() {
write_config
mkdir -p "$(slug_dir)"
start_ship
# Long enough for one poll to read the line into the batch, short enough
# that the 2s batch interval has not flushed it on its own.
# Written before the first poll, so the poll reads it into the batch; the
# kill then lands while the shipper is asleep with it still in there,
# because the batch interval has not elapsed since it started.
say "already batched" >> "$(slug_dir)/s1.jsonl"
sleep 1
await_napping
kill -TERM "$SHIP_PID"
wait "$SHIP_PID" 2>/dev/null || true
SHIP_PID=""
@@ -172,10 +190,11 @@ await_shipped() {
write_config
mkdir -p "$(slug_dir)"
start_ship
# Written and killed inside one poll interval, so nothing has read it yet:
# only the trap's own rescan can find it. A final_flush that flushed the
# batch without rescanning would pass the test above and fail this one.
sleep 1
# Written after a poll has already run and killed before the next one, so
# nothing has read it yet: only the trap's own rescan can find it. A
# final_flush that flushed the batch without rescanning would pass the test
# above and fail this one.
await_napping
say "the final answer" >> "$(slug_dir)/s1.jsonl"
kill -TERM "$SHIP_PID"
wait "$SHIP_PID" 2>/dev/null || true
+87 -8
View File
@@ -27,7 +27,15 @@ import time
HELPER = os.path.abspath(sys.argv[1])
SHELL = sys.argv[2] if len(sys.argv) > 2 else "bash"
WORK = os.environ.get("TTY_SUSPEND_WORK") or "/tmp"
# Required, not defaulted. The fallback was "/tmp", and the three paths below
# are then fixed names in a world-writable directory that this fixture goes on
# to WRITE and EXECUTE. Every bats caller passes a directory, so nothing
# shipped ever took that branch - but this runs on CI hosts, and a predictable
# executed path is not a thing to leave lying around for the one caller that
# forgets (#249).
WORK = os.environ.get("TTY_SUSPEND_WORK")
if not WORK:
sys.exit("tty-suspend-harness: set TTY_SUSPEND_WORK to a directory of its own")
RECEIVED = os.path.join(WORK, "received")
LAUNCHER = os.path.join(WORK, "launcher.sh")
CONTAINER = os.path.join(WORK, "container.py")
@@ -110,8 +118,8 @@ finally:
# TTY_SUSPEND_NO_HELPER leaves the helper out, which is dejima before this
# existed. It is the control: a harness that cannot see the bug cannot be
# trusted to see the fix either.
start_helper = "" if os.environ.get("TTY_SUSPEND_NO_HELPER") else \
'"%s" &\nhelper=$!' % HELPER
HELPER_EXPECTED = not os.environ.get("TTY_SUSPEND_NO_HELPER")
start_helper = '"%s" &\nhelper=$!' % HELPER if HELPER_EXPECTED else ""
# bin/dejima's shape: the client inherits SIGTSTP ignored and is started with
# the terminal, and the keystroke is converted into an explicit SIGSTOP for it
# before the launcher stops itself. Without the conversion the client is the
@@ -159,7 +167,11 @@ if pid == 0:
if slave > 2:
os.close(slave)
os.close(master)
env = dict(os.environ, PS1="PROMPT> ")
# LC_ALL=C so the shell's job-control notice is the "Stopped" this expects.
# Left to the environment it is whatever locale the runner happens to have,
# and the assertion then fails on a translated message with nothing about
# the terminal handling having changed (#249).
env = dict(os.environ, PS1="PROMPT> ", LC_ALL="C", LANG="C")
args = [SHELL, "--norc", "-i"] if "bash" in SHELL else [SHELL, "-f", "-i"]
os.execvpe(SHELL, args, env)
os.close(slave)
@@ -209,6 +221,53 @@ def lflag():
return termios.tcgetattr(master)[3]
def reclaimed():
"""True once dejima-tty-suspend has taken ^Z back from the container's raw mode.
Read off the pty rather than inferred from output, and specific enough to
mean only the helper. Raw mode has ISIG off; a plain shell has ISIG on with
intr at ^C. Only the helper leaves ISIG on, susp at ^Z, and intr undefined
- which is the whole of what it does, so this is the feature's precondition
stated exactly.
"""
attrs = termios.tcgetattr(master)
cc = attrs[6]
return bool(attrs[3] & termios.ISIG) \
and cc[termios.VSUSP] == b"\x1a" \
and cc[termios.VINTR] == b"\x00"
def await_reclaim(timeout=10.0):
end = time.time() + timeout
while time.time() < end:
if reclaimed():
return True
pump(0.05)
return reclaimed()
def raw_restored():
"""True once the resumed client has put the terminal back into raw mode."""
flags = lflag()
return not (flags & termios.ICANON) and not (flags & termios.ECHO)
def await_raw(timeout=15.0):
"""Wait for that, rather than reading the flags at a fixed moment after fg.
`fg` returns as soon as the job is continued; the client sets raw mode some
scheduling later. Sampling the flags right after fg was timing the machine,
and on a loaded one it read the shell's own canonical mode and reported it
as a failure to restore - the same shape as the reclaim wait above.
"""
end = time.time() + timeout
while time.time() < end:
if raw_restored():
return True
pump(0.05)
return raw_restored()
def received():
try:
with open(RECEIVED, "rb") as fh:
@@ -230,6 +289,27 @@ try:
check("the container run attached", expect("CONTAINER-ATTACHED"))
for cycle in (1, 2):
# Wait for the helper to hold the terminal before typing at it (#262).
#
# CONTAINER-ATTACHED is printed by the stand-in immediately after it
# sets raw mode, so it marks the START of the window this feature
# exists to close, not the end. The helper only acts once it has POLLED
# the terminal and seen -isig, so between those two moments ^Z is still
# the byte 0x1a. Sending into that window put the byte in the container
# and failed `nothing-reached-the-container` - the security assertion -
# on a machine where nothing was wrong but the scheduling.
#
# Waiting for the precondition rather than lengthening the deadline
# after it: with the terminal provably converting ^Z to a signal before
# anything is typed, that check can no longer fail for timing reasons
# at all, which is the point. A longer timeout would only have narrowed
# the window.
#
# Skipped without the helper, where the byte reaching the container is
# the expected result and this would never come true.
if HELPER_EXPECTED:
check("cycle%d-suspend-char-reclaimed" % cycle, await_reclaim(),
"dejima-tty-suspend never took ^Z back from raw mode")
before = received()
forget()
send("\x1a")
@@ -243,12 +323,11 @@ try:
expect("SHELL-ALIVE-%d" % cycle, 5.0))
forget()
send("fg\n")
expect("CONTAINER-REDRAW", 5.0)
restored = await_raw()
flags = lflag()
check("cycle%d-raw-mode-restored" % cycle,
not (flags & termios.ICANON) and not (flags & termios.ECHO),
check("cycle%d-raw-mode-restored" % cycle, restored,
"ICANON=%s ECHO=%s" % (bool(flags & termios.ICANON), bool(flags & termios.ECHO)))
check("cycle%d-redraw-poked" % cycle, "CONTAINER-REDRAW" in "".join(seen))
check("cycle%d-redraw-poked" % cycle, expect("CONTAINER-REDRAW", 15.0))
check("cycle%d-session-alive" % cycle, "LAUNCHER-DONE" not in "".join(seen))
before = received()
+106 -25
View File
@@ -95,14 +95,12 @@ launch() {
# No default, so a config that launches must select one.
# A launch runs dejima-update-check, which would ls-remote this checkout's
# own origin. Off here rather than in each fixture's config, because the
# launch is what triggers it. XDG_STATE_HOME is stated for the mirror
# reason: left alone the cache lands under the test's HOME, so every test
# misses and calls out again; exported by whoever ran the suite, it lands
# in their real state directory.
# launch is what triggers it. Its cache needs nothing further: it lands
# under $HOME, which every launch fixture already points somewhere of its
# own.
printf '[core]\nharness = claude-code\nupdate_check = off\nssh_key = %s\n' \
"$LAUNCH_DIR/sandbox-key" >> "$conf"
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
XDG_STATE_HOME="$LAUNCH_DIR/state" \
SANDBOX_TEST_RUN_ENV="$LAUNCH_ENV" \
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
SANDBOX_TEST_DOCKERFILE_LOG="$LAUNCH_DIR/dockerfile-log" \
@@ -583,8 +581,8 @@ EOF
run "$REPO_ROOT/bin/dejima-allow" crates.io
[ "$status" -ne 0 ]
[[ "$output" == *"Multiple running dejima containers"* ]] || { echo "$output"; return 1; }
grep -qE 'dejima-run-aaaa +alpha' <<< "$output" || { echo "$output"; return 1; }
grep -qE 'dejima-run-bbbb +beta' <<< "$output" || { echo "$output"; return 1; }
grep -qE 'alpha +dejima-run-aaaa' <<< "$output" || { echo "$output"; return 1; }
grep -qE 'beta +dejima-run-bbbb' <<< "$output" || { echo "$output"; return 1; }
rm -rf "$STUB_DIR"
}
@@ -597,8 +595,8 @@ EOF
run "$REPO_ROOT/bin/dejima-allow" crates.io
# beta is first in the attach listing, so beta is 1 - not alpha, which
# docker happens to report first.
grep -qE 'beta .*\(dejima-attach 1\)' <<< "$output" || { echo "$output"; return 1; }
grep -qE 'alpha .*\(dejima-attach 2\)' <<< "$output" || { echo "$output"; return 1; }
grep -qE '^ *beta .*\(dejima-attach 1\)' <<< "$output" || { echo "$output"; return 1; }
grep -qE '^ *alpha .*\(dejima-attach 2\)' <<< "$output" || { echo "$output"; return 1; }
rm -rf "$STUB_DIR"
}
@@ -615,7 +613,7 @@ EOF
chmod +x "$STUB_DIR/dejima-attach"
run "$REPO_ROOT/bin/dejima-allow" crates.io
[ "$status" -ne 0 ]
grep -qE 'dejima-run-aaaa +alpha' <<< "$output" || { echo "$output"; return 1; }
grep -qE 'alpha +dejima-run-aaaa' <<< "$output" || { echo "$output"; return 1; }
[[ "$output" != *"dejima-attach 1"* ]] || { echo "invented a number: $output"; return 1; }
[[ "$output" != *"Remote access is not configured"* ]] \
|| { echo "leaked the attach refusal: $output"; return 1; }
@@ -1068,13 +1066,13 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
launch_setup
run launch "$PROJ"
[ "$status" -eq 0 ]
[ "$(launched_env SANDBOX_IMAGE)" = "dejima:$(id -un)-harness-claude-code" ]
[ "$(launched_env SANDBOX_IMAGE)" = "localhost/dejima:$(id -un)-harness-claude-code" ]
"$REPO_ROOT/bin/dejima-dep" add --project "$PROJ" apt:libpq-dev >/dev/null
run launch "$PROJ"
[ "$status" -eq 0 ]
slug="$(bash -c 'source "$1"; project_slug "$2"' _ "$LIB" "$PROJ")"
[ "$(launched_env SANDBOX_IMAGE)" = "dejima:$(id -un)-$slug" ]
[ "$(launched_env SANDBOX_IMAGE)" = "localhost/dejima:$(id -un)-$slug" ]
}
# nvim's data/state/cache are Docker volumes, not host paths, so nothing a
@@ -1118,8 +1116,8 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
launch_setup
run launch "$PROJ"
[ "$status" -eq 0 ]
grep -q -- "-t dejima:$(id -un)-global -f" <(docker_log)
[ "$(launched_env SANDBOX_IMAGE)" = "dejima:$(id -un)-global" ]
grep -q -- "-t localhost/dejima:$(id -un)-global -f" <(docker_log)
[ "$(launched_env SANDBOX_IMAGE)" = "localhost/dejima:$(id -un)-global" ]
}
@test "the launcher builds the global layer then the project layer on top of it" {
@@ -1129,12 +1127,12 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
run launch "$PROJ"
[ "$status" -eq 0 ]
slug="$(bash -c 'source "$1"; project_slug "$2"' _ "$LIB" "$PROJ")"
global_line=$(grep -n -- "-t dejima:$(id -un)-global -f" <(docker_log) | cut -d: -f1)
project_line=$(grep -n -- "-t dejima:$(id -un)-$slug -f" <(docker_log) | cut -d: -f1)
global_line=$(grep -n -- "-t localhost/dejima:$(id -un)-global -f" <(docker_log) | cut -d: -f1)
project_line=$(grep -n -- "-t localhost/dejima:$(id -un)-$slug -f" <(docker_log) | cut -d: -f1)
[ -n "$global_line" ]
[ -n "$project_line" ]
[ "$global_line" -lt "$project_line" ]
[ "$(launched_env SANDBOX_IMAGE)" = "dejima:$(id -un)-$slug" ]
[ "$(launched_env SANDBOX_IMAGE)" = "localhost/dejima:$(id -un)-$slug" ]
}
@test "the project layer's Dockerfile is generated FROM the global layer, not the base image" {
@@ -1150,11 +1148,11 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
[ "$status" -eq 0 ]
slug="$(bash -c 'source "$1"; project_slug "$2"' _ "$LIB" "$PROJ")"
# base -> harness -> global -> project, each FROM the one before it.
dockerfile_for "dejima:$(id -un)-harness-claude-code" \
| grep -qxF "FROM dejima:$(id -un)"
dockerfile_for "dejima:$(id -un)-global" \
| grep -qxF "FROM dejima:$(id -un)-harness-claude-code"
dockerfile_for "dejima:$(id -un)-$slug" | grep -qxF "FROM dejima:$(id -un)-global"
dockerfile_for "localhost/dejima:$(id -un)-harness-claude-code" \
| grep -qxF "FROM localhost/dejima:$(id -un)"
dockerfile_for "localhost/dejima:$(id -un)-global" \
| grep -qxF "FROM localhost/dejima:$(id -un)-harness-claude-code"
dockerfile_for "localhost/dejima:$(id -un)-$slug" | grep -qxF "FROM localhost/dejima:$(id -un)-global"
}
@test "the launcher builds no dependency layer when neither global nor project deps are approved" {
@@ -1165,10 +1163,10 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
# harness in the image - so this is about the DEPENDENCY layers, which is
# what "no approved dependencies" is a statement about.
local tagged
tagged="$(grep -oE -- '-t dejima:[^ ]+' <(docker_log) | sed 's/^-t //' | sort -u)"
[ "$tagged" = "dejima:$(id -un)-harness-claude-code" ] \
tagged="$(grep -oE -- '-t localhost/dejima:[^ ]+' <(docker_log) | sed 's/^-t //' | sort -u)"
[ "$tagged" = "localhost/dejima:$(id -un)-harness-claude-code" ] \
|| { echo "expected only the harness layer to be built, got:"; echo "$tagged"; return 1; }
[ "$(launched_env SANDBOX_IMAGE)" = "dejima:$(id -un)-harness-claude-code" ]
[ "$(launched_env SANDBOX_IMAGE)" = "localhost/dejima:$(id -un)-harness-claude-code" ]
}
@test "the launcher warns when the approval store sits inside the mounted workspace" {
@@ -1935,3 +1933,86 @@ declare_repeatedly() {
[ "$status" -eq 0 ]
[[ "$output" == *"Removed apt:ripgrep"* ]]
}
@test "with no terminal the multi-container case stays a list and an exit" {
# The picker must never be what a script meets. `run` gives no tty, which
# is the same shape as cron, a pipe, or an agent running this: a prompt
# there is a hang nobody can answer.
stub_two_containers
run "$REPO_ROOT/bin/dejima-allow" crates.io
[ "$status" -ne 0 ] || { echo "a script got no error: $output"; return 1; }
[[ "$output" == *"specify one with --container"* ]] \
|| { echo "the flag is not offered: $output"; return 1; }
[[ "$output" != *"q to quit"* ]] || { echo "prompted with no tty: $output"; return 1; }
# And the line an agent is pointed at stays, since this is the path an
# agent's operator reads.
[[ "$output" == *"don't let it guess one"* ]] \
|| { echo "the guess-one warning is gone: $output"; return 1; }
rm -rf "$STUB_DIR"
}
@test "the picker acts on the row that was picked" {
# The number typed indexes the rows as printed. Worth its own test because
# the failure is silent: the wrong session is patched, the domain is
# allowed, and everything reports success.
stub_two_containers
local out
# beta is docker's second row, so 2 is beta - and 1 must not be.
# || true: the stub's reachability probe decides the exit status, and this
# is a test about which container was acted on, not about that.
out="$(printf '2\n' | TERM=xterm-256color script -qec \
"PATH='$STUB_DIR:$PATH' '$REPO_ROOT/bin/dejima-allow' crates.io" /dev/null || true)"
[[ "$out" == *dejima-run-bbbb* ]] \
|| { echo "picking 2 did not act on the second row: $out"; return 1; }
[[ "$out" != *"inside dejima-run-aaaa"* ]] \
|| { echo "picking 2 acted on the first row: $out"; return 1; }
out="$(printf '1\n' | TERM=xterm-256color script -qec \
"PATH='$STUB_DIR:$PATH' '$REPO_ROOT/bin/dejima-allow' crates.io" /dev/null || true)"
[[ "$out" == *dejima-run-aaaa* ]] \
|| { echo "picking 1 did not act on the first row: $out"; return 1; }
rm -rf "$STUB_DIR"
}
@test "quitting the picker allows nothing" {
stub_two_containers
local out
out="$(printf 'q\n' | TERM=xterm-256color script -qec \
"PATH='$STUB_DIR:$PATH' '$REPO_ROOT/bin/dejima-allow' crates.io" /dev/null)"
[[ "$out" != *Resolving* ]] || { echo "q went ahead anyway: $out"; return 1; }
rm -rf "$STUB_DIR"
}
@test "the picker's columns are padded to what is in them" {
# "dejima-sandbox-run-" plus twelve hex digits is 31 characters, and the
# column was a fixed 28 - so every real name overran it and the project
# beside it started one space later than the row above. That is the whole
# complaint: there is no telling where the name stops.
stub_two_containers
local out proj_cols
out="$(printf 'q\n' | TERM=xterm-256color script -qec \
"PATH='$STUB_DIR:$PATH' '$REPO_ROOT/bin/dejima-allow' crates.io" /dev/null \
| sed 's/\x1b\[[0-9;]*m//g; s/\r$//')"
# The container name starts at the same column on every row, header
# included, which is what padding to the widest value buys.
proj_cols="$(grep -oE '^ +[0-9]+ +[a-z]+ +dejima-run' <<< "$out" \
| awk '{ print index($0, "dejima-run") }' | sort -u | wc -l)"
[ "$proj_cols" -eq 1 ] \
|| { echo "rows start the name at different columns:"; echo "$out"; return 1; }
rm -rf "$STUB_DIR"
}
@test "dejima-allow's picker does not wipe the terminal it was run in" {
# It shares the banner with dejima-attach and deliberately not the clear.
# dejima-attach is a front door, navigated to; this is a prompt inside a
# shell the operator is already using, and wiping their scrollback to ask
# one question is worse than the interleaving the clear exists to fix.
stub_two_containers
local out
out="$(printf 'q\n' | TERM=xterm-256color script -qec \
"PATH='$STUB_DIR:$PATH' '$REPO_ROOT/bin/dejima-allow' crates.io" /dev/null || true)"
[[ "$out" != *$'\033[2J'* ]] \
|| { echo "dejima-allow cleared the operator's screen"; return 1; }
# It still drew the picker, so this is not passing because nothing ran.
[[ "$out" == *"q to quit"* ]] || { echo "no picker at all: $out"; return 1; }
rm -rf "$STUB_DIR"
}
+82
View File
@@ -1045,11 +1045,34 @@ assert_remedy_forges_exist() {
# with (#187). The guard is generic; the manifest is what makes it fire.
[ "$(lib manifest_get forge github repo_flags)" = "--repo -R" ]
[ "$(lib manifest_get forge github repo_env)" = "GH_REPO" ]
# And in a path rather than a flag: `gh api repos/<owner>/<name>/...`
# names a repository with no --repo in it at all (#243).
[ "$(lib manifest_get forge github repo_paths)" = "api:repos" ]
# The gitea CLIs resolve the repo from origin and refuse another host, so
# there is nothing to declare and nothing for the guard to compare. A
# forge is not required to be aimable.
[ -z "$(lib manifest_get forge gitea repo_flags)" ]
[ -z "$(lib manifest_get forge gitea repo_env)" ]
[ -z "$(lib manifest_get forge gitea repo_paths)" ]
}
@test "a repo path has to name a subcommand and a segment" {
# `<subcommand>:<first segment>`. The subcommand half is what keeps the
# guard from comparing every positional argument that contains two
# slashes, so a spelling that omits it is not a narrower declaration - it
# is one that would refuse an issue title.
local d bad; d="$(mktemp -d "$BATS_TEST_TMPDIR/pathtype.XXXXXX")"
mkdir -p "$d/forges/probe"
for bad in "repos" "api:" ":repos" "api:repos/more" "api:/repos" "-api:repos"; do
printf 'repo_paths = %s\n' "$bad" > "$d/forges/probe/forge.conf"
run env DEJIMA_PROVIDER_ROOT="$d" \
bash -c 'source "$1"; manifest_validate forge probe' _ "$LIB"
[ "$status" -ne 0 ] || { echo "accepted repo_paths = $bad"; return 1; }
done
printf 'repo_paths = api:repos\n' > "$d/forges/probe/forge.conf"
run env DEJIMA_PROVIDER_ROOT="$d" \
bash -c 'source "$1"; manifest_validate forge probe' _ "$LIB"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
}
@test "a repo flag has to look like a flag" {
@@ -1263,3 +1286,62 @@ assert_remedy_forges_exist() {
[ -n "$spec" ] || { echo "$name declares no install and is not in the base image"; return 1; }
done
}
@test "a workflow that omits block_merge blocks merges anyway" {
# The default decides what happens to a workflow that forgets the key -
# a third-party one, or a fourth shipped one added in a hurry. `off` means
# a missing key silently permits merging from inside a session, which is
# the direction a guard should never fail in. Every shipped workflow sets
# it explicitly, so the invariant test covering those would pass either
# way; this is about the ones nobody has written yet.
FAKE_ROOT="$(mktemp -d)"
mkdir -p "$FAKE_ROOT/workflows/forgetful"
printf 'requires =\npush = off\n' > "$FAKE_ROOT/workflows/forgetful/workflow.conf"
run env DEJIMA_PROVIDER_ROOT="$FAKE_ROOT" DEJIMA_CONFIG_DIR="$CONFIG_DIR" \
bash -c 'source "$1"; manifest_get workflow forgetful block_merge' _ "$LIB"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$output" = "on" ] \
|| { echo "a workflow that names no block_merge got '$output'"; return 1; }
rm -rf "$FAKE_ROOT"
}
@test "a forge that aims its CLI also gets that CLI shadowed" {
# entrypoint.sh installs the PATH shadow from merge_block and disclose
# only (_shadow_clis). repo_flags and repo_env are exported to the
# container regardless - so a forge declaring repo_flags without either of
# the other two hands the guard its configuration with no guard on PATH to
# read it, and the aiming check silently does not exist.
#
# Both shipped forges declare disclose, so nothing is broken today. This
# is the manifest contract stated as an invariant, for the third forge
# someone writes (#244).
local forge named shadowed cli entry
for dir in "$REPO_ROOT"/forges/*/; do
[ -d "$dir" ] || continue
forge="$(basename "$dir")"
[ -n "$(lib manifest_get forge "$forge" repo_flags)$(lib manifest_get forge "$forge" repo_env)" ] \
|| continue
# The CLI is the first word of each command the forge names.
named=""
for entry in pr_command issue_command; do
cli="$(lib manifest_get forge "$forge" "$entry")"
[ -n "$cli" ] && named="$named ${cli%% *}"
done
# What entrypoint.sh would actually shadow: merge_block's first word,
# and the CLI in each disclose record ("<kind>:<cli> <subcommand>").
shadowed=" $(lib manifest_get forge "$forge" merge_block | cut -d' ' -f1) "
while IFS= read -r entry; do
entry="${entry#"${entry%%[![:space:]]*}"}"
[ -n "$entry" ] || continue
cli="${entry#*:}"
shadowed="$shadowed ${cli%% *} "
done < <(lib manifest_get forge "$forge" disclose | tr ',' '\n')
for cli in $named; do
[[ "$shadowed" == *" $cli "* ]] \
|| { echo "forge $forge aims '$cli' but nothing shadows it: declare it in merge_block or disclose"; return 1; }
done
done
}
+173 -7
View File
@@ -7,7 +7,7 @@ PLACEHOLDER="sandbox-credential-proxy-placeholder"
setup() {
REPO_ROOT="$BATS_TEST_DIRNAME/.."
PROXY="$REPO_ROOT/bin/dejima-anthropic-proxy"
PROXY="$REPO_ROOT/bin/dejima-credential-proxy"
TD="$(mktemp -d)"
SOCK="$TD/proxy.sock"
CREDS="$TD/credentials.json"
@@ -27,10 +27,21 @@ EOF
chmod 600 "$CREDS"
}
# The Anthropic arrangement, spelled the way bin/dejima spells it. The proxy
# itself now defaults none of this (#171), so a test that omitted a flag would
# be exercising a configuration no launch produces - which is why these are
# here in full rather than left to defaults, and why static.bats pins that
# bin/dejima still passes the same set.
start_proxy() {
"$PROXY" --socket "$SOCK" --credentials "$CREDS" \
--upstream "http://127.0.0.1:$PORT" \
--oauth-endpoint "http://127.0.0.1:$PORT/v1/oauth/token" "$@" 2>"$TD/stderr" &
--credential-kind anthropic-oauth \
--oauth-endpoint "http://127.0.0.1:$PORT/v1/oauth/token" \
--oauth-client-id "9d1c250a-e61b-44d9-88ed-5944d1962f5e" \
--auth-header Authorization \
--auth-format "Bearer {token}" \
--strip-header x-api-key \
--ensure-header "anthropic-beta=oauth-2025-04-20" "$@" 2>"$TD/stderr" &
PROXY_PID=$!
for _ in $(seq 1 50); do [ -S "$SOCK" ] && break; sleep 0.1; done
[ -S "$SOCK" ]
@@ -268,8 +279,13 @@ teardown() {
@test "an already-expired refresh token is fatal at startup, before the socket exists" {
write_creds 7200
sed -i 's/"refreshTokenExpiresAt":[0-9]*/"refreshTokenExpiresAt":1000/' "$CREDS"
run "$PROXY" --socket "$SOCK" --credentials "$CREDS" --upstream "http://127.0.0.1:$PORT"
[ "$status" -ne 0 ]
run timeout 5 "$PROXY" --socket "$SOCK" --credentials "$CREDS" \
--upstream "http://127.0.0.1:$PORT" --credential-kind anthropic-oauth \
--oauth-endpoint "http://127.0.0.1:$PORT/v1/oauth/token" \
--oauth-client-id probe
# 1, not just nonzero: an argument mistake exits 2, and this test would
# otherwise pass on one while proving nothing about the expiry check.
[ "$status" -eq 1 ]
[[ "$output" == *"log in"* ]]
[ ! -S "$SOCK" ]
}
@@ -278,7 +294,7 @@ teardown() {
start_proxy
SANDBOX_ANTHROPIC_PROXY_SOCKET="$SOCK" SANDBOX_ANTHROPIC_PROXY_PORT=18399 \
SANDBOX_ANTHROPIC_TRANSPORT=loopback \
node "$REPO_ROOT/docker/anthropic-proxy-forwarder.js" &
node "$REPO_ROOT/docker/credential-proxy-forwarder.js" &
FWD_PID=$!
ok=""
for _ in $(seq 1 50); do
@@ -347,7 +363,7 @@ start_forwarder() { # start_forwarder <transport> <port>; extra env passes thro
SANDBOX_ANTHROPIC_PROXY_PORT="$2" \
SANDBOX_ANTHROPIC_TLS_CERT="$TD/cert.pem" \
SANDBOX_ANTHROPIC_TLS_KEY="$TD/key.pem" \
node "$REPO_ROOT/docker/anthropic-proxy-forwarder.js" 2>"$TD/fwd-stderr" &
node "$REPO_ROOT/docker/credential-proxy-forwarder.js" 2>"$TD/fwd-stderr" &
FWD_PID=$!
for _ in $(seq 1 50); do
(exec 3<>"/dev/tcp/127.0.0.1/$2") 2>/dev/null && return 0
@@ -422,7 +438,7 @@ make_cert() {
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 \
node "$REPO_ROOT/docker/anthropic-proxy-forwarder.js"
node "$REPO_ROOT/docker/credential-proxy-forwarder.js"
[ "$status" -ne 0 ]
[[ "$output" == *"SANDBOX_ANTHROPIC_TLS_CERT"* ]]
}
@@ -464,3 +480,153 @@ make_cert() {
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$output" = "200" ] || { echo "a 2MiB body was refused with $output"; return 1; }
}
# --- provider-generic behaviour (#171) ---------------------------------------
#
# The proxy no longer knows a provider's name: upstream, credential kind, auth
# header and shape, what to strip and what to ensure are all arguments. These
# exercise the second kind - a static key, which is what an OpenAI-compatible
# hosted provider has - and the parts of the shape a provider can move.
# A static-key proxy against the same fake upstream. $1.. are extra flags.
# Startup refusals run the proxy in the FOREGROUND and expect it to exit, so
# every one of them is bounded by `timeout`. Without it, a check that stopped
# refusing does not fail the test - the proxy starts, serves forever, and the
# test hangs until the whole suite is killed, which reports as no result rather
# than as a wrong one. Found by mutating two of these and watching them time
# out instead of going red.
start_static() {
printf 'sk-test-static-key\n' > "$TD/key"
chmod 600 "$TD/key"
"$PROXY" --socket "$SOCK" --credentials "$TD/key" \
--upstream "http://127.0.0.1:$PORT" \
--credential-kind static "$@" 2>"$TD/stderr" &
PROXY_PID=$!
for _ in $(seq 1 50); do [ -S "$SOCK" ] && break; sleep 0.1; done
[ -S "$SOCK" ]
}
@test "a static key is injected, and the placeholder never reaches upstream" {
start_static
run pcurl -d '{"hello":1}' http://proxy/v1/chat/completions
[ "$status" -eq 0 ]
[ "$(jq -r '.headers.authorization' <<<"$output")" = "Bearer sk-test-static-key" ]
[[ "$output" != *"$PLACEHOLDER"* ]]
[ "$(jq -r '.body' <<<"$output")" = '{"hello":1}' ]
}
@test "a static key file's trailing newline does not reach the header" {
# Written by an editor or a shell redirection as often as by a tool. A
# newline in a header value is not cosmetic: it would be rejected or split
# the block.
start_static
run pcurl -d '{}' http://proxy/v1/chat/completions
[ "$(jq -r '.headers.authorization' <<<"$output")" = "Bearer sk-test-static-key" ]
}
@test "a static credential is never refreshed, so a 401 is reported once" {
# Refreshing needs somewhere to refresh against, and a static key has
# none. Retrying would send the same bytes and turn one rejection into
# two, which reads as a flapping upstream rather than a wrong key.
# The same route the OAuth retry test uses: 401 on the first call, echo
# after. An OAuth credential refreshes and the second call succeeds; a
# static one has nowhere to refresh against, so the 401 is what the
# session sees.
start_static
run pcurl -o /dev/null -w '%{http_code}' -d '{}' http://proxy/echo-401-once
[ "$output" = "401" ]
# And no refresh was attempted anywhere, which the fake counts for us.
[ "$(curl -s "http://127.0.0.1:$PORT/oauth-hits")" = "0" ]
}
@test "the auth header and its format are the provider's, not Bearer by fiat" {
# A provider that wants its key in a header of its own, unprefixed, needs
# no code here. Asserted together because the pair is the shape.
start_static --auth-header x-goog-api-key --auth-format '{token}'
run pcurl -d '{}' http://proxy/v1/chat/completions
[ "$(jq -r '.headers["x-goog-api-key"]' <<<"$output")" = "sk-test-static-key" ]
# And the client's own Authorization placeholder is left alone rather than
# silently rewritten: it is not this provider's auth header. Stripping it
# is a separate declaration, which the next test covers.
[ "$(jq -r '.headers.authorization' <<<"$output")" = "Bearer $PLACEHOLDER" ]
}
@test "a header named in strip-header does not travel beside the injected one" {
start_static --auth-header x-goog-api-key --auth-format '{token}' \
--strip-header authorization
run pcurl -d '{}' http://proxy/v1/chat/completions
[ "$(jq -r '.headers.authorization // "absent"' <<<"$output")" = "absent" ]
[ "$(jq -r '.headers["x-goog-api-key"]' <<<"$output")" = "sk-test-static-key" ]
}
@test "the auth header cannot be stripped out from under itself" {
# Naming it in both places is a provider's mistake, and the safe reading
# is that the credential still goes: dropping it would send an
# unauthenticated request and report the provider's 401 as the fault.
#
# Position as well as presence, because presence alone does not
# distinguish the two paths. A stripped-then-re-added header arrives at
# the END of the block from the supplied-names pass; a replaced one keeps
# the slot the client gave it. Both send the credential, and only one of
# them leaves the request looking like the client wrote it - which is the
# property the whole header-emission dance exists for.
start_static --strip-header authorization
run pcurl -H "Content-Type: application/json" -H "X-Stainless-Lang: js" \
-d '{}' http://proxy/v1/chat/completions
[ "$(jq -r '.headers.authorization' <<<"$output")" = "Bearer sk-test-static-key" ]
local auth_at type_at
auth_at="$(jq -r '[.header_lines[][0] | ascii_downcase] | index("authorization")' <<<"$output")"
type_at="$(jq -r '[.header_lines[][0] | ascii_downcase] | index("content-type")' <<<"$output")"
[ "$auth_at" -lt "$type_at" ] \
|| { echo "Authorization moved to position $auth_at, after Content-Type at $type_at"; return 1; }
}
@test "no ensure-header means none is invented" {
# anthropic-beta is one provider's requirement. A proxy that added it
# anyway would put an Anthropic tell on every other provider's wire.
start_static
run pcurl -d '{}' http://proxy/v1/chat/completions
[ "$(jq -r '.headers["anthropic-beta"] // "absent"' <<<"$output")" = "absent" ]
}
@test "an unusable static key is fatal at startup, before the socket exists" {
: > "$TD/key"
run timeout 5 "$PROXY" --socket "$SOCK" --credentials "$TD/key" \
--upstream "http://127.0.0.1:$PORT" --credential-kind static
[ "$status" -eq 1 ]
[[ "$output" == *"empty"* ]]
[ ! -S "$SOCK" ]
}
@test "a key carrying a control character is refused rather than sanitised" {
# A key that needs sanitising was pasted wrong. Trimming it would send
# mangled bytes and report the provider's rejection as the fault.
printf 'sk-good\x1bbad\n' > "$TD/key"
run timeout 5 "$PROXY" --socket "$SOCK" --credentials "$TD/key" \
--upstream "http://127.0.0.1:$PORT" --credential-kind static
[ "$status" -eq 1 ]
[[ "$output" == *"control character"* ]]
[ ! -S "$SOCK" ]
}
@test "an auth-format with no token placeholder is refused" {
# It would start, answer requests, and send every one of them without a
# credential. Refusing at parse time is the difference between a
# configuration error and a silently unauthenticated session.
printf 'k\n' > "$TD/key"
run timeout 5 "$PROXY" --socket "$SOCK" --credentials "$TD/key" \
--upstream "http://127.0.0.1:$PORT" --credential-kind static \
--auth-format 'Bearer'
[ "$status" -eq 2 ]
[[ "$output" == *"{token}"* ]]
[ ! -S "$SOCK" ]
}
@test "anthropic-oauth without its endpoint or client id is refused" {
write_creds 7200
run timeout 5 "$PROXY" --socket "$SOCK" --credentials "$CREDS" \
--upstream "http://127.0.0.1:$PORT" --credential-kind anthropic-oauth
[ "$status" -eq 2 ]
[[ "$output" == *"oauth-endpoint"* ]]
[ ! -S "$SOCK" ]
}
+173 -30
View File
@@ -180,14 +180,12 @@ launch() {
# No default, so a config that launches must select one.
# A launch runs dejima-update-check, which would ls-remote this checkout's
# own origin. Off here rather than in each fixture's config, because the
# launch is what triggers it. XDG_STATE_HOME is stated for the mirror
# reason: left alone the cache lands under the test's HOME, so every test
# misses and calls out again; exported by whoever ran the suite, it lands
# in their real state directory.
# launch is what triggers it. Its cache needs nothing further: it lands
# under $HOME, which every launch fixture already points somewhere of its
# own.
printf '[core]\nharness = claude-code\nupdate_check = off\nssh_key = %s\n' \
"$LAUNCH_DIR/sandbox-key" >> "$conf"
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
XDG_STATE_HOME="$LAUNCH_DIR/state" \
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
SANDBOX_TEST_SETTINGS_COPY="$LAUNCH_DIR/settings-copy.json" \
SSH_AUTH_SOCK= ALLOWED_DOMAINS= \
@@ -765,26 +763,28 @@ start_tmux() {
}
# Provenance, pinned offline. Upstream's color.table is the authority, but a
# test cannot go and fetch it, so the name->hex half is transcribed from it
# here. The half that catches a typo is checked rather than transcribed: every
# escape is decoded back to a hex and compared against the one written beside
# it, so a value edited in one place and not the other fails here instead of
# shipping under the name "gruvbox".
@test "the picker's palette is gruvbox, by upstream's own values" {
local -A gruvbox=(
[bright_orange]=fe8019 [light1]=ebdbb2 [gray_245]=928374
[dark4]=7c6f64 [dark2]=504945 [bright_green]=b8bb26
[bright_yellow]=fabd2f [bright_red]=fb4934 [bright_blue]=83a598
)
local seen=0 r g b name hex
while read -r r g b name hex; do
seen=$((seen + 1))
[ "${gruvbox[$name]:-unknown}" = "$hex" ]
[ "$(printf '%02x%02x%02x' "$r" "$g" "$b")" = "$hex" ]
done < <(sed -n \
"s/.*38;2;\([0-9]*\);\([0-9]*\);\([0-9]*\)m'.*# \([a-z_0-9]*\) *#\([0-9a-f]*\).*/\1 \2 \3 \4 \5/p" \
"$REPO_ROOT/bin/dejima-attach")
[ "$seen" -eq "${#gruvbox[@]}" ]
# The terminal palette carries no hexes at all any more, and that is the
# property worth pinning. It used to hardcode gruvbox in 24-bit, which is the
# brand but assumes a background dejima does not own: measured against a light
# one, light1 came out at 1.21:1 and bright yellow at 1.49:1. Indexed colours
# cannot have that problem, because the theme's author picked them against
# their own background - and on a gruvbox terminal they ARE those hexes.
@test "the terminal palette is the terminal's own, not hardcoded" {
local lib="$REPO_ROOT/bin/dejima-color-lib.sh"
run grep -n '38;2;' "$lib" "$REPO_ROOT/bin/dejima-tui-lib.sh"
[ "$status" -ne 0 ] \
|| { echo "24-bit colour is back in a terminal palette:"; echo "$output"; return 1; }
# And it is one palette: a second definition is how C_OK and C_WARN came
# to mean two different things depending on which file a caller sourced.
run grep -c '^\s*C_OK=' "$REPO_ROOT/bin/dejima-tui-lib.sh"
[ "$output" -eq 0 ] \
|| { echo "dejima-tui-lib.sh defines its own palette again"; return 1; }
# The colours it does use are the semantic slots, so a gruvbox terminal
# fills them with gruvbox.
local slot
for slot in '92m' '93m' '91m' '94m' '90m'; do
grep -q "$slot" "$lib" || { echo "the palette lost slot $slot"; return 1; }
done
}
@test "attaching creates a grouped session on the requested window and disposes of it on detach" {
@@ -1322,7 +1322,6 @@ EOF
stub_launch
write_remote_config
launch "$PROJ"
grep -q 'dejima-notify.sh waiting' "$LAUNCH_DIR/settings-copy.json"
grep -q 'dejima-notify.sh done' "$LAUNCH_DIR/settings-copy.json"
# The layer is a throwaway, so the operator's real settings.json - which
# nothing registers this hook into - is left byte-for-byte alone.
@@ -1337,8 +1336,8 @@ EOF
# Copying them would run each of them twice. Verified against Claude Code
# 2.1.220: a SessionStart hook in a --settings file and one in
# ~/.claude/settings.json both fire.
[ "$(jq '.hooks.Notification | length' "$LAUNCH_DIR/settings-copy.json")" = "1" ]
[ "$(jq '.hooks.Stop | length' "$LAUNCH_DIR/settings-copy.json")" = "1" ]
[ "$(jq '.hooks.Notification // [] | length' "$LAUNCH_DIR/settings-copy.json")" = "0" ]
run jq -r '[.hooks[][].hooks[].command] | join(" ")' "$LAUNCH_DIR/settings-copy.json"
[[ "$output" != *operator-desktop-notify.sh* ]]
[[ "$output" != *operator-tts.sh* ]]
@@ -1354,15 +1353,29 @@ EOF
# the whitelist below is what makes the log sufficient.
stub_curl() {
STUB_DIR="${STUB_DIR:-$(mktemp -d)}"
# stdin as well as argv, because the request URL now arrives that way and
# a stub that only saw argv would report the push as carrying no
# destination at all - which reads like the value being dropped.
#
# Drained only when `--config -` asked for it, exactly as the real curl
# does. A stub that reads stdin unconditionally HANGS when the caller
# sends none, so a change that stopped redirecting would fail this suite
# by timing out rather than by failing an assertion.
cat > "$STUB_DIR/curl" <<EOF
#!/bin/bash
printf '%s\n' "\$@" >> "$STUB_DIR/curl-argv"
_want_stdin=
for _a in "\$@"; do
[ "\$_a" = --config ] && _want_stdin=1
done
[ -z "\$_want_stdin" ] || cat >> "$STUB_DIR/curl-stdin"
exit 0
EOF
chmod +x "$STUB_DIR/curl"
PATH="$STUB_DIR:$PATH"
}
curl_log() { cat "$STUB_DIR/curl-argv" 2>/dev/null; }
curl_stdin() { cat "$STUB_DIR/curl-stdin" 2>/dev/null; }
# The EXACT argument vector of one push, one argument per line - a whitelist,
# not a search. Absence assertions cannot do this job: an argument that leaks
@@ -1376,7 +1389,7 @@ expect_argv() {
-H "Title: dejima: $1" \
-H "Priority: default"
[ -z "${3:-}" ] || printf '%s\n' -H "Click: $3"
printf '%s\n' -d "$2" -- https://ntfy.example.invalid/dejima
printf '%s\n' -d "$2" --config -
}
# Compared as a whole file, so a SECOND push would fail this too. The
# sentinel is what makes "whole" true: both sides are command substitutions,
@@ -1570,6 +1583,49 @@ EOF
[ -z "$(curl_log)" ]
}
@test "the ntfy URL never reaches curl's argv, because it can carry a token" {
# An ntfy server that wants credentials takes them in the URL, and
# docs/remote-access.md documents that form. Every argument of every
# process on this host is readable by any local account through
# /proc/<pid>/cmdline, so a URL in argv is a token published to the
# machine for as long as the request runs - `ps` is enough.
#
# Both halves are asserted. Absent from argv is the fix; present on stdin
# is what stops the fix from being "the push lost its destination", which
# would pass an absence check just as well.
stub_curl
conf_with_ntfy
write_entry 0123456789abcdef '%1' 'work:2'
marker 0123456789abcdef waiting
run "$REPO_ROOT/bin/dejima-notify-watch" --once
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$(curl_log)" != *ntfy.example.invalid* ]] \
|| { echo "the URL is in argv:"; curl_log; return 1; }
[[ "$(curl_stdin)" == *'url = "https://ntfy.example.invalid/dejima"'* ]] \
|| { echo "the URL did not reach curl at all:"; curl_stdin; return 1; }
# The click URL is not secret and stays where it is: it is composed from
# base_url, which is the address of the operator's own front door.
[[ "$(curl_log)" == *"Click: https://dejima.example.invalid/?arg=0123456789abcdef"* ]] \
|| { echo "the click target moved:"; curl_log; return 1; }
}
@test "a misconfigured ntfy URL is not echoed back with its credentials" {
# The refusal path prints the offending value, and the journal is where
# that lands under a systemd user unit. A token that survives a
# misconfiguration by being written to the journal is a token that
# outlives the mistake.
stub_curl
write_remote_config 'ntfy_url = https://someone:hunter2@ntfy.example.invalid/t{}'
run "$REPO_ROOT/bin/dejima-notify-watch" --once
[ "$status" -ne 0 ] || { echo "a bad URL was accepted: $output"; return 1; }
[[ "$output" != *hunter2* ]] \
|| { echo "the credential was echoed back: $output"; return 1; }
[[ "$output" == *"ntfy_url"* ]] || { echo "$output"; return 1; }
[ -z "$(curl_log)" ]
}
@test "a config value curl would read as an option is refused, not passed on" {
# The url schema type requires an http(s) scheme, which "-K/path" is not -
# verified against the real curl, which would otherwise read "-K/path" as
@@ -1952,6 +2008,12 @@ expect_ttyd_argv() {
[ -z "${3:-}" ] || printf '%s\n' -t "tmuxPrefix=$3"
[ -z "${4:-}" ] || printf '%s\n' -t "focusOnTap=$4"
[ -z "${5:-}" ] || printf '%s\n' -t "buttonColors=$5"
# Always sent, unlike the four above: a browser has no theme to inherit,
# so this is not an option an operator turns on. Read out of the script
# rather than transcribed - this file asserts that ttyd is given a theme
# and that its values match the page (static.bats), not what they are.
printf '%s\n' -t "$(sed -n "/^theme_opt=(-t '/,/^}')$/p" \
"$REPO_ROOT/bin/dejima-ttyd" | sed "1s/^theme_opt=(-t '//; \$s/')$//")"
printf '%s\n' "$(readlink -f "$REPO_ROOT/bin")/dejima-attach"
}
# Sentinel on both sides: both are command substitutions, which strip trailing
@@ -2177,7 +2239,10 @@ PY
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -eq 0 ]
assert_ttyd_argv 12345
run grep -cx -- '-t' "$STUB_DIR/ttyd-argv"
# No fontSize reaches ttyd, rather than no -t at all: the theme is sent on
# every launch (a browser has no palette to inherit), so counting -t stopped
# measuring what this is about, which is that an unset option sends nothing.
run grep -c 'fontSize' "$STUB_DIR/ttyd-argv"
[ "$output" = "0" ]
# Set. Checked through the exact vector, not a substring: "fontSize=20
@@ -2706,7 +2771,7 @@ nginx_blocks() {
# bin/dejima registers $HOME/.claude/hooks/dejima-notify.sh per session,
# and ~/.claude/hooks is what gets mounted read-only into the container.
# Without this link every Notification and Stop is an exit-127.
# Without this link every notification hook is an exit-127.
[ -L "$FAKE_HOME/.claude/hooks/dejima-notify.sh" ]
[ "$(readlink -f "$FAKE_HOME/.claude/hooks/dejima-notify.sh")" \
= "$(readlink -f "$REPO_ROOT/hooks/dejima-notify.sh")" ]
@@ -3054,3 +3119,81 @@ remote_doc() {
[ "$status" -ne 0 ]
[ -z "$output" ]
}
@test "one session-finished push per stop, not a second one a minute later" {
# Claude Code raises Notification when it needs permission and when the
# prompt has sat idle. A dejima session runs with permission prompts off,
# so only the timer is left, and it expires shortly after the Stop it
# follows - two pushes on a phone for one event, the second carrying
# nothing the first had not. Debouncing cannot merge them: the watcher
# keys on run id AND event, deliberately, so waiting and done are separate
# signals whenever a harness can actually tell them apart.
stub_launch
write_remote_config
launch "$PROJ"
run jq -r '[.hooks[][].hooks[].command] | join(" ")' "$LAUNCH_DIR/settings-copy.json"
[[ "$output" == *'dejima-notify.sh done'* ]] \
|| { echo "the stop notification is gone entirely: $output"; return 1; }
[[ "$output" != *'dejima-notify.sh waiting'* ]] \
|| { echo "the idle timer is registered again: $output"; return 1; }
rm -rf "$PROJ"
}
@test "the watcher still consumes a waiting marker no hook registers" {
# Nothing writes one by default now, and that changes nothing here: the
# marker directory is bind-mounted read-write, so a session creates
# whatever names it likes. The enum is the boundary, not the registration,
# and dropping `waiting` from it would leave a marker that accumulates
# rather than one that is refused.
conf_with_ntfy
stub_curl
write_entry 0123456789abcdef '%1' 'work:2'
marker 0123456789abcdef waiting
run "$REPO_ROOT/bin/dejima-notify-watch" --once
[ "$status" -eq 0 ]
[ ! -e "$RUNTIME_DIR/dejima/notify/0123456789abcdef/waiting" ] \
|| { echo "the marker was left to accumulate"; return 1; }
assert_argv 'myproject needs input' 'Session is waiting for you.' \
'https://dejima.example.invalid/?arg=0123456789abcdef'
}
@test "the session picker starts on a clean screen, scrollback included" {
# Reopening it from the web toolbar is a full page load, and it drew on
# top of whatever the previous session had left on screen - rows
# interleaved with the tail of the session just left, which reads as a
# rendering fault rather than a new screen (#213). Invisible over SSH,
# where a fresh connection to the forced command starts on a blank one.
write_remote_config
stub_docker_run_id 0123456789abcdef
start_tmux
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1'
run script -qec "echo q | '$REPO_ROOT/bin/dejima-attach'" /dev/null
[ "$status" -eq 0 ]
[[ "$output" == *$'\033[2J'* ]] || { echo "the screen is not cleared"; return 1; }
# And the scrollback with it: without 3J a swipe up on a phone lands in
# the middle of the previous session's output.
[[ "$output" == *$'\033[3J'* ]] || { echo "the scrollback is left behind"; return 1; }
# Not on the machine interface. ttyd feeds run ids back in from --list,
# and these bytes in a log are the failure this is gated to avoid.
run script -qec "'$REPO_ROOT/bin/dejima-attach' --list" /dev/null
[ "$status" -eq 0 ]
[[ "$output" != *$'\033[2J'* ]] || { echo "--list cleared the screen"; return 1; }
}
@test "no terminal, no clear" {
# A pipe or a dumb terminal gets none of it. NO_COLOR does not suppress it
# though: that asks for no decoration, not for a front door drawn over the
# last session.
local out
out="$(bash -c 'source "$1"; tui_clear; echo AFTER' _ "$REPO_ROOT/bin/dejima-tui-lib.sh")"
[ "$out" = AFTER ] || { echo "a pipe got escape bytes: $(printf '%q' "$out")"; return 1; }
run script -qec "TERM=dumb bash -c 'source $REPO_ROOT/bin/dejima-tui-lib.sh; tui_clear; echo AFTER'" /dev/null
[[ "$output" != *$'\033['* ]] || { echo "a dumb terminal got escape bytes"; return 1; }
# NO_COLOR too, on a real terminal. It rides the same switch as the colour
# and the glyphs, which is the reading this file already committed to -
# the alternative is a picker answering to two ideas of "plain".
run script -qec "NO_COLOR=1 TERM=xterm bash -c 'source $REPO_ROOT/bin/dejima-tui-lib.sh; tui_clear; echo AFTER'" /dev/null
[[ "$output" != *$'\033['* ]] || { echo "NO_COLOR still got a screen clear"; return 1; }
}
+196 -19
View File
@@ -1,13 +1,16 @@
#!/bin/bash
# Every confined suite at once, for the case where the whole set is genuinely
# wanted - before opening a pull request, or after touching bin/dejima, which
# nearly every suite reads.
# The confined suites, in one of two lanes.
#
# The suites are separate files that share no state, which is what makes this
# safe: each writes into its own BATS_*_TMPDIR, and the two that bind a socket
# ask the kernel for a free port rather than naming one. Run sequentially they
# take about twenty-five minutes on ten cores; run together, about seven, which
# is the difference between checking the whole set and not bothering.
# test/run-confined.sh every confined suite - the pull-request gate
# test/run-confined.sh --changed only the suites the working diff calls for
#
# The suites are separate files that share no state, which is what makes running
# them together safe: each writes into its own BATS_*_TMPDIR, and the two that
# bind a socket ask the kernel for a free port rather than naming one. Within a
# file, bats' own --jobs parallelism is used too; it needs flock rather than GNU
# parallel, which is why --no-parallelize-across-files is passed alongside it
# (bats refuses --jobs without GNU parallel unless across-files is off, and this
# script is already doing the across-files half itself).
#
# CI does not use this. It runs the same suites as a matrix, one job each, so
# that a failure names the suite it came from without anyone reading a log -
@@ -26,22 +29,196 @@ cd "$root" || exit 1
# this repo is usually worked on.
PRIVILEGED=" runtime ci-runner "
suites=()
# Which suites a changed path calls for. This is the table .dejima.md used to
# carry as prose; it is here so that it runs, and so that an agent iterating
# cannot map a change to the wrong suite by hand.
#
# Anything that matches no rule selects every suite. A path nobody has mapped
# is a path nobody has decided about, and guessing "none" there is how a change
# ships having been checked by nothing.
suites_for() {
case "$1" in
# Read by nearly every other suite, so a change to one is the whole set.
bin/dejima|bin/dejima-config-lib.sh) echo ALL ;;
bin/dejima-audit) echo audit ;;
bin/dejima-update-check) echo config ;;
bin/dejima-epitaph) echo config ;;
bin/dejima-tty-suspend) echo static ;;
bin/dejima-color-lib.sh) echo 'remote project' ;;
bin/dejima-tui-lib.sh) echo 'remote project' ;;
bin/dejima-conversation-ship) echo conversation ;;
bin/dejima-share) echo share ;;
# The slug scheme keys the transcript mounts, the epitaph and audit
# stores, and the share layout, so a change to it is not one area's.
bin/dejima-slug-lib.sh) echo 'share conversation audit static' ;;
bin/dejima-credential-proxy|docker/credential-proxy-forwarder.js) echo proxy ;;
bin/dejima-project-lib.sh|bin/dejima-project|bin/dejima-dep|bin/dejima-allow) echo project ;;
bin/dejima-attach|bin/dejima-ttyd|bin/dejima-notify-watch) echo remote ;;
web/*|systemd/*) echo remote ;;
bin/dejima-provider-lib.sh|bin/dejima-harness-lib.sh) echo providers ;;
bin/harness/*) echo providers ;;
forges/*|workflows/*|harnesses/*|tools/*) echo providers ;;
install.sh) echo config ;;
# static.bats asserts on files it does not own, so a shipped doc or a
# docker/ file needs it as well as whatever else the change touched -
# which for docker/ is most things, since the entrypoint and compose
# file are read by nearly every suite that builds a launch.
docker/*) echo ALL ;;
hooks/*) echo 'static config providers project' ;;
docs/*|README.md|*.md) echo static ;;
.gitea/*|.shellcheckrc) echo static ;;
.dejima/*) echo 'static config project providers' ;;
test/*.bats) local n="${1##*/}"; echo "${n%.bats}" ;;
test/*) echo static ;;
*) echo ALL ;;
esac
}
lane=all
jobs=""
base=""
list_only=0
given_paths=()
while [ "$#" -gt 0 ]; do
case "$1" in
--changed) lane=changed ;;
--base) shift; base="${1:-}" ;;
--list) list_only=1 ;;
-j|--jobs) shift; jobs="${1:-}" ;;
-h|--help)
sed -n '2,5p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
printf ' --list print the selection and stop\n'
printf ' --jobs N tests to run at once within each suite\n'
printf ' --base REF what --changed diffs against\n'
exit 0 ;;
-*) echo "unknown argument: $1" >&2; exit 2 ;;
# A path given here stands in for one git would have reported, so the
# map can be asked what it does with a path without the answer
# depending on what happens to be uncommitted.
*) given_paths+=("$1") ;;
esac
shift
done
if [ "$lane" != changed ] && [ "${#given_paths[@]}" -gt 0 ]; then
echo "paths only mean something with --changed: ${given_paths[*]}" >&2
exit 2
fi
all_suites=()
for f in test/*.bats; do
[ -f "$f" ] || continue
name="$(basename "$f" .bats)"
case "$PRIVILEGED" in *" $name "*) continue ;; esac
suites+=("$name")
all_suites+=("$name")
done
[ "${#suites[@]}" -gt 0 ] || { echo "found no confined suites under test/" >&2; exit 1; }
[ "${#all_suites[@]}" -gt 0 ] || { echo "found no confined suites under test/" >&2; exit 1; }
# Within-file parallelism is bats' own, and it needs flock. Without it bats
# exits before running anything, so the fallback is stated rather than left to
# look like a broken suite.
if [ -z "$jobs" ]; then
if command -v flock >/dev/null 2>&1; then
jobs="$(nproc 2>/dev/null || echo 4)"
else
jobs=1
fi
fi
bats_flags=()
# Deliberately more concurrent tests than cores when the whole set runs: these
# tests spend their time forking rather than computing, so oversubscribing
# measured faster and levelled off rather than degrading.
[ "$jobs" -gt 1 ] && bats_flags=(--jobs "$jobs" --no-parallelize-across-files)
selected=()
changed_paths=()
if [ "$lane" = changed ]; then
if [ "${#given_paths[@]}" -gt 0 ]; then
changed_paths=("${given_paths[@]}")
else
if [ -z "$base" ]; then
for candidate in origin/development development origin/main main; do
if git rev-parse --verify --quiet "$candidate" >/dev/null; then
base="$candidate"
break
fi
done
fi
# Committed work plus what is still in the working tree, including
# files git has never seen: iteration happens before a commit, and a
# lane that only saw committed changes would report on the wrong thing.
mapfile -t changed_paths < <(
{
[ -z "$base" ] || git diff --name-only \
"$(git merge-base "$base" HEAD 2>/dev/null || echo "$base")" 2>/dev/null
git diff --name-only HEAD 2>/dev/null
git ls-files --others --exclude-standard 2>/dev/null
} | sort -u
)
fi
wanted=" "
for p in "${changed_paths[@]}"; do
[ -n "$p" ] || continue
for s in $(suites_for "$p"); do
[ "$s" != ALL ] || { wanted=ALL; break 2; }
case "$wanted" in *" $s "*) ;; *) wanted="$wanted$s " ;; esac
done
done
if [ "$wanted" = ALL ]; then
selected=("${all_suites[@]}")
else
for s in "${all_suites[@]}"; do
case "$wanted" in *" $s "*) selected+=("$s") ;; esac
done
fi
else
selected=("${all_suites[@]}")
fi
skipped=()
for s in "${all_suites[@]}"; do
for t in ${selected[@]+"${selected[@]}"}; do [ "$t" = "$s" ] && continue 2; done
skipped+=("$s")
done
# Which lane ran, before anything else, because the whole point of having two
# is that a run can be read back later without guessing which one it was. To
# stderr, so --list's stdout is the suite names and nothing else.
{
if [ "$lane" = changed ]; then
printf 'lane: CHANGED - %d of %d confined suites, from %d changed path(s)\n' \
"${#selected[@]}" "${#all_suites[@]}" "${#changed_paths[@]}"
[ "${#skipped[@]}" -eq 0 ] || printf ' not run: %s\n' "${skipped[*]}"
printf ' An iteration check. NOT the pull-request gate - for that run\n'
printf ' test/run-confined.sh with no arguments.\n'
else
printf 'lane: ALL - every confined suite (the pull-request gate)\n'
printf ' not run: runtime, ci-runner - they need Docker and a real runner.\n'
fi
[ "$list_only" = 1 ] || printf ' %d test(s) at a time within each suite\n' "$jobs"
printf '\n'
} >&2
if [ "$list_only" = 1 ]; then
[ "${#selected[@]}" -eq 0 ] || printf '%s\n' "${selected[@]}"
exit 0
fi
if [ "${#selected[@]}" -eq 0 ]; then
echo "nothing to run: no changed path maps to a confined suite"
exit 0
fi
# One process per suite. There are fewer suites than cores on anything this
# repo is developed on, and they are shell rather than CPU-bound, so a job
# limit would cost wall clock for no benefit.
out="$(mktemp -d)"
trap 'rm -rf "$out"' EXIT
for s in "${suites[@]}"; do
( bats "test/$s.bats" > "$out/$s" 2>&1; printf '%s' "$?" > "$out/$s.status" ) &
for s in "${selected[@]}"; do
( bats ${bats_flags[@]+"${bats_flags[@]}"} "test/$s.bats" > "$out/$s" 2>&1
printf '%s' "$?" > "$out/$s.status" ) &
done
wait
@@ -51,7 +228,7 @@ wait
# that as "0 failures" is how a green board comes to mean nothing.
failed=0
printf '%-16s %6s %8s\n' suite ran failed
for s in "${suites[@]}"; do
for s in "${selected[@]}"; do
ran="$(grep -cE '^(ok|not ok)' "$out/$s")"
bad="$(grep -cE '^not ok' "$out/$s")"
status="$(cat "$out/$s.status" 2>/dev/null || echo 1)"
@@ -69,7 +246,7 @@ done
if [ "$failed" -ne 0 ]; then
echo
for s in "${suites[@]}"; do
for s in "${selected[@]}"; do
ran="$(grep -cE '^(ok|not ok)' "$out/$s")"
bad="$(grep -cE '^not ok' "$out/$s")"
{ [ "$bad" -gt 0 ] || [ "$ran" -eq 0 ]; } || continue
@@ -77,7 +254,7 @@ if [ "$failed" -ne 0 ]; then
if [ "$ran" -eq 0 ]; then
head -20 "$out/$s"
else
grep -E '^not ok' -A 6 "$out/$s"
grep -E '^not ok' -A 20 "$out/$s"
fi
echo
done
+73 -18
View File
@@ -49,7 +49,10 @@ compose_cap_add() {
' "$BATS_TEST_DIRNAME/../docker/docker-compose.yml"
}
IMAGE=dejima:latest
# localhost/ for the same reason bin/dejima uses it: a bare name is a Docker
# Hub name to anything that pushes, and nothing built here is meant to leave
# the host (#53).
IMAGE=localhost/dejima:latest
# What a session actually runs: the base image plus the selected harness's own
# layer, built in setup_file. Exported there because bats re-runs this file's
@@ -75,6 +78,29 @@ network_name() { echo "dejima-test-net-$(basename "$BATS_RUN_TMPDIR")"; }
# long-lived runner, until someone clears it by hand.
test_container_name() { echo "dejima-test-$1-$(basename "$BATS_RUN_TMPDIR")"; }
# The Anthropic arrangement, in one place.
#
# dejima-credential-proxy defaults none of this any more (#171): which upstream,
# which credential kind, which header and what shape are all the caller's to
# state, because a default is one provider's arrangement wearing every
# provider's name. Three copies of the same eight flags in this file would be
# three places for them to drift from what bin/dejima actually launches, so
# there is one - and static.bats pins that bin/dejima still passes the same set.
#
# Sets PROXY_PID, which every caller's teardown already kills.
start_credential_proxy() { # <socket> <credentials> <upstream> <oauth-endpoint>
"$BATS_TEST_DIRNAME/../bin/dejima-credential-proxy" \
--socket "$1" --credentials "$2" --upstream "$3" \
--credential-kind anthropic-oauth \
--oauth-endpoint "$4" \
--oauth-client-id "9d1c250a-e61b-44d9-88ed-5944d1962f5e" \
--auth-header Authorization \
--auth-format "Bearer {token}" \
--strip-header x-api-key \
--ensure-header "anthropic-beta=oauth-2025-04-20" &
PROXY_PID=$!
}
# 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
@@ -169,7 +195,7 @@ harness_manifest_value() {
# teardown_file's sweep finds it. Prints the new tag.
build_harness_layer() {
local base="$1" tag df ctx
tag="dejima-test-harness:$(basename "$BATS_RUN_TMPDIR")"
tag="localhost/dejima-test-harness:$(basename "$BATS_RUN_TMPDIR")"
df="$(mktemp)"
# An empty build context, for the reason bin/dejima uses one: a build
# whose context is a directory anything else can write is a way into the
@@ -321,6 +347,37 @@ setup_file() {
done < <(docker ps -a --filter name=^dejima-test- --filter status=exited \
--filter status=created --format '{{.Names}}' 2>/dev/null)
# And the images this suite builds - harness, override, derived - which
# nothing reaps across runs (#26). The per-test teardown removes only the
# CURRENT run's suffix, so an abandoned run's images have no reaper at all.
# They hold no subnet, so this is disk rather than the network's kind of
# harm; it is also the largest thing here by disk, since each is built
# FROM the session image.
#
# Same age guard as above, for the same reason: a concurrent run's images
# are minutes old. The prefix is checked again on the name rather than
# trusted to the CLI filter, so the reap can only ever reach this suite's
# own namespace.
local _img _born _suffix
_suffix="$(basename "$BATS_RUN_TMPDIR")"
while IFS= read -r _img; do
[ -n "$_img" ] || continue
# Both spellings, because the images this suite builds were renamed to
# localhost/ (#53) after this reap was written against the bare form,
# and for two releases it therefore matched nothing. A reap whose
# pattern stops matching is not a reap that fails - it is one that
# silently does nothing, which is indistinguishable from having
# nothing to do, so nothing anywhere reported it.
#
# Matching both is also what stops the next rename doing it again.
case "$_img" in dejima-test-*|localhost/dejima-test-*) ;; *) continue ;; esac
case "$_img" in *":$_suffix") continue ;; esac
_born="$(docker image inspect -f '{{.Created}}' "$_img" 2>/dev/null)" || continue
_born="$(date -d "$_born" +%s 2>/dev/null)" || continue
[ "$(( $(date +%s) - _born ))" -gt "$REAP_AFTER" ] || continue
docker rmi -f "$_img" >/dev/null 2>&1 || true
done < <(docker images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null)
docker network create "$network" >/dev/null
# No DEJIMA_FORGE_HOSTS/LOKI_URL: this suite only asserts the default
@@ -407,7 +464,7 @@ teardown() {
docker rm -f "$c" >/dev/null 2>&1 || true
done < <(docker ps -a --filter "name=^dejima-test-.*$suffix\$" \
--format '{{.Names}}' 2>/dev/null)
docker images -q "dejima-test-*:$suffix" 2>/dev/null \
docker images -q "localhost/dejima-test-*:$suffix" 2>/dev/null \
| xargs -r docker rmi -f >/dev/null 2>&1 || true
}
@@ -420,7 +477,7 @@ dexec_node() {
}
@test "the container's user actually matches overridden SANDBOX_USER/UID/GID/HOME build args, not just the defaults" {
local test_image="dejima-test-override:$(basename "$BATS_RUN_TMPDIR")"
local test_image="localhost/dejima-test-override:$(basename "$BATS_RUN_TMPDIR")"
local test_container="$(test_container_name override)"
local sandbox_dir="$BATS_TEST_DIRNAME/../docker"
local repo_root="$BATS_TEST_DIRNAME/.."
@@ -1256,17 +1313,16 @@ EOF'
cat > "$TD/credentials.json" <<EOF
{"claudeAiOauth":{"accessToken":"real-access-token","refreshToken":"r","expiresAt":$((($(date +%s) + 7200) * 1000)),"refreshTokenExpiresAt":$((($(date +%s) + 86400) * 1000)),"scopes":[],"subscriptionType":"max","rateLimitTier":"default"}}
EOF
"$BATS_TEST_DIRNAME/../bin/dejima-anthropic-proxy" \
--socket "$TD/proxy.sock" --credentials "$TD/credentials.json" \
--upstream "http://127.0.0.1:$(cat "$TD/port")" &
PROXY_PID=$!
start_credential_proxy "$TD/proxy.sock" "$TD/credentials.json" \
"http://127.0.0.1:$(cat "$TD/port")" \
"http://127.0.0.1:$(cat "$TD/port")/v1/oauth/token"
for _ in $(seq 1 50); do [ -S "$TD/proxy.sock" ] && break; sleep 0.1; done
# UnixStreamServer binds at umask default, so the socket lands 755, and
# connecting to a unix socket needs *write* permission on it. In the real
# flow the connecting uid is the one that bound it, so that is fine;
# under CI it is root binding and node connecting, and the whole chain
# this test follows fails at its first hop. Widened rather than left to
# dejima-anthropic-proxy, which has no such problem in production.
# dejima-credential-proxy, which has no such problem in production.
# Unconditional: if the loop above timed out, "No such file" here says so
# more clearly than a test that fails on a bare false condition.
chmod 666 "$TD/proxy.sock"
@@ -1478,7 +1534,7 @@ EOF
}
@test "a derived project image gets the package and keeps the base privilege posture" {
local derived="dejima-test-derived:$(basename "$BATS_RUN_TMPDIR")"
local derived="localhost/dejima-test-derived:$(basename "$BATS_RUN_TMPDIR")"
local test_container="$(test_container_name derived)"
local ctx; ctx="$(mktemp -d)"
local store; store="$(mktemp -d)"
@@ -1535,13 +1591,13 @@ EOF
@test "live end-to-end: claude -p through the real proxy (RUN_LIVE_API=1 only)" {
[ "${RUN_LIVE_API:-0}" = "1" ] || skip "set RUN_LIVE_API=1 to hit the real API"
TD="$BATS_TEST_TMPDIR"
"$BATS_TEST_DIRNAME/../bin/dejima-anthropic-proxy" \
--socket "$TD/proxy.sock" --credentials "$HOME/.claude/.credentials.json" &
PROXY_PID=$!
start_credential_proxy "$TD/proxy.sock" "$HOME/.claude/.credentials.json" \
"https://api.anthropic.com" \
"https://platform.claude.com/v1/oauth/token"
for _ in $(seq 1 50); do [ -S "$TD/proxy.sock" ] && break; sleep 0.1; done
SANDBOX_ANTHROPIC_PROXY_SOCKET="$TD/proxy.sock" SANDBOX_ANTHROPIC_PROXY_PORT=18399 \
SANDBOX_ANTHROPIC_TRANSPORT=loopback \
node "$BATS_TEST_DIRNAME/../docker/anthropic-proxy-forwarder.js" &
node "$BATS_TEST_DIRNAME/../docker/credential-proxy-forwarder.js" &
FWD_PID=$!
sleep 0.5
run env ANTHROPIC_BASE_URL=http://127.0.0.1:18399 \
@@ -1630,10 +1686,9 @@ s.serve_forever()' "$bind_addr" > "$port_file" 2>/dev/null &
cat > "$TD/credentials.json" <<EOF
{"claudeAiOauth":{"accessToken":"real-access-token","refreshToken":"r","expiresAt":$((($(date +%s) + 7200) * 1000)),"refreshTokenExpiresAt":$((($(date +%s) + 86400) * 1000)),"scopes":[],"subscriptionType":"max","rateLimitTier":"default"}}
EOF
"$BATS_TEST_DIRNAME/../bin/dejima-anthropic-proxy" \
--socket "$TD/proxy.sock" --credentials "$TD/credentials.json" \
--upstream "http://127.0.0.1:$(cat "$TD/port")" &
PROXY_PID=$!
start_credential_proxy "$TD/proxy.sock" "$TD/credentials.json" \
"http://127.0.0.1:$(cat "$TD/port")" \
"http://127.0.0.1:$(cat "$TD/port")/v1/oauth/token"
for _ in $(seq 1 50); do [ -S "$TD/proxy.sock" ] && break; sleep 0.1; done
# See the loopback test above for why this chmod is here.
chmod 666 "$TD/proxy.sock"
+255
View File
@@ -0,0 +1,255 @@
#!/usr/bin/env bats
# The per-project share directory: what a launch mounts at ~/shared, and the
# host-side CLI that says where it is.
#
# `[core] share_dir` used to be mounted whole, so every session saw every other
# session's handoff files, across projects. What is asserted here is that the
# mount is now one project's subdirectory, that an operator can still find it,
# and that the files left over from the old layout are neither hidden silently
# nor moved without being asked about.
setup() {
REPO_ROOT="$BATS_TEST_DIRNAME/.."
LIB="$REPO_ROOT/bin/dejima-provider-lib.sh"
SLUG_LIB="$REPO_ROOT/bin/dejima-slug-lib.sh"
HOST="$(mktemp -d)"
CONFIG_DIR="$HOST/config"
SHARE="$HOST/share"
WS="$HOST/ws"
mkdir -p "$CONFIG_DIR" "$SHARE" "$WS" "$HOST/home/.claude"
echo '{}' > "$HOST/home/.claude/settings.json"
KEY="$HOST/sandbox-key"
: > "$KEY"
chmod 600 "$KEY"
export DEJIMA_CONFIG_DIR="$CONFIG_DIR"
# A dejima session exports several of these into every shell it starts, so
# a test that meant "unset" by not setting them would be reading the
# invoking environment rather than stating its own precondition.
unset SANDBOX_SHARE_DIR SANDBOX_CMD SANDBOX_EXTRA_MOUNTS LOKI_URL \
ENABLE_FIREWALL ALLOWED_DOMAINS
}
teardown() {
rm -rf "$HOST" ${STUB_DIR:+"$STUB_DIR"}
}
write_config() {
{
printf '[core]\nharness = claude-code\nforge = none\nworkflow = local-only\n'
printf 'update_check = off\nssh_key = %s\nshare_dir = %s\n' "$KEY" "$SHARE"
[ "$#" -eq 0 ] || printf '%s\n' "$@"
} > "$CONFIG_DIR/config"
}
slug() { bash -c 'source "$1"; ws_slug "$2"' _ "$SLUG_LIB" "$1"; }
share() { "$REPO_ROOT/bin/dejima-share" "$@"; }
# Drives the real launch with a stubbed docker, and records what
# SANDBOX_SHARE_DIR was when compose was invoked. Reading --print-config
# instead would pass against a version that resolved the path correctly and
# then mounted something else.
launch() {
STUB_DIR="$(mktemp -d)"
cat > "$STUB_DIR/docker" <<'EOF'
#!/bin/sh
printf '%s\n' "${SANDBOX_SHARE_DIR-}" >> "$SANDBOX_TEST_SHARE_LOG"
exit 0
EOF
chmod +x "$STUB_DIR/docker"
SHARE_LOG="$HOST/share-log"
: > "$SHARE_LOG"
HOME="$HOST/home" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
SANDBOX_TEST_SHARE_LOG="$SHARE_LOG" \
run "$REPO_ROOT/bin/dejima" "${1:-$WS}"
}
# What the launch actually handed compose, deduplicated - compose is invoked
# more than once per launch and every call must agree.
mounted_share() { sort -u "$SHARE_LOG" | grep -v '^$' | head -1; }
@test "a launch mounts one project's subdirectory, not share_dir itself" {
write_config
launch
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$(mounted_share)" = "$SHARE/projects/$(slug "$WS")" ] \
|| { echo "mounted '$(mounted_share)'"; return 1; }
[ -d "$SHARE/projects/$(slug "$WS")" ] \
|| { echo "the launch did not create the directory it mounts"; return 1; }
}
@test "two projects get two directories" {
local other="$HOST/other"
mkdir -p "$other"
write_config
launch "$WS"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
local first="$(mounted_share)"
launch "$other"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$first" != "$(mounted_share)" ] \
|| { echo "both projects mounted $first"; return 1; }
}
@test "dejima-share path prints the directory a launch mounts, and creates it" {
write_config
run share path "$WS"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ -d "$output" ] || { echo "path did not create $output"; return 1; }
local printed="$output"
launch
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
# The point of the shared layout helpers: an operator dropping a file where
# `path` says would otherwise be dropping it where nothing reads it.
[ "$printed" = "$(mounted_share)" ] \
|| { echo "path said $printed, the launch mounted $(mounted_share)"; return 1; }
}
@test "dejima-share path defaults to the current directory" {
write_config
cd "$WS"
run share path
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$output" = "$SHARE/projects/$(slug "$WS")" ] || { echo "$output"; return 1; }
}
@test "dejima-share path refuses a directory that does not exist" {
# It would otherwise answer a typo with a share directory nothing will
# ever mount, which is worse than refusing: the file goes somewhere real
# and no session sees it.
write_config
run share path "$HOST/no-such-project"
[ "$status" -ne 0 ]
[[ "$output" == *"no such directory"* ]] || { echo "$output"; return 1; }
}
@test "a launch says which files the split left behind, every time until they are gone" {
write_config
echo secret > "$SHARE/left-over.txt"
launch
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"left-over.txt"* ]] \
|| { echo "the launch hid a file it stopped mounting: $output"; return 1; }
[[ "$output" == *"dejima-share adopt"* ]] \
|| { echo "it does not say what to do about it: $output"; return 1; }
# Said again rather than once: a file handed to a session yesterday and
# silently not arriving today is the failure this exists against.
launch
[[ "$output" == *"left-over.txt"* ]] || { echo "$output"; return 1; }
rm "$SHARE/left-over.txt"
launch
[[ "$output" != *"no session can see"* ]] \
|| { echo "still warning with nothing left over: $output"; return 1; }
}
@test "the per-project root is not itself reported as left behind" {
write_config
launch
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" != *"no session can see"* ]] \
|| { echo "a clean share_dir warned about its own projects dir: $output"; return 1; }
}
@test "dejima-share list names each project and flags what is loose" {
write_config
share path "$WS" > /dev/null
echo x > "$SHARE/loose-file"
run share list
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"$(slug "$WS")"* ]] || { echo "$output"; return 1; }
[[ "$output" == *"loose-file"* ]] || { echo "$output"; return 1; }
# The retention story is the only one there is: nothing prunes this.
[[ "$output" == *"Nothing prunes this"* ]] || { echo "$output"; return 1; }
}
@test "adopt moves nothing without a yes" {
write_config
echo keep > "$SHARE/left-over.txt"
run bash -c 'printf "n\n" | "$1" adopt "$2"' _ "$REPO_ROOT/bin/dejima-share" "$WS"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ -f "$SHARE/left-over.txt" ] || { echo "declining still moved it"; return 1; }
# And an empty answer is a no, not a prompt loop or a yes.
run bash -c 'printf "\n" | "$1" adopt "$2"' _ "$REPO_ROOT/bin/dejima-share" "$WS"
[ -f "$SHARE/left-over.txt" ] || { echo "an empty answer moved it"; return 1; }
}
@test "adopt moves what is loose into one project's share" {
write_config
echo one > "$SHARE/a.txt"
mkdir -p "$SHARE/adir"
echo two > "$SHARE/adir/b.txt"
run bash -c 'printf "y\n" | "$1" adopt "$2"' _ "$REPO_ROOT/bin/dejima-share" "$WS"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
local dest="$SHARE/projects/$(slug "$WS")"
[ -f "$dest/a.txt" ] || { echo "a.txt did not arrive"; return 1; }
[ -f "$dest/adir/b.txt" ] || { echo "the directory did not arrive whole"; return 1; }
[ ! -e "$SHARE/a.txt" ] || { echo "a.txt is still loose"; return 1; }
[ "$(cat "$dest/a.txt")" = one ] || { echo "contents changed"; return 1; }
}
@test "adopt never overwrites" {
# Both files are the operator's and this cannot know which one they meant,
# so the loose one stays loose and says so.
write_config
local dest="$SHARE/projects/$(slug "$WS")"
mkdir -p "$dest"
echo kept > "$dest/a.txt"
echo incoming > "$SHARE/a.txt"
run bash -c 'printf "y\n" | "$1" adopt "$2"' _ "$REPO_ROOT/bin/dejima-share" "$WS"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$(cat "$dest/a.txt")" = kept ] || { echo "it overwrote the destination"; return 1; }
[ -f "$SHARE/a.txt" ] || { echo "it removed the source it refused to move"; return 1; }
[[ "$output" == *"already has one"* ]] || { echo "$output"; return 1; }
}
@test "adopt says so when there is nothing loose" {
write_config
share path "$WS" > /dev/null
run share adopt "$WS"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"Nothing loose"* ]] || { echo "$output"; return 1; }
}
@test "a writable mount naming the share root is refused" {
# The ~/shared mount is per project only while nothing else hands the whole
# of it over. A session's own share arrives without going through
# [mounts] read_write at all, so refusing costs nothing here.
write_config '[mounts]' "read_write = $SHARE/projects"
mkdir -p "$SHARE/projects"
launch
[ "$status" -ne 0 ] || { echo "mounted every project's handoff files"; return 1; }
[[ "$output" == *"Refusing"* ]] || { echo "$output"; return 1; }
[[ "$output" == *"handoff"* ]] || { echo "the refusal does not say why: $output"; return 1; }
}
@test "a read-only mount naming the share root is refused too" {
write_config
mkdir -p "$SHARE/projects"
launch_ro() {
STUB_DIR="$(mktemp -d)"
printf '#!/bin/sh\nexit 0\n' > "$STUB_DIR/docker"
chmod +x "$STUB_DIR/docker"
HOME="$HOST/home" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
run "$REPO_ROOT/bin/dejima" --extra-readonly "$SHARE/projects" "$WS"
}
launch_ro
[ "$status" -ne 0 ] || { echo "read-only was enough to hand it over"; return 1; }
[[ "$output" == *"Refusing"* ]] || { echo "$output"; return 1; }
}
@test "the briefing tells the session the share directory is this project's" {
# The agent is told to leave things in ~/shared and to pick things up
# there. The path inside the container did not change, so what has to
# change is what it is told about who else can read it.
local out flat
out="$(bash -c 'source "$1"; briefing_compose none local-only claude-code' _ "$LIB")" \
|| { echo "$out"; return 1; }
[[ "$out" == *"~/shared"* ]] || { echo "the briefing lost ~/shared"; return 1; }
# Flattened, because the sentence wraps: a phrase spanning a line break
# matches nothing and the test fails on a reflow that changed no words.
flat="$(tr '\n' ' ' <<< "$out" | tr -s ' ')"
[[ "$flat" == *"this project's own"* ]] \
|| { echo "the briefing still reads as one directory for the host"; return 1; }
[[ "$flat" == *"every session against this project shares it"* ]] \
|| { echo "the briefing does not say who else can read it"; return 1; }
}
+968 -53
View File
File diff suppressed because it is too large Load Diff