Compare commits

...
227 Commits
Author SHA1 Message Date
branderson 31ffe8564b Merge pull request 'A session switcher that scales past a dozen sessions' (#405) from session-switcher into development
Reviewed-on: #405
Reviewed-by: branderson <brad@branderson.io>
2026-10-16 17:48:42 -07:00
branderson cf8b3ee20f Merge remote-tracking branch 'origin/development' into session-switcher
tests / confined (conversation) (pull_request_target) Successful in 1m19s
tests / confined (mirror) (pull_request_target) Successful in 1m18s
tests / confined (proxy) (pull_request_target) Successful in 1m31s
tests / confined (providers) (pull_request_target) Successful in 2m14s
tests / confined (launch) (pull_request_target) Successful in 2m38s
tests / confined (share) (pull_request_target) Successful in 2m26s
tests / runner-env (confined) (pull_request_target) Successful in 1m10s
tests / confined (shellcheck) (pull_request_target) Successful in 1m42s
tests / confined (audit) (pull_request_target) Successful in 4m23s
tests / confined (remote) (pull_request_target) Successful in 4m21s
tests / confined (project) (pull_request_target) Successful in 5m9s
tests / confined (static) (pull_request_target) Successful in 4m14s
tests / privileged (pull_request_target) Successful in 6m1s
tests / confined (config) (pull_request_target) Successful in 6m11s
tests / runner-env (privileged) (pull_request_target) Successful in 1m3s
2026-10-16 17:22:09 -07:00
branderson 91befe91f1 Merge pull request 'Count every state in one pass, and say where the states live' (#412) from attach-counts into session-switcher
tests / confined (mirror) (pull_request_target) Successful in 1m14s
tests / confined (conversation) (pull_request_target) Successful in 1m16s
tests / confined (proxy) (pull_request_target) Successful in 1m29s
tests / confined (providers) (pull_request_target) Successful in 2m14s
tests / confined (launch) (pull_request_target) Successful in 2m39s
tests / confined (share) (pull_request_target) Successful in 2m27s
tests / runner-env (confined) (pull_request_target) Successful in 1m11s
tests / confined (shellcheck) (pull_request_target) Successful in 1m43s
tests / confined (audit) (pull_request_target) Successful in 4m24s
tests / confined (remote) (pull_request_target) Successful in 4m24s
tests / confined (project) (pull_request_target) Successful in 5m11s
tests / confined (static) (pull_request_target) Successful in 4m19s
tests / privileged (pull_request_target) Successful in 6m12s
tests / confined (config) (pull_request_target) Successful in 6m14s
tests / runner-env (privileged) (pull_request_target) Successful in 1m5s
Reviewed-on: #412
Reviewed-by: branderson <brad@branderson.io>
2026-10-16 15:23:40 -07:00
branderson d8b687f4e4 Merge pull request 'Refuse an oversized body before inviting it' (#413) from proxy-expect-100 into development
Reviewed-on: #413
Reviewed-by: branderson <brad@branderson.io>
2026-10-16 14:54:52 -07:00
branderson ab74531b63 Call the state waiting, which is what everything else already called it
tests / confined (mirror) (pull_request_target) Successful in 1m16s
tests / confined (conversation) (pull_request_target) Successful in 1m21s
tests / confined (proxy) (pull_request_target) Successful in 1m29s
tests / confined (providers) (pull_request_target) Successful in 2m15s
tests / confined (launch) (pull_request_target) Successful in 2m38s
tests / confined (share) (pull_request_target) Successful in 2m27s
tests / runner-env (confined) (pull_request_target) Successful in 1m11s
tests / confined (shellcheck) (pull_request_target) Successful in 1m42s
tests / confined (audit) (pull_request_target) Successful in 4m26s
tests / confined (remote) (pull_request_target) Successful in 4m25s
tests / confined (static) (pull_request_target) Successful in 4m14s
tests / confined (project) (pull_request_target) Successful in 5m11s
tests / privileged (pull_request_target) Successful in 6m7s
tests / confined (config) (pull_request_target) Successful in 6m14s
tests / runner-env (privileged) (pull_request_target) Successful in 1m4s
The hook's event is `waiting`, the flag is --count-waiting, and the picker
said needs-you: three names for one thing, and the status line is where that
stops being an opinion - `waiting=1 working=0` is about as short as a status
can get and still say what it is.

One word now, in the picker as well as in --counts, so a number on a status
bar and a row on a screen still say the same thing. The fold from the two
harness events onto it is said once at each site that does it, because
`done|waiting) state=waiting` reads as a tautology without knowing that the
left side is an event name.
2026-10-16 13:03:12 -07:00
branderson 06018fdda4 Refuse an oversized body before inviting it
tests / confined (mirror) (pull_request_target) Successful in 1m17s
tests / confined (conversation) (pull_request_target) Successful in 1m18s
tests / confined (proxy) (pull_request_target) Successful in 1m30s
tests / confined (providers) (pull_request_target) Successful in 2m13s
tests / confined (launch) (pull_request_target) Successful in 2m37s
tests / confined (share) (pull_request_target) Successful in 2m25s
tests / runner-env (confined) (pull_request_target) Successful in 1m11s
tests / confined (shellcheck) (pull_request_target) Successful in 1m41s
tests / confined (remote) (pull_request_target) Successful in 3m12s
tests / confined (audit) (pull_request_target) Successful in 4m12s
tests / confined (project) (pull_request_target) Successful in 4m52s
tests / confined (static) (pull_request_target) Successful in 4m1s
tests / confined (config) (pull_request_target) Successful in 5m57s
tests / privileged (pull_request_target) Successful in 6m17s
tests / runner-env (privileged) (pull_request_target) Successful in 1m4s
BaseHTTPRequestHandler answers 100 Continue and returns True before do_* runs,
so the proxy invited the upload, read the declared length in _read_body,
refused it and closed mid-flight. Whether the client read the 413 or only the
100 was timing - a race the client cannot win and a test cannot pass
reliably, which is how it was found: an intermittently red proxy job on a pull
request that did not touch the proxy.

The cap itself was doing the thing that matters, in that nothing oversized was
read into host memory. What was wrong was the conversation.

The new test asks what the FIRST answer is and sends no body at all, which is
the question the race cannot reach: the one beside it passes either way when
the timing is kind.

Fixes #400
2026-10-16 11:28:17 -07:00
branderson 0e4717d7ed Count every state in one pass, and say where the states live
tests / runner-env (confined) (pull_request_target) Successful in 1m11s
tests / confined (shellcheck) (pull_request_target) Successful in 1m42s
tests / confined (static) (pull_request_target) Successful in 4m24s
tests / privileged (pull_request_target) Successful in 6m1s
tests / confined (config) (pull_request_target) Successful in 6m23s
tests / runner-env (privileged) (pull_request_target) Successful in 1m3s
tests / confined (share) (pull_request_target) Successful in 2m30s
tests / confined (audit) (pull_request_target) Successful in 4m29s
tests / confined (remote) (pull_request_target) Successful in 4m31s
tests / confined (project) (pull_request_target) Successful in 5m17s
tests / confined (mirror) (pull_request_target) Successful in 1m16s
tests / confined (conversation) (pull_request_target) Successful in 1m24s
tests / confined (proxy) (pull_request_target) Failing after 1m31s
tests / confined (providers) (pull_request_target) Successful in 2m18s
tests / confined (launch) (pull_request_target) Successful in 2m41s
A status bar wanting both numbers paid for two invocations of the one thing
here that runs on a timer. --counts answers for all of them in the pass the
counter already makes, in the picker's words, so a number on a status bar and
a row on a screen say the same thing about the same session.

--state-dir is for a reader that is not dejima: the directory is under
XDG_RUNTIME_DIR where there is one and under /tmp where there is not, so an
outside tool either asks or re-derives that.
2026-10-16 00:31:14 -07:00
branderson 7da90f51e1 Merge pull request 'Reach the picker on a new socket, not a new page' (#411) from web-reconnect into session-switcher
tests / confined (mirror) (pull_request_target) Successful in 1m15s
tests / confined (proxy) (pull_request_target) Successful in 1m30s
tests / confined (providers) (pull_request_target) Successful in 2m15s
tests / confined (conversation) (pull_request_target) Successful in 1m22s
tests / confined (launch) (pull_request_target) Successful in 2m42s
tests / confined (share) (pull_request_target) Successful in 2m29s
tests / confined (shellcheck) (pull_request_target) Successful in 1m46s
tests / runner-env (confined) (pull_request_target) Successful in 1m13s
tests / confined (remote) (pull_request_target) Successful in 4m34s
tests / confined (project) (pull_request_target) Successful in 5m21s
tests / privileged (pull_request_target) Successful in 6m0s
tests / confined (audit) (pull_request_target) Successful in 4m35s
tests / confined (static) (pull_request_target) Successful in 4m23s
tests / confined (config) (pull_request_target) Successful in 6m24s
tests / runner-env (privileged) (pull_request_target) Successful in 1m1s
Reviewed-on: #411
Reviewed-by: branderson <brad@branderson.io>
2026-10-16 00:03:51 -07:00
branderson 117622e144 Resolve Playwright where it is, and name the check in the release pass
tests / confined (conversation) (pull_request_target) Successful in 1m16s
tests / confined (mirror) (pull_request_target) Successful in 1m16s
tests / confined (proxy) (pull_request_target) Successful in 1m31s
tests / confined (providers) (pull_request_target) Successful in 2m17s
tests / confined (launch) (pull_request_target) Successful in 2m44s
tests / confined (share) (pull_request_target) Successful in 2m28s
tests / confined (shellcheck) (pull_request_target) Successful in 1m42s
tests / confined (audit) (pull_request_target) Successful in 4m22s
tests / confined (static) (pull_request_target) Successful in 4m13s
tests / confined (project) (pull_request_target) Successful in 5m11s
tests / runner-env (privileged) (pull_request_target) Successful in 1m5s
tests / runner-env (confined) (pull_request_target) Successful in 1m9s
tests / confined (remote) (pull_request_target) Successful in 4m23s
tests / privileged (pull_request_target) Successful in 6m6s
tests / confined (config) (pull_request_target) Successful in 6m13s
The import was an absolute path into the dejima image, so the command the
README gives worked in a sandbox and nowhere else. It tries the module path
first and says what to install when neither answers.

Nothing runs this check on a schedule, which is the cost of it living outside
npm test: docs/release.md's manual list is where this project already parks
checks that need a machine CI does not have, so a release touching web/ names
it.
2026-10-15 23:17:58 -07:00
branderson a26c10e8c6 Reach the picker on a new socket, not a new page
tests / confined (mirror) (pull_request_target) Successful in 1m18s
tests / confined (proxy) (pull_request_target) Successful in 1m33s
tests / confined (conversation) (pull_request_target) Successful in 1m22s
tests / confined (providers) (pull_request_target) Successful in 2m20s
tests / confined (launch) (pull_request_target) Successful in 2m43s
tests / confined (share) (pull_request_target) Successful in 2m27s
tests / confined (audit) (pull_request_target) Successful in 4m24s
tests / runner-env (confined) (pull_request_target) Successful in 1m12s
tests / confined (shellcheck) (pull_request_target) Successful in 1m40s
tests / confined (remote) (pull_request_target) Successful in 4m21s
tests / confined (project) (pull_request_target) Successful in 5m14s
tests / confined (config) (pull_request_target) Successful in 6m14s
tests / privileged (pull_request_target) Successful in 6m39s
tests / runner-env (privileged) (pull_request_target) Successful in 1m5s
tests / confined (static) (pull_request_target) Successful in 4m18s
ttyd takes the run id as a WebSocket argument rather than as part of the page,
so pressing SESS only has to open a different socket. It reloaded the page
instead: half a megabyte of inlined terminal fetched, parsed and run again to
change one command, which on a phone was most of the wait between the key and
the picker. Measured in Chromium over loopback, where there is no network and
the CPU is a desktop's: 406ms before, 195ms after.

The old socket's handlers are detached before it is closed rather than a flag
being read inside them - onclose is asynchronous, so a handler left attached
can arrive after the replacement socket exists, null the field holding it, and
schedule a reconnect to the url that was just replaced.

The terminal is reset on the way, and the mode tracker with it: what the
socket carries away is the process, not the alternate screen or the mouse
reporting it left switched on in here.

web/test/browser/ is a mock ttyd and a Chromium check, outside npm test
because CI has no browser. It is what shows the page is neither fetched nor
re-run, which no unit test can see.
2026-10-15 22:16:27 -07:00
branderson 65caf4577b Say what a listing costs now, where the docs said one call per container
tests / confined (conversation) (pull_request_target) Successful in 1m15s
tests / confined (mirror) (pull_request_target) Successful in 1m39s
tests / confined (proxy) (pull_request_target) Successful in 1m48s
tests / confined (providers) (pull_request_target) Successful in 2m36s
tests / confined (launch) (pull_request_target) Successful in 2m51s
tests / confined (share) (pull_request_target) Successful in 2m22s
tests / confined (shellcheck) (pull_request_target) Successful in 1m57s
tests / confined (audit) (pull_request_target) Successful in 4m13s
tests / runner-env (confined) (pull_request_target) Successful in 1m9s
tests / confined (project) (pull_request_target) Successful in 5m3s
tests / confined (static) (pull_request_target) Successful in 4m4s
tests / privileged (pull_request_target) Successful in 6m17s
tests / runner-env (privileged) (pull_request_target) Successful in 1m3s
tests / confined (remote) (pull_request_target) Successful in 4m31s
tests / confined (config) (pull_request_target) Successful in 6m10s
2026-10-15 19:38:14 -07:00
branderson 21eeaa5bf0 Ask docker and tmux once each for a listing, not once per session
A listing of eighteen sessions took 2.3 seconds before the picker drew, and
the web front door reaches it through a full page load, so that is the wait
after the SESS key. Measured against an eighteen-session fixture with a stub
answering in 60ms, which is what a local daemon costs: 2259ms before, 468ms
after.

Three things, in the order they cost:

docker inspect ran once per container. It runs once for all of them, with both
values as JSON so that one line per container is a property rather than a
hope - a newline in an environment value is escaped rather than printed, and a
container that went away takes its own line with it instead of shifting the
rest.

tmux list-panes ran once per session, through a grep. It runs once per listing
and the membership test is a case.

container_for, session_level, state_color and age_short were each called in a
command substitution per row, which forks. They answer in a variable, the way
tui_pick already does.
2026-10-15 19:20:35 -07:00
branderson d279c93e13 Fit the name column to the terminal, and mark what it cuts
tests / confined (mirror) (pull_request_target) Successful in 1m16s
tests / confined (conversation) (pull_request_target) Successful in 1m24s
tests / confined (project) (pull_request_target) Successful in 5m16s
tests / confined (proxy) (pull_request_target) Successful in 1m32s
tests / confined (providers) (pull_request_target) Successful in 2m17s
tests / confined (launch) (pull_request_target) Successful in 2m41s
tests / runner-env (confined) (pull_request_target) Successful in 1m10s
tests / confined (share) (pull_request_target) Successful in 2m28s
tests / confined (shellcheck) (pull_request_target) Successful in 1m43s
tests / confined (audit) (pull_request_target) Successful in 4m27s
tests / confined (remote) (pull_request_target) Successful in 4m27s
tests / confined (static) (pull_request_target) Successful in 4m17s
tests / privileged (pull_request_target) Successful in 6m14s
tests / runner-env (privileged) (pull_request_target) Successful in 1m4s
tests / confined (config) (pull_request_target) Successful in 6m19s
A fixed width is wrong in both directions at once: a desktop has room for
every name and would cut them anyway, and a phone narrower than the guess
still overflows and wraps, which is the failure this layout exists to avoid.

The width is asked of the controlling terminal rather than of stdin, which is
where `tput cols` reads it and is a pipe wherever a choice is fed in rather
than typed; a pty nobody set a size on reports zero columns, which is an
unanswered question and not a width. A cut name ends in a `~`, in ASCII where
the rest of the glyphs are not, because this one sits inside a padded field and
bash measures a multi-byte character as its bytes wherever the locale is not
UTF-8 - which would take the state column two places left on that row alone.
2026-10-15 16:00:29 -07:00
branderson 439b9b2d8b Document the picker's three columns and the starting state
tests / confined (mirror) (pull_request_target) Successful in 1m15s
tests / confined (conversation) (pull_request_target) Successful in 1m19s
tests / confined (proxy) (pull_request_target) Successful in 1m32s
tests / confined (providers) (pull_request_target) Successful in 2m15s
tests / confined (launch) (pull_request_target) Successful in 2m43s
tests / confined (share) (pull_request_target) Successful in 2m28s
tests / runner-env (confined) (pull_request_target) Successful in 1m10s
tests / confined (shellcheck) (pull_request_target) Successful in 1m39s
tests / confined (static) (pull_request_target) Successful in 4m14s
tests / confined (audit) (pull_request_target) Successful in 4m26s
tests / confined (remote) (pull_request_target) Successful in 4m23s
tests / confined (project) (pull_request_target) Successful in 5m9s
tests / privileged (pull_request_target) Successful in 6m10s
tests / confined (config) (pull_request_target) Successful in 6m12s
tests / runner-env (privileged) (pull_request_target) Successful in 1m4s
2026-10-15 15:30:42 -07:00
branderson 6dcefc127d Cut the picker's row down to what a phone can read
Five columns made a row about eighty characters wide. On a phone it wrapped,
and the wrapped half - the state and the age - landed under the NEXT session's
number, which is the opposite of what a numbered list is for.

Three columns now. The name comes first, against the index, because those two
are what an operator matches up. The tmux label and the container id stay in
--list: the label is a coordinate nobody has to act on now that picking a
session goes to it, and every container id shares a prefix with every other,
so a column of them said nothing. The one answer that column carried that was
not decoration - a session whose image is still building - moves into the
state, where it is read.

The age is minutes for an hour, then hours, then days: three characters, and
not a sum to do first.
2026-10-15 15:29:55 -07:00
branderson aff7f511e8 Stop an empty field shifting every column after it in the picker
A tab is IFS whitespace, so a run of them collapses into one delimiter. An
entry written before the name field existed has an empty one, and the picker
then read the tmux label as the name, the state as the age, and the container
id as the state - so a stale session was shown neither in red nor with the word
stale anywhere a reader would look for it.

Every session on a host that upgraded mid-flight has such an entry, which is
every session until it is relaunched.

The rows stay tab-separated, because that is what --list promises; the picker
converts them to a separator that is not whitespace before reading.
2026-10-15 15:25:37 -07:00
branderson 02159d95e2 Merge pull request 'Mirror the organization profile repository, .github' (#410) from mirror-dotgithub into development
Reviewed-on: #410
Reviewed-by: branderson <brad@branderson.io>
2026-10-15 14:53:06 -07:00
branderson 51dc5ddf5c Mirror the organization profile repository, .github
tests / confined (conversation) (pull_request_target) Successful in 1m21s
tests / confined (mirror) (pull_request_target) Successful in 1m19s
tests / confined (proxy) (pull_request_target) Successful in 1m41s
tests / runner-env (confined) (pull_request_target) Successful in 1m10s
tests / confined (remote) (pull_request_target) Successful in 3m13s
tests / confined (audit) (pull_request_target) Successful in 4m18s
tests / confined (project) (pull_request_target) Successful in 5m3s
tests / confined (static) (pull_request_target) Successful in 3m40s
tests / confined (config) (pull_request_target) Successful in 6m10s
tests / runner-env (privileged) (pull_request_target) Successful in 1m3s
tests / confined (providers) (pull_request_target) Successful in 2m19s
tests / confined (launch) (pull_request_target) Successful in 2m42s
tests / confined (share) (pull_request_target) Successful in 2m27s
tests / confined (shellcheck) (pull_request_target) Successful in 1m40s
tests / privileged (pull_request_target) Successful in 6m3s
2026-10-15 14:31:03 -07:00
branderson 44ac1e6216 Default the session name to the project, and suffix a collision
tests / confined (launch) (pull_request_target) Successful in 2m46s
tests / confined (shellcheck) (pull_request_target) Successful in 1m41s
tests / confined (audit) (pull_request_target) Successful in 4m33s
tests / confined (remote) (pull_request_target) Successful in 4m29s
tests / confined (static) (pull_request_target) Successful in 4m19s
tests / privileged (pull_request_target) Successful in 5m56s
tests / runner-env (privileged) (pull_request_target) Successful in 59s
tests / confined (config) (pull_request_target) Successful in 6m44s
tests / confined (mirror) (pull_request_target) Successful in 1m19s
tests / confined (proxy) (pull_request_target) Successful in 1m36s
tests / confined (conversation) (pull_request_target) Successful in 1m29s
tests / confined (providers) (pull_request_target) Successful in 2m21s
tests / confined (share) (pull_request_target) Successful in 2m28s
tests / runner-env (confined) (pull_request_target) Successful in 1m12s
tests / confined (project) (pull_request_target) Successful in 5m20s
Most launches pass no --name, so the default is what this mostly is: the
project directory, which is what both front doors showed before.

A name already in use takes the first free -N rather than refusing the launch -
several sessions against one project is the case the name exists for, so
stopping over a collision would be a worse answer than two rows that agree.
The first free one, so a number a finished session gives back is used again
instead of the count climbing for the life of the host. Collisions come from
the live registry and the tmux window list, because each is blind to a case the
other sees, and this launch's own window is not a conflict with itself.

A derived name is cut down where an explicit one is refused. The difference is
who chose it: a directory was not named for this, so a launch that stopped
because a path was long would be refusing over something it can fix.
2026-10-15 14:23:40 -07:00
branderson fc34c165df Document the session name, the states and the live view
docs/remote-access.md's Attaching section is where the session list is
described, so the two columns that changed and the two new modes belong there.
The README gets the half that applies with remote access off: the tmux window
name.
2026-10-15 14:23:40 -07:00
branderson 5f13b4015b Add a live view and a count for the status line
--watch is the picker redrawn on a timer: which sessions are working and which
are waiting, in one window, with the same numbers to go to one. Deliberately
not a second tool - a monitor would grow its own list, its own columns and its
own idea of what a session is, and what an operator does after seeing which
session wants them is to go to it.

--count-waiting is the same question reduced to one number, for a tmux status
line that re-runs it every few seconds. It reads the registry, the pane list
and the levels and asks docker nothing: the point of the number is not having
to open anything, so it must not cost a round trip per container.

tui_pick grew an optional timeout rather than the caller growing its own read,
so the live list and the static one share one prompt and one range check.
2026-10-15 14:23:13 -07:00
branderson f99868fb7c Ask docker once per listing instead of once per session
container_for ran `docker ps` and then inspected containers one at a time
until it found its match, and it was called for every entry in the registry:
quadratic in the number of sessions, on the listing both front doors open on.
Twelve sessions cost twelve `docker ps` calls and up to a hundred and
forty-four inspects.

The map is built once and looked up. What keeps "no such container" apart from
"couldn't ask" is now a flag on the map rather than a local, with the same
reach: one unanswerable inspect anywhere makes every miss unverified, because
pruning on that answer unregisters a live session for good.
2026-10-15 14:23:13 -07:00
branderson f5f228f7ae Switch the client when the picker is already inside tmux
Everything in attach() is written for a client with no tmux of its own, a
phone or an ssh forced command, and tmux refuses to nest a session inside one
of its own panes - so on the operator's own machine the picker could list every
session and then reach none of them.

switch-client rather than select-window, so a sandbox launched from a different
tmux session is reachable too. Nothing to create, verify or reap on this path,
and the window landed on is the real one.
2026-10-15 14:23:13 -07:00
branderson 3eacfd26e8 Say in the listing which sessions want attention
Every row said "attachable", which is true of all of them. The column now
carries the state the session reported when there is one - needs-you, working -
and the reachability answer when there is not, because a session that cannot be
attached to has nothing useful to say about whether it wants attention and the
two sets of words do not overlap.

Oldest first, by start time and deliberately not by state: the numbers are how
a session is picked, so an order that followed the state would renumber the
rows between an operator reading one and typing it.
2026-10-15 14:23:13 -07:00
branderson 1a4ba0d192 Have a session report which state it is in, not only that it changed
The marker a session writes is consumed by the watcher that pushes it, so
after a notification there is nothing left to read - and with no watcher
running, nothing has read it at all. Neither front door can answer "which of
these sessions is waiting for me" from that.

The hook now also keeps one file in a level/ directory the host creates: the
state the session is in, overwritten rather than consumed. Still a name and no
content, still only ever about the session writing it. UserPromptSubmit
supplies the other half and pushes nothing, because the edge markers keep
their two-name enum.
2026-10-15 14:23:13 -07:00
branderson b12b303780 Name the tmux window the launch is sitting in
choose-window, the status line and the window list all show a window name, and
for a dozen sandboxes they showed a dozen copies of whatever automatic-rename
made of the container command. Nothing in tmux has to know dejima exists for
this to be the fix, which is why it is worth more than a picker column.

A window the operator named by hand is left alone: tmux turns automatic-rename
off on rename, so the option still being on is the evidence that the name is
tmux's guess. The option goes back on at exit, or the window keeps the name of
a session that has ended.
2026-10-15 14:23:12 -07:00
branderson 79853138fb Let a launch say what the session is for
Both front doors identify a session by the basename of its workspace, which
answers "which project" and nothing else. The case that needs answering is a
dozen sessions against one project, where every row in the picker reads the
same.

--name takes the operator's own words into the registry and the listing. The
picker spends the project's column on it rather than adding one, because that
column's width is what wraps a row on a phone.
2026-10-15 14:23:12 -07:00
branderson affb2ac903 Merge pull request 'Rewrite the mirrored history: no AI co-author trailers, no operator values' (#408) from mirror-strip-attribution into development
Reviewed-on: #408
Reviewed-by: branderson <brad@branderson.io>
2026-10-14 19:40:15 -07:00
branderson dfdc720434 Give the mirror's ssh alias one multiplexed connection on port 443
tests / confined (mirror) (pull_request_target) Successful in 1m13s
tests / confined (providers) (pull_request_target) Successful in 2m14s
tests / confined (launch) (pull_request_target) Successful in 2m34s
tests / runner-env (confined) (pull_request_target) Successful in 1m9s
tests / confined (conversation) (pull_request_target) Successful in 1m20s
tests / confined (proxy) (pull_request_target) Successful in 1m28s
tests / confined (share) (pull_request_target) Successful in 2m24s
tests / confined (shellcheck) (pull_request_target) Successful in 1m39s
tests / confined (audit) (pull_request_target) Successful in 4m41s
tests / confined (remote) (pull_request_target) Successful in 3m22s
tests / confined (project) (pull_request_target) Successful in 6m10s
tests / privileged (pull_request_target) Successful in 6m17s
tests / confined (static) (pull_request_target) Successful in 5m17s
tests / runner-env (privileged) (pull_request_target) Successful in 1m1s
tests / confined (config) (pull_request_target) Successful in 9m52s
2026-10-14 19:12:49 -07:00
branderson 34f8fb85c9 Record the mirror's rewritten history in the changelog 2026-10-14 18:58:25 -07:00
branderson 4395887fa9 Document the mirror's history rewrite, the scrub file and --reset 2026-10-14 18:58:19 -07:00
branderson 44a74ce187 Rewrite the mirrored history: no AI co-author trailers, no operator values
Commit messages lose Co-Authored-By trailers crediting Claude, so GitHub
lists no tool as a contributor, and the values in a host-side scrub file
are replaced in every file and message. The rewrite is deterministic, so
syncs still fast-forward; --reset replaces a mirror built before it.
2026-10-14 18:56:55 -07:00
branderson f6e2480d37 Merge pull request 'Release 2026.10.14' (#407) from release-2026.10.14 into main
Reviewed-on: #407
Reviewed-by: branderson <brad@branderson.io>
2026-10-14 18:31:47 -07:00
branderson ab362e8d75 Refuse a symlinked README in the mirror rewrite
perl -i reads through the link and writes the target back as a regular
file, which the mirror commit would then publish.
2026-10-14 18:03:44 -07:00
branderson a808447747 End perl's options before the forge's filenames in the mirror rewrite
A tracked file named like -eBEGIN{...} was read as code, which ran on
the host holding the GitHub key.
2026-10-14 18:02:37 -07:00
branderson c6afe46220 Say in the security doc why the mirror script is watched and copied 2026-10-14 18:02:02 -07:00
branderson 11b1d7f726 Watch mirror/ in the exit audit, and install the mirror script as a copy
The timer runs it on the host with a key that can push to every
repository in the GitHub organisation, and a session working in this
checkout can edit it.
2026-10-14 18:01:55 -07:00
branderson 4289bb59d8 Date the release 2026.10.14 and record the GitHub mirrors 2026-10-14 17:56:04 -07:00
branderson e8a80ed3e8 Merge pull request 'Mirror every dejima repository's main and release tags to GitHub' (#406) from github-mirror into development
Reviewed-on: #406
Reviewed-by: branderson <brad@branderson.io>
2026-10-14 17:49:24 -07:00
branderson 361a7413fe Treat a repository with no forge links as nothing to rewrite, not a failure 2026-10-14 16:57:07 -07:00
branderson 9586a464ea Document mirroring every repository in the organisation 2026-10-14 16:29:22 -07:00
branderson 3bdc5a664b Configure the mirror timer for every repository in the organisation 2026-10-14 16:29:13 -07:00
branderson f86ceb9441 Mirror every dejima repository, and rewrite forge URLs in code too
One run syncs dejima, dejima-badge and the three sister projects, each
in its own subshell so one failure does not stop the rest. The rewrite
now covers every text file, since clone URLs and systemd Documentation=
lines live in code, and Gitea's /raw/branch/ becomes GitHub's /raw/.
2026-10-14 16:29:04 -07:00
branderson 8b7b9cfff0 Insert the mirror notice once, so the transform is idempotent 2026-10-14 15:15:37 -07:00
branderson 6c5f652375 Check for the wordmark rewrite itself, not a word the comment also uses 2026-10-14 14:49:42 -07:00
branderson ae91766acc Test tag ancestry with an if, not an && under set -e 2026-10-14 14:31:18 -07:00
branderson cfc1b01b1f Document the GitHub mirror 2026-10-14 14:27:51 -07:00
branderson 078b0d814b Add a systemd user timer that runs the GitHub mirror hourly 2026-10-14 14:27:29 -07:00
branderson d8924693d5 Add a script that publishes main and its tags to a GitHub mirror
The mirror's main is one commit on top of the forge's, rewriting forge
repository links to dejima-org and making the README wordmark follow
the reader's colour scheme. --settings turns off issues, pull requests
and Actions on the GitHub repository.
2026-10-14 14:27:07 -07:00
branderson bd590edfea Accept either host in the sister-project link tests 2026-10-14 14:22:02 -07:00
branderson 1436bb2614 Merge pull request 'Release 2026.10.13' (#403) from release-2026.10.13 into main
Reviewed-on: #403
Reviewed-by: branderson <brad@branderson.io>
2026-10-13 22:37:25 -07:00
branderson f679e5d59f Count a later word in the merge guard only when it names a path
That is where the two layers divide: a bare `gh` anywhere in a segment is
reached by executing what PATH resolves, so the shadow sees it, while a path
names the real binary and the shadow never runs. Checking every word regardless
also blocked `echo gh pr merge 3`, which the agreement test names as the one
case the two layers must differ on.
2026-10-13 21:38:46 -07:00
branderson d41483e073 Record what the release review changed, and what it left
The merge-guard and gh api fixes restore what the entry already claims, so
neither is an operator-visible change against the previous main; --config-env
and the empty-key warning are. The merge endpoint goes under what this release
does not do, with its ticket.
2026-10-13 21:21:18 -07:00
branderson 6bf63c8325 Carry git's --config-env into the push guard's resolution
It sets config the way `-c` does, with the value read from the environment,
so a pushurl set through it has to reach the URL this resolves. The separated
spelling also takes the next word: left unrecognised, `remote.origin.pushurl=VAR`
was read as the subcommand, and a subcommand that is not `push` ends the
verdict having checked neither the host allowlist nor the blocked branch.

It is the last of git's global options that takes a separate value and was
neither skipped in pairs nor refused.
2026-10-13 21:19:40 -07:00
branderson 0936ac80ac Say when an empty ssh_key leaves the operator's agent signing
A key file that exists but is empty loads nothing, so the withdrawal that a
configured key triggers does not fire and the forwarded agent stays mounted.
With a pushing workflow and an agent running, every other signal agrees with
the arrangement the operator asked for: the key is configured, the launch
starts, pushes work - and they work because the personal identities are doing
them.
2026-10-13 21:17:42 -07:00
branderson f35556ee0b Hold the gh api brace exemption to an endpoint's {owner}/{repo}
gh substitutes those two from the checkout, so a path carrying them names this
repository by construction. Any other brace says nothing about what the call
reaches, and the exemption accepted one anywhere in argv: `-f name={x}` or
`--template '{{.login}}'` asserted the whole command was repository-scoped,
which left `gh api orgs/other/repos -X POST` and `gh api user` reachable.
2026-10-13 21:14:39 -07:00
branderson 5f595de0ab Read every word of a segment in the merge guard, not the first
A forge CLI named by absolute path behind a prefix word - `env`, `nohup`,
`xargs`, `timeout` - reaches the real binary without passing the PATH shadow.
A check on the segment's first word leaves that combination with no layer
looking at it, so `env /usr/local/lib/dejima/forge/gh pr merge 7` was allowed.

The test asserted the path-qualified form but not a prefixed one, which is why
it kept passing.
2026-10-13 21:09:26 -07:00
branderson 31e7b869c3 Date the release 2026.10.13
The heading has to match the tag on main, and the tag is the day the merge
lands. Nothing derives one from the other, so they are set by hand together.
2026-10-13 21:01:03 -07:00
branderson 9acc280e03 List the changelog in the README's docs table
The changelog exists to be found by an operator deciding whether to update,
and the README table is where someone looks first.
2026-10-13 20:58:43 -07:00
branderson 55046cf863 Merge pull request 'Release blockers: N1-N6 and the two older Highs from the development to main pass' (#399) from release-blockers into development
Reviewed-on: #399
Reviewed-by: branderson <brad@branderson.io>
2026-10-13 20:38:36 -07:00
branderson a88c994954 Cut the story from the same-day release note
Three sentences on which format was tried first, which test refused it, and
why the paragraph was written the way it was. One fact survives that: four
dot-separated numbers beginning with a year and a month read as a private
IPv4 address, which is why the suffix is a letter.
2026-10-13 17:30:14 -07:00
branderson 0ffdfa6da2 Correct what the changelog says this release does to an existing config
Three things an operator has to act on were missing from "what an existing
config stops being able to do", each new since main and each visible to a
session that worked before:

- a configured ssh_key now withdraws the forwarded agent from the container
  entirely, so a session that used the key for the forge and the agent for a
  second remote loses the second half;
- an empty ssh_key file counts as no key, so a pushing launch with one and no
  agent is refused where it used to start;
- under the github forge, gh api is held to this repository, which an agent
  using it for anything else will notice.

Two entries were wrong rather than incomplete. #386 did not fail every
launch: it failed when the ranges came from a cache file, which is how CI
supplies them, and an ordinary launch's URL always has a host. #391 is about
what dejima gives you without the sister projects around it, not without a
forge.

The clamp covering only the bridge gateway moves to a "Known limitations"
section. It is a thing this release does not do rather than something that
stops working, and mixing the two makes the list an operator must act on
longer than it is. #379 and #385 are test-suite reliability and fold into one
line: the changelog's own rule is that entries name what an operator would
notice.

And one clause in the threat model, which this release's own clamp change
made checkable: no port on the host is open *unless you named one*.
2026-10-12 23:21:33 -07:00
branderson 2bee0e8b70 Have --version use the version library, and name a same-day release
Two review nits.

--version reimplemented dejima_version inline because the library was not
loaded at that point. It is pure functions with no config and nothing behind
it, so sourcing just that one costs nothing and leaves one implementation
rather than a copy that can drift from what the banner and the update notice
print.

And docs/release.md says what a second release on one day is called, which
the format otherwise had no answer for: a letter, 2026.10.12b. `sort -V`
orders it after the bare date, which is what the update notice compares with.

A letter rather than a fourth dotted number, which was the first choice: four
dot-separated numbers starting with a year and a month read as a private IPv4
address, and the static test that keeps those out of the documentation
refused the example. Writing that collision down tripped the same test, which
is why the paragraph describes the shape instead of showing it.
2026-10-12 23:21:33 -07:00
branderson a822e19fd0 Run the mount guard over the second seed list instead of arguing it away
The pull request's case for the second load was that what a plugin adds
there is a path dejima made this run. True of the one plugin that does it,
and not a property of the mechanism: harness_seed runs again in full, so a
plugin deriving a seed from state prepare_config changed would reach the
mount assembly on that argument alone.

So the rule is applied again, over a list rebuilt after the hook and still
before anything is mounted. Two loops over lists that are already built,
which is cheaper than the argument it replaces.

Driven at the function, since no shipped plugin exercises it - with the
adjacency of the call asserted separately, because the behavioural half
would not notice the call being deleted. The first version of that assertion
used a two-line `grep -F` pattern, which reads as two alternatives and
matched either line, so it passed with the call removed.
2026-10-12 23:21:15 -07:00
branderson 449ba3023d Stop throwing away the stage directory's path before cleanup reads it
My own bug from two commits ago, and the comment on it and the changelog
entry were both false as merged. The block that initialises trap-owned names
to empty runs AFTER the mktemp in straight-line order, so it replaced the
real path with "" and cleanup() removed nothing. Every launch, on every
harness, left a dejima-harness-stage directory in TMPDIR - 372 of them on
this machine from the test runs alone.

The line is gone rather than moved. SANDBOX_SETTINGS_FILE and
SANDBOX_CLAUDE_JSON_FILE are mktemp'd in the same region and deliberately
not in that block, for the reason that applies here too: they are bound
above the trap, so there is no window in which cleanup() could see an
inherited value, and resetting them afterwards is the only way to break
them.

Nothing caught it because nothing looked after exit. A launch test does now,
with its own TMPDIR, for both harnesses - cline is the one that writes a file
into it, so a leak there carries content rather than being an empty
directory - and for the two staging paths that were never broken, which is
what this one should have looked like.
2026-10-12 23:21:14 -07:00
branderson 3cf12910f3 Configure an isolated cline session from the staging directory
Fixes #393, so --isolated is supported on every shipped harness and the
release does not have to carry an exception.

Cline reads its provider configuration from a JSON file inside its own data
directory, and dejima wrote that file onto the host and let the state mount
carry it in. An isolated launch has no state mount - so the file went to a
host directory keyed on the container-local working directory, shared by
every isolated cline launch, and was then not mounted. Cline started with no
model and fell back to its own hosted endpoint, which the firewall was never
asked to allow.

It is written into the per-run staging directory now and seeded in, so
entrypoint.sh installs a copy the session owns. Two things follow. It arrives
without a state mount, which is what makes the mode work for this harness.
And nothing the session writes to it reaches the host, which is what the
comment above that function already wanted - "a repointing cannot persist
into the next session" - and which writing into a mounted directory only
approximated by rewriting the file each launch.

An ordinary launch ends up where it did: that one has a state mount, so the
seeded copy lands inside it.

The existing cline tests read the file out of the state directory, which is
no longer where it is; their fixture passes the staging directory core now
passes and reads it from there. The test that asserted the refusal asserts
the configuration arrives and the host state directory is untouched, with an
ordinary launch beside it so neither can pass by nothing being mounted.
2026-10-12 19:24:44 -07:00
branderson 54f0dac269 Give the hook that produces per-run files somewhere to put them
harness_prepare_config exists to produce what a launch needs and throws away
afterwards, and it had exactly one place to write: the single settings-override
file core passes it. A harness whose configuration is a file inside its own
state directory had nowhere, so it wrote onto the host and relied on the state
mount to carry it in.

So it gets a per-run directory as well, made beside that file and removed by
cleanup() with every other staging path.

And core loads the seed list twice. The early load is what the mount guard
runs against - a guard after the mount it would refuse is not a guard - and
the second, after prepare_config, is what the mount assembly reads: a hook
whose job is to make a file has to run before the list that mounts it. The
loader resets rather than appends, so a plugin emitting the same entries in
both passes gets them once. What a plugin adds in the second pass is a path
dejima made this run rather than one an operator could have pointed a mount
at, which is why the guard's early pass is still the whole guard.
2026-10-12 19:24:44 -07:00
branderson 075fcd1def Test the version, the update notice and the changelog's own claims
One commit for three because the tests are one block in test/static.bats,
and the alternative was scattering them across the file so the history could
have them separately.

What they hold. The release comes from git and not from a file, driven over
every state a checkout can be in - untagged, on a tag, past a tag, a
non-release tag present, and no git at all - because "unreleased" versus a
date versus a date+N are three different things to tell an operator. The
notice names both releases and the changelog, reads the remote's tags through
the same hardened invocation as the branch tip, and asks for them only when
there is news. The changelog says which version is supported and points at
the binding statement, and has an Unreleased heading to write the next entry
into.

There is deliberately no sweep for words like "backport". The first version
of one failed on the changelog's own sentence saying there are no backports:
a pattern cannot tell a promise from its denial, and a test that matches the
shape of prose rather than its claim is one that gets edited until it passes.
2026-10-12 17:14:58 -07:00
branderson d111bf0aed Write down what each release changed, and how to cut one
CHANGELOG.md, newest first, for one question: should I update, and what will
I be getting. Entries name what an operator would notice; the pull requests
carry the detail and are linked.

The risk a changelog introduces is the expectation that comes with it - a
list of releases reads as a list of things one may be running - so the file
says at the top that no release here is supported and points at SECURITY.md,
which is where that is binding. SECURITY.md's own "Supported versions"
section now says where to see what changed, since that is the question
someone arrives there with.

The first entry is this release: 119 commits, PRs #360-#391 and #399. It has
a section for what an existing config stops being able to do, because two
things in this one refuse an arrangement that worked and one of them - a
host-resident Loki configured by name - fails silently. The clamp covering
only the bridge gateway is named there too, with the issue.

`## Unreleased` is where the next entry goes before its date is known, since
the date is the version and cannot be written before the merge is made.
docs/release.md §6 is the two steps that produce a release - tag `main`,
rename the heading - both easy to forget because nothing fails without them.
2026-10-12 17:14:58 -07:00
branderson 6aacfbd8dd Name the release an outdated checkout is missing, and the changelog
The update notice said a branch has commits this checkout does not, which
answers "is there something newer" and nothing else. An operator deciding
whether to update wants to know how far behind they are and what they would
be getting - which is what releases are for, so the notice names both sides
and points at CHANGELOG.md rather than leaving the file to be discovered.

Both sides even when they name the same release: a checkout on 2026.10.12
whose remote is also on 2026.10.12 is behind by commits not yet released, and
saying that is more honest than naming one version.

The remote's newest release comes from its tags, through the same hardened
invocation as the branch tip and for the same reason - the checkout's own git
config must not name a command this runs. Release-shaped tags only, and asked
for only when there is something to report, so an up-to-date checkout still
pays one request rather than two. The cached answer grew a third field; a
cache written before it existed reads as an unknown release, which is what a
failed lookup also gives.
2026-10-12 17:14:58 -07:00
branderson 53a448bc43 Say which release a checkout is on
A release is a date - 2026.10.12 - and the git tag of that name on `main` is
where it lives. Nothing in the tree records it, which is the point: a VERSION
file says what was released and goes on saying it while a checkout is pulled
past that release, and the one moment the answer matters is the moment it
would be wrong. `git describe` cannot drift from history, and it tells the
truth about a checkout between releases: 2026.10.12+37 is on no release.

A date rather than a version number because the useful fact about a release
is how old it is, and because MAJOR.MINOR would be a promise about
compatibility and patch lines that SECURITY.md explicitly does not make. The
tip of main is the only supported version; a tag is a record of what shipped.

Two surfaces: `dejima --version`, answerable with no config read for the same
reason --help is, and the release beside the wordmark on the startup banner,
where an operator is already looking. The --help line for it is earned by
covering both flags on one.

Four states, each a different thing to tell someone: the release, the release
plus a distance, `unreleased` for a checkout with no release tag yet, and
`unknown` for no git at all. Only tags that look like a release count, so a
marker pushed for something else cannot become the answer.

Tests for this and the two commits after it arrive together, two commits
along: all three sit in one block of test/static.bats, and splitting them
would mean scattering them across the file for the sake of the history.
2026-10-12 17:14:58 -07:00
branderson 6bf9a1b60f Decide which host candidates are the host in the filter, not the launcher
The round-1 fix compared a configured destination's address against
EGRESS_HOST_ADDR in the launcher. That looked equivalent to deciding
residency and was not: the variable is the operator's override for when the
policy's own derivation from the default route is wrong, so it is normally
unset - and with it unset nothing was ever derived.

Which means the clamp going first, which is the fix this branch exists for,
refused a host-resident Loki or self-served model at the gateway address on
any ordinary install. The model fails loudly, because the session cannot
reach it. The Loki case fails silently: the shipper is best-effort, so
network logging just stops.

Split the decision instead, because neither process knows both halves. The
launcher offers candidates it is sure of - an explicit port on a literal
address - as EGRESS_HOST_CANDIDATES. The policy, which works the host's
address out from its default route, keeps the port of each candidate whose
address is the host's. Neither resolves a name, which is what both sides were
already refusing to do.

The folding runs after discovery and before validation, so a candidate's
port is held to the same check as an operator's rather than trusted for
having crossed a process boundary - with its own refusal naming the variable
it came from.

What is still a behaviour change on upgrade: a host-resident service
configured by NAME needs EGRESS_HOST_PORTS set by hand, since a name is a
destination neither process will resolve to decide what to open. It fails
closed. docs/release.md now asks the release pull request to name that class
of change, because failing closed is quiet, and an observability path is the
worst case.

Also the review's nit: the summary line is why nothing can reach a
host-resident service, the session and the shipper beside it.
2026-10-12 01:57:33 -07:00
branderson cbdac96133 Drive the credential-mount check through a launch, not over the source
The P1 test greps the flattened `elif` for five substrings, which is better
than the line-number comparison it replaced and would still pass on a
restructuring that tested the same words in a different combination.

So it is driven now, the way the release pass reproduced it: a backend whose
credential kind is none - cline with a self-served endpoint, since no API
claude-code speaks has one - with a real-looking credential sitting on the
host, and the docker argv asserted to carry no mount of it. With the
arrangement the mount IS for asserted the other way, so the test cannot pass
by nothing ever being mounted.

The grep stays. It says which conditions the branch tests, which argv
cannot.
2026-10-11 18:57:02 -07:00
branderson 0eb418266c Derive a host port only where the launcher can be sure of the host
The N1 fix introduced this, and it is the sharper half of what that fix was
about. The derivation added the port of every configured destination
"whether or not it turns out to be host-resident" - so a remote Loki at
https://loki.example.com, which has no port of its own, added 443 and opened
the host's own 443 to every session. On a box following the reverse-proxy
arrangement in docs/remote-access.md that is the proxy in front of the web
terminal: the thing the clamp exists to keep out of reach, opened by the
change that was meant to close it.

Three conditions now, and each is load-bearing because what the list opens
is a port on the host rather than on the destination: an explicit `:port`,
a literal address rather than a name, and that address being the one
EGRESS_HOST_ADDR names. Outside that nothing is derived, so a host-resident
service configured by name needs EGRESS_HOST_PORTS set by hand - which the
docs now say, where they claimed the launcher handled it.

Three URL shapes also produced a value that is not a port, which the policy
then refused by naming a variable the operator never set: userinfo whose
password starts with a digit, a query with no path, and a bracketed IPv6
address. Userinfo, query and fragment are stripped before the split, an
unbracketed IPv6 address is read as having no port because every colon in
it is the address, and a port has to be all digits.
2026-10-11 18:57:02 -07:00
branderson a53936b10c Fix the documentation the release pass found wrong
Section 4 of the pass, each one a claim the code contradicts.

Security properties stated too strongly. `[identity] ssh_key` is not a
section that exists - it is `[identity.<name>]` or the deprecated
`[core] ssh_key`. The isolated table said the forwarded agent is "not
mounted" flatly, where it is withdrawn when the workflow does not push OR a
usable key is configured - so the docs' own example, a pushing workflow on a
host with no key, forwards it. "The table above is the whole of what
crosses" was false: LOKI_URL still reaches the session, and the model
credential follows its own rules whatever the mode. And
`a session sees its own project's history and no other's` is true of the
per-project state and not of the four shared read-write directories this
release's own comment calls shared (#380).

Claims about the code's mechanism. The README listed "the Docker host" as
allowed, where it now opens the ports a host-resident service was
configured on and no others. providers.md described merge-guard.sh by a
regex mechanism it no longer uses, and said an empty DEJIMA_MERGE_BLOCK
makes it inert - it stays active for the disclosure and for repository
scoping, neither of which is the workflow's to turn off.
EGRESS_HOST_PORTS is set in the launching environment, not "on the filter
container", and the launcher now contributes to it.
configuration.md claimed nothing on the command line turns a setting back
on, which is true of the `--no-*` family and not of `--ro`, `--rw`,
`--include`, `--allow-github-push` or a `--forge` the config did not name.

Things that were simply missing. `--isolated` was not in the README at all,
and the refusal of a project path was undocumented. An empty `ssh_key` file
counting as no key is how a launch keeps the setting and forwards an agent
instead, which nothing said.

Smaller: a sentence orphaned by the multi-domain insertion now names its
two subjects, ci.md loses a container count that drifts, and
development.md's merge-guard row describes what the hook does rather than
what it was named for.
2026-10-11 17:30:14 -07:00
branderson 505c6f0623 Test the host clamp's ordering and the ports the launcher derives
The two tests for the host-port clamp change earlier in this branch. They
are a commit of their own rather than part of it because the hunk selection
that built these commits left them behind, and moving them back would mean
rewriting six commits that are otherwise correct.

What they assert: that the clamp is emitted before the open class and after
the forge clamp, which is what decides the matter since nft takes the first
match - and that the launcher derives a port from each configured
host-resident URL, with the derivation itself driven over the shapes it has
to read. A wrong port is the same as no port once the clamp is first.
2026-10-11 17:30:14 -07:00
branderson 7776aa3298 Remove an isolated session's share when it is still empty
From the release pass's lows. The share is made before most of the launch's
refusals, so a refused isolated launch left an empty directory under the
isolated root for an operator to find and wonder about - and a session that
handed nothing back left one too, in a root that grows by one per launch
with nothing to prune it (#389).

`rmdir` in the exit trap, which only removes an empty directory, so the
check is the removal. A project's share is untouched: that one is expected
to outlive its sessions.

The two tests that asserted the directory still exists afterwards now assert
the opposite, and one of them drives a stub that writes into the share -
which is what shows the launch creates and mounts it rather than only
computing a path, the thing the old assertion was really standing in for.

Refs #389
2026-10-11 17:29:44 -07:00
branderson 2a9606d873 Refuse --isolated written after the project path
From the release pass's lows, fixed here rather than ticketed because of
which way it fails. The flag loop stops at the first positional, so
`dejima ~/proj --isolated` passed the flag to the harness and mounted the
project read-write: the operator asked for the one thing that silently did
not happen, and nothing in the output said so.

Refused unless `--` says the rest is deliberately the harness's, which is
the one reading where passing it through is what was meant.
2026-10-11 17:29:43 -07:00
branderson 885423a0a8 Warn about every guarded store inside the workspace, not three of six
P2 from the release pass, reframed. The finding was that the workspace mount
bypasses the guarded-store refusal. It does - and that is a deliberate
decision with three tests pinning it: warn_if_inside_workspace tells the
operator, with each store's own consequence, rather than refusing a launch
against a directory that happens to contain one. Refusing would make
`dejima ~` impossible on a host whose config lives under it, which is a
policy change for the operator to make rather than a release fix.

What was not deliberate is which stores it covered. Three were warned about
and six were guarded, so a path dejima refuses to mount through [mounts] or
--ro was mounted without a word when it sat inside the project: the epitaph
store, every project's share root, and the isolated share root this release
added. Each has its own consequence and now says it - the epitaph one is the
sharpest, since that store exists to not be readable by a later session.

A static test holds the two lists in step by name, in both directions, so
the next store added to one is added to the other. The generic refusal text
said "even read-only" for a mount that is read-write by definition; it says
"mounted at all", which is true of every caller.

Whether the workspace should be refused rather than warned about is a real
question and not this one: #398.

Refs #398
2026-10-11 17:29:31 -07:00
branderson bec542ca48 Mount the real Claude credential only when it is this backend's own
P1 from the release pass, older than this release and worth fixing before it
because docs/security.md says the opposite.

The proxy branch requires a credential kind other than none, the proxy on,
and the credential usable. Everything else fell through to
`elif [ -f ~/.claude/.credentials.json ]`, which mounted the host file
read-write. That covers a backend needing no credential at all - a model you
serve yourself, the arrangement the docs call the one with nothing in the
container to steal - and any backend whose own credential file is elsewhere
and missing.

The branch names every condition that makes this the documented downgrade:
there is a backend, its credential is not none, the proxy is off for it, and
its credential file IS this file. The last is what distinguishes "the
operator turned the proxy off for the credential in this file" from "the
proxy branch did not take, so mount whatever is lying there".

The unusable-with-proxy-on case cannot reach it either way: that is already
a refusal, by design, a few lines above.
2026-10-11 17:27:43 -07:00
branderson 402fac5d5d Refuse an isolated cline launch rather than writing its config to the host
N4 from the release pass. harness_prepare_config writes providers.json into
the state directory, and the session reads it through the state mount - which
--isolated skips, because that hook also creates a slug directory on the
host. So an isolated cline launch wrote a file to the host, keyed on the
container-local working directory and shared by every isolated cline launch,
did not mount it, and started Cline with no model configured. That last part
is the failure providers.json's own updatedAt comment describes: Cline falls
back to its hosted endpoint, which the firewall was never asked to allow.

Refused in the plugin, before anything is created, because the harness is
what knows it has no way in. #393 is the way in - a per-run mount, the same
shape as the settings override.

Refs #393
2026-10-11 17:27:43 -07:00
branderson 2d462ea53c Make every reader of an isolated launch's settings give the same answer
N3 and N6 from the release pass, together because both are the same block
and splitting it is what a reader would have to undo to see either.

N3: `_override_set core.forge` is true for any `--forge`, `none` included -
so `dejima --isolated --forge none`, the launch that said most clearly that
it wanted no forge access, was the one handed core's GitHub credential. The
GitHub token now follows the resolved forge's capabilities instead: a forge
that can do nothing has no use for a credential, which is the actual rule,
and it names no forge, which core may not.

N6: the banner worked the epitaph out from [epitaph] enabled alone and
printed "epitaph on" a few lines above the isolated notice saying it was off
- and nothing was being kept either way, because the save was gated on the
mode instead. --print-config had the same split, for both keys. Both are
overrides now, where the flag is read, so the banner, --print-config, the
epitaph's save and the shipper's start all read one answer. The two mode
checks those last two carried are gone. The audit keeps its own, having no
key to override.

The test called "reaches no forge unless the same command names one" never
named a forge - only a workflow. It covers both, in both orders, with the
other setting asserted to stay at the mode's default.
2026-10-11 17:27:23 -07:00
branderson 8bea036701 Narrow the projects map on the proxy-off path too, not just the first one
N2 from the release pass. With the proxy off for a backend whose credential
IS ~/.claude.json, the staged copy was re-seeded with `cp` so the credential
would survive - and that put back the whole `projects` map #381 narrowed:
every project's path, a sample of each one's filenames, and what each had
cost. It ran after the credential scan had read the narrowed copy, so
nothing ever looked at what came back.

The narrowing is one function with the credential as a parameter now, so
there is no second expression to forget. The two call sites differ in that
one respect and nothing else.

docs/security.md said the copy was "seeded from the real file" with only the
credential removed, in two places. It describes both halves now, including
that this path keeps the credential and narrows the map exactly as the
other does.
2026-10-11 17:27:02 -07:00
branderson 8b9c94bfc5 Clamp the host's own address before the rules that would widen it
N1 from the release pass. The clamp said the right thing and never saw the
packets: the open class permits on every port, and the host's address lands
in it by several ordinary routes - a host-resident LOKI_URL, a self-served
model's base_url, `dejima-allow <gateway>`. The permit was emitted first, so
the host was open on every port it publishes, sshd and the remote-access
front door included, while docs/security.md said configuring one host
service is how that service becomes reachable.

Moved ahead of the open class and still behind the forge clamp, so a
host-resident forge is read as a forge rather than as the host.

On its own that would refuse what the clamp is for. EGRESS_HOST_PORTS has no
config key - it comes from the environment the operator launches from - so a
host-resident Loki or self-served model worked only because the open permit
answered first. The launcher now adds the port of each host-resident
destination it configured, which is what makes "that service, not the whole
host" true rather than aspirational. A port is added whether or not the
destination turns out to be host-resident, because deciding that means
resolving a name and the launcher does not.

One bug found writing the derivation, worth knowing: in a single `local`, a
later initialiser does not see an earlier name from the same statement, so
every URL read as its scheme's default port. Declared separately now.
2026-10-11 17:26:29 -07:00
branderson 43ac721355 Merge pull request 'Say what dejima on its own gives you' (#391) from standalone-posture into development
Reviewed-on: #391
Reviewed-by: branderson <brad@branderson.io>
2026-10-11 03:32:00 -07:00
branderson 60d0b8ec97 Merge pull request 'Run a session with no past and no future: dejima --isolated' (#390) from isolated-mode into development
Reviewed-on: #390
Reviewed-by: branderson <brad@branderson.io>
2026-10-11 03:29:21 -07:00
branderson f0513208a1 Stop inviting the isolated mode to be used for untrusted work
The pointer to security.md said it was worth reading "before using this for
anything you would not run against your own checkout", which reads as an
offer the threat model does not make: its stated premise is that the
project is one you already trust, and nothing about this flag changes that.
It narrows what a session can read of the host and what credential it
carries, which is not the same claim.
2026-10-11 01:01:49 -07:00
branderson a6e1de5f30 Keep the credential argument in one place
The explanation of what crosses into an isolated session and why was in
both documents, nearly verbatim. security.md keeps it: why a forge token
is the widest thing a session holds, why core's GitHub credential counts
as one, why the agent and the key get opposite answers, and why this is a
narrower blast radius rather than a stronger sandbox.

configuration.md keeps the table, the two commands, and the two rows that
surprise people - the GitHub token being withheld though it is not a forge
setting, and the key being mounted though the agent is not - then links to
the argument. The division is that configuration.md owns what the mode
does and how to ask for it, security.md owns what boundary it moves.

One duplication stays, in the other direction: security.md says in one
sentence that the conversation log, the epitaph and the audit are off,
where configuration.md has the full version and the issue number. Each
page carries its own argument whole and one sentence of the other's.
2026-10-11 00:34:56 -07:00
branderson 60d6736e37 Narrow the comment above the isolated forge default to the key
It said the ssh key or the forwarded agent is untouched, which was true
when it was written and stopped being true in the same commit: the
workflow this sets pushes nothing, and a workflow that never pushes
already gets no agent socket. Only the key is untouched, and #392 is where
that is being decided.
2026-10-11 00:34:56 -07:00
branderson b36e7c935f Withhold the GitHub token from an isolated session, and get the agent right
Two halves of one question - what credential an isolated launch carries -
which the round-1 change answered for the forge's own token and got wrong
for the other two.

Core fills GH_TOKEN from [github] token_file for every session whatever
forge is selected, so narrowing the forge did not narrow it. Read-only
across an operator's private repositories is still account-wide reach, so
an isolated launch that named no forge no longer gets it. The filter
container still does: that token authenticates dejima's own fetch of
GitHub's address ranges, a request about the allowlist made where no
session can reach it, so the session reads a separate variable. Gated on
whether the command named a forge, captured before the mode's own default
answers for it.

The docs had the ssh agent backwards. A workflow that never pushes gets no
agent socket and local-only is one, so an isolated launch forwards no
agent; a configured key crosses whatever the workflow. They now state the
pair, with a table row each.

The test for it was worse than incomplete. project.bats' launch helper
hardcodes SSH_AUTH_SOCK empty, so no launch it drives could have forwarded
an agent and asserting its absence proved nothing - and a configured key
withdraws the agent independently of the workflow, so even with a socket
the fixture would have shown the right answer for the wrong reason. The
helper now takes LAUNCH_SSH_AUTH_SOCK for the one test about that path,
and that test empties the key file so the push rule is the only thing left
that can withhold the socket, with a pushing workflow as the positive
control.

Withholding the key as well, for any launch whose workflow never pushes,
is #392: wider than this flag and not free.

Refs #392
2026-10-11 00:03:58 -07:00
branderson ebf3b1dfe1 Name the runtime test's second volume so teardown can sweep it
Purpose before the run suffix, like test_container_name: teardown's
filter is anchored on the suffix, so a name ending in anything else is one
it cannot reach if the test is interrupted between creating the volume and
removing it.
2026-10-11 00:03:33 -07:00
branderson 84ed923f85 Reach no forge from an isolated launch unless the command names one
The flag narrows, and the forge credential is the widest thing a session
gets: a token is commonly account-wide rather than per repository, so a
session holding one can read and write everything its operator can.
Carrying that by default into the mode meant for a session nobody is
watching had the host narrowed to nothing and the forge left at full
reach.

So --isolated sets forge to none and workflow to local-only for that
launch. Naming either on the same command line is how an operator asks
for a session that clones and opens a pull request, which is a real use
of this mode. The defaults are applied after the argument loop rather
than inside it, so the two orders agree.

The ssh key and a forwarded agent are untouched. That requirement belongs
to the launch rather than to this flag, so what this removes is the forge
token and the instruction to push, not the ability to reach a host over
ssh. Both documents say so, and a test exists to fail if it stops being
true rather than letting the claim go stale.

The briefing's closing paragraph said work leaves "pushed or left in
~/shared", which is loose when the default workflow pushes nothing.

Also here, because they sit inside the same hunk: the history-volume
assertion in project.bats now says "sets nothing" rather than naming a
volume, which is the previous commit's reasoning applied to the test that
had the same weakness.

And two things the review raised that are not this: the remote-access
registry writes `workspace=isolated`, so `dejima-attach --list` stops
showing `work` - dejima's own scratch path - for every isolated session;
and the launch notice names the forge and workflow it resolved, since
both are now part of what the mode is.
2026-10-10 22:06:41 -07:00
branderson 770e8778be Give the isolated working directory a named volume, reaped like the rest
It was an anonymous volume, asked for with a colonless mount spec, which
left the reaping to whatever `compose run --rm` does with one - a question
the runtime test could not answer, because it exercised plain `docker
run`. The guarantee never depended on it: an anonymous volume is as
unreachable as a named one. What did depend on it is that the volumes do
not pile up, once per launch, and this is the one holding everything the
session cloned or wrote.

So it is a volume declared beside the other four, named for the run and
added to the set cleanup() removes. One rule for all five, and nothing
left resting on an unverified property of compose.

The runtime test now asserts what a confined suite genuinely cannot: that
a volume at that path is writable by the session user, since it is seeded
from the image and a wrong owner would break a session on its first
write, and that a second session's starts empty. It no longer compares
`docker volume ls | wc -l` before and after, which another job on a shared
runner creating a volume in between would have failed. runtime.bats'
teardown sweeps this suite's volumes too, for the run interrupted between
making one and removing it.
2026-10-10 22:06:21 -07:00
branderson ba3615ec4b Keep the shell-history volume pointing at the volume it already was
My own regression from the commit that made these names variable. The
compose project is `dejima` and the key is `dejima-bashhistory`, so an
entry with no `name:` gets `dejima_dejima-bashhistory` - and the default I
wrote was the key alone, a different volume. Every ordinary launch after
this branch merged would have started from an empty history with the real
one orphaned.

The test is rewritten rather than corrected. Pinning the right string
leaves a test that gets edited to match whatever the file says, which is
what the old one did and why it passed. It now derives the expected name
from the compose project and each volume's key, and checks every declared
volume, so a default that merely looks plausible fails and a volume added
later cannot skip the check.
2026-10-10 22:06:06 -07:00
branderson 83f9fcfe7f Document isolated sessions, and where a flag's reasoning lives
A section in configuration.md with the table of what differs, why the
launch directory decides nothing, and what the mode gives up. Two notes
beyond the mechanics: that there is no config key in any spelling, and
that this is a narrower blast radius rather than a stronger sandbox - the
firewall, the credential boundary and the capability drops are unchanged,
and a container escape is exactly as serious.

security.md gets the short version beside the section on the mounted
project being writable, which is the class this removes rather than
bounds.

providers.md lists the briefing placeholder whitelist, which a test holds
to the code, so the two new ones are named there with what they are for.

The narrowing-flags paragraph cut from `dejima --help` lands here in full,
which is what earned the line the flag needed.
2026-10-10 20:26:39 -07:00
branderson a687d1793b Test what an isolated launch hands the container
Driven through the real launcher against a stubbed docker, because every
claim here is about what `compose run` is given and a grep over the source
passes just as well on a line that no longer takes effect.

The one about an attack rather than about tidiness is that a launch from
inside an approved project gets neither its domains nor its dependency
layer, with an ordinary launch from the same directory asserted to get
both - so it cannot pass on a fixture whose approvals were never recorded.

Two independent things in bin/dejima hold that, and the test fails only
when both are reversed. Worth saying because a mutation run looks like a
miss otherwise: reversing either alone still passes, which is what defence
in depth looks like from the outside.

Seventeen other mutations each fail the test named for them. The runtime
one covers the single assumption a confined suite cannot decide - that
Docker reads a colonless mount spec as an anonymous volume, writable by
the session user, with no host path behind it.
2026-10-10 20:26:38 -07:00
branderson 537ab56b04 Stop telling an isolated session things that are not true of it
Four claims in the core preamble an isolated launch makes false: that the
workspace is the real project, that ~/shared is shared with every session
against it, that the harness keeps state on the host, and that the work is
recoverable there afterwards. A session acts on these - one that believes
its transcript outlives the container leaves work for a resume that cannot
happen.

The two paragraphs carrying them become placeholders with a variant each,
which is the mechanism the preamble already uses, so the ordinary briefing
comes out byte-identical apart from two reworded clauses. The harness
fragment, which is what promises the resume, is replaced rather than
dropped: a session told nothing would assume the usual arrangement.

The worktree block's whole rationale is that the bind-mounted project is
the only path backed by host storage, which inverts here. Both worktree
workflows also stated its first bullet identically in their own fragments,
leaving one argument in three places and only two of them substitutable -
it is core's now, so a workflow says where the block goes and nothing
about what it says.

"outside the project directory" became "outside your working directory",
which is true in both modes rather than vacuous in one.
2026-10-10 20:26:25 -07:00
branderson a72ea3dcda Turn off what reads a transcript, and say so at launch
The epitaph, the conversation shipper and the exit-time audit all key on a
workspace that an isolated launch has no host side of. Each would
otherwise run against /dejima/work, find nothing, and look like a feature
that is present.

So all three are skipped, and the launch prints what it is, what is off,
and where its share directory is - whatever [ui] banner says and whether
or not stderr is a terminal, unlike the banner. The share path is the only
route back to what the session hands over, and a log that is quietly empty
is worse than one the operator knows they are not getting.

Making either of the first two work in this mode is #388.

Refs #388
2026-10-10 20:26:25 -07:00
branderson 006a2319e7 Give an isolated launch a share directory of its own
The one thing that outlives such a session, so it has to be a real host
directory - and it must never be the one another session had.

A sibling of the per-project root rather than a slug inside it, because
an isolated session has no project to be keyed by. mktemp -d rather than
a name built from a timestamp: two launches in the same second would
otherwise be handed one directory, which is the single thing this layout
cannot do.

The root joins the guarded stores, so no ordinary launch can name it in
[mounts] readonly and be handed every isolated session's handoff files -
the pile the per-project split removed. The loose-entry notice learns
about it too, or it would report dejima's own directory as something left
behind by hand at every launch.
2026-10-10 20:26:02 -07:00
branderson c9064053ab Mount none of the host's writable state for an isolated launch
What crosses in, and what outlives the container.

The harness state hook is not called. It is what mounts the transcripts
and the per-project memory, and it creates the directory for the
workspace's own slug before mounting it - so skipping the call is also
what stops an isolated launch leaving a directory behind.

Config directories are filtered on the mode the harness already declares.
The read-only ones are the operator's hooks, skills and commands, which no
session can write; the writable ones are shared state every project's
sessions append to (#380). They are not pre-created either, since a
directory nothing mounts needs no mount source.

Both nvim's volumes and the shell history are named for the run. Every
isolated launch has the same working directory, so a slug would give them
all one set - and the history volume is shared by every project even when
the nvim ones are not, which makes it the one surface where an isolated
session would read what was typed in any session before it. What keeps
them unreachable is the name carrying this run's id; the removal in
cleanup() is tidiness on top of that.

The [mounts] lists are dropped with a line naming them, rather than
refused: they are a standing preference about ordinary work, and refusing
would mean an operator with one could never use this flag.

Refs #380
2026-10-10 20:25:50 -07:00
branderson 57317e14ec Add --isolated, a launch with no project behind it
The flag, the working directory it runs in, and the one thing that makes
the directory it was started from irrelevant.

/dejima/work is container-local and outside $HOME on purpose: a few
host-side reads take this path, and ~/work is a path an operator might
plausibly have. The compose file takes the whole mount spec from one
variable because an isolated launch needs one with no colon in it - an
anonymous volume - and a list entry cannot be left empty.

The approval store is not resolved. resolve_project_store walks upwards
from the path it is given, so a launch from $HOME on a machine whose
dotfiles were once approved as a project would otherwise be handed that
project's allowed domains and its dependency layer.

--ro and --rw are refused rather than dropped: a path typed into this
command cannot be quietly ignored. A project path is refused for the same
reason, since nothing afterwards would contradict it.

The --help line is earned rather than taken: the page was at its budget,
so the two-line note on narrowing flags became one and moved to
docs/configuration.md in full.
2026-10-10 20:25:34 -07:00
branderson dd9fc844df Link the security notes' laptop caveat to what still holds there
Refs #387
2026-10-10 20:21:03 -07:00
branderson b41097c253 Point the README's stack table at the standalone answer
Refs #387
2026-10-10 20:07:14 -07:00
branderson f9f7a3f60f Say what dejima on its own gives you, and what to do about the rest
Refs #387
2026-10-10 20:07:09 -07:00
branderson af6913ebbe Merge pull request 'Wait for what teardown killed before removing its directories' (#385) from remote-teardown into development
Reviewed-on: #385
Reviewed-by: branderson <brad@branderson.io>
2026-10-09 23:13:49 -07:00
branderson e2a7dcd4dd Merge pull request 'Stage only the launching project's entry in ~/.claude.json' (#381) from claude-json-scope into development
Reviewed-on: #381
Reviewed-by: branderson <brad@branderson.io>
2026-10-09 23:13:10 -07:00
branderson a6585dd050 Merge pull request 'Take several domains at once in dejima-allow, capped and confirmed' (#383) from allow-multi into development
Reviewed-on: #383
Reviewed-by: branderson <brad@branderson.io>
2026-10-09 23:08:25 -07:00
branderson b142c31b22 Merge pull request 'The self-check retry that missed its own failure, and deadlines that scale with the load' (#379) from reliability into development
Reviewed-on: #379
Reviewed-by: branderson <brad@branderson.io>
2026-10-09 22:49:53 -07:00
branderson c5b3c5d222 Wait for what teardown killed before removing its directories
remote.bats failed intermittently on `rm: cannot remove ...: Directory not
empty` in its own teardown - a red check on a test that had passed. Seen in CI
on #374 and again locally on a branch touching neither this file nor
dejima-attach, which is what said it was the teardown rather than the test.

`kill` sends a signal; it does not wait. The attach client these tests
background - and the script(1) and tmux processes under it - takes a moment to
go, and `tmux kill-server` likewise returns once the server has been told. In
that window `rm -rf` lists a directory, deletes what it saw, and something
still exiting creates another entry before the rmdir.

So teardown waits for each thing to be gone rather than sleeping or hoping: the
client until its pid stops answering, the server until it stops answering
has-session. The removal then retries as a backstop for anything else that
writes on its way out, and reports what is left rather than failing - a leaked
temp directory costs disk, where a red check on a passing test costs a re-run
and some trust in the suite.

Three consecutive full-gate runs clean, with no occurrence of the message. That
is evidence rather than proof for a race, and the suite's own history says so:
#295 called three clean runs "rarer, not impossible".
2026-10-09 21:51:45 -07:00
branderson 3d2e3d2699 Stage only the launching project's entry in ~/.claude.json
That file keys per-project state by absolute path, and dejima copied the whole
map in. So a session was told the name of every project on the host, a sample
of each one's filenames (exampleFiles), what each had cost and when each last
ran - from inside a sandbox whose whole claim is that it cannot learn what you
do elsewhere (#380).

Narrowed for the reason #104 narrowed the projects MOUNT: the whole map is
every project's. The project's own path and anything under it are kept, because
a session's cwd is routinely a worktree inside the project and Claude Code keys
that separately. Compared as strings, which is meaningful here rather than a
guess about layout: both sides of this mount agree on the path by construction.

A `projects` key holding something other than an object is left alone rather
than narrowed. Narrowing it would error, and the staging treats a jq failure as
fatal - so an odd but parseable file would have become a launch that will not
start, over a key with nothing in it to narrow.

The other three directories #380 names are untouched: scoping backups/, tasks/,
plans/ and sessions/ is a behaviour change for the host side, and this one is
not.
2026-10-09 21:51:45 -07:00
branderson 2d8c86ea44 Take several domains at once, up to twenty, confirmed before any is added
An agent that needs a toolchain's hosts asks for them together, and this took
one domain per command - so the operator ran it four times for four hosts, or
the agent asked four times.

Up to twenty now. One still goes through as it always did: that is a decision
the operator made by typing it, and a prompt on every ordinary use is a prompt
nobody reads. More than one is printed back one per line and confirmed first,
defaulting to no.

The cap and the confirmation are the same point. A list is where an unwanted
name rides along unread, the agent that suggested the command is what might
have put it there, and neither mechanism helps if the list is too long to
finish reading - so twenty, which is enough for the real case and short enough
to take in at once.

Every name is validated before any of them is shown, and one bad entry refuses
the whole command. The list being confirmed has to be the list that gets added,
or the operator approves names and a prefix of them lands; and the printout can
only be read if no entry carries a carriage return or an escape sequence that
rewrites the lines above it, which the pattern already forbids. All the invalid
ones are named, because fixing a list of ten should not mean running it ten
times to find the second mistake.

Confirming needs a terminal, so a script does them one at a time - which is
what it did before and still does for one. Repeats collapse, case-folded, so
the count the operator reads is honest.

$domain is only set inside the per-domain loops now, so the container picker's
prompt takes a label for the whole request instead. Under set -u that one
reference was fatal rather than merely wrong, which is how it was found.

The help page stayed within its thirty lines by earning them: the picker is
described once under the scope that finds it rather than twice, and two
paragraphs that both said a scope flag is an approval for later became one.
2026-10-09 21:51:45 -07:00
branderson 503addfc8b Scale a bounded wait by the concurrency the run asked for
The suites wait on things that take as long as the machine makes them take - a
pty reaching raw mode, a shipper polling, a prompt being answered - and each
wait has a deadline. One that is right on an idle box is wrong under the gate,
which runs every suite at once, and raising it until both pass means picking a
number that is wrong in one of them. #296 reduced that by budgeting the
concurrency; it did not remove it, and the same mistake arrived in a new
fixture three days later, where an idle bound went from sixty seconds to two
hundred and forty.

So each runner says how much concurrency it asked the machine for -
run-confined.sh the budget it divided among the suites, the CI job what it
passes bats - and the fixtures multiply their bounds by how oversubscribed that
makes it. Neither number is a guess about load: it is what that runner asked
for.

Nothing gets shorter than it is today. The multiplier floors at 1, so an idle
run and a run with the variable unset behave exactly as before, and it is
capped because a wrong value here turns a test that should fail in seconds into
one that hangs for the length of the job.
2026-10-09 21:51:45 -07:00
branderson a279460318 Make two tests look at what they are named for
"The permitted set cannot be amended from the session container" checked only
the exit status, so an amendment that landed and then failed on something later
would have passed it - non-zero, with the destination permitted. It now re-reads
what a permit for a name actually writes: the entries file and the resolver
configuration. Not the address sets, which only ever hold addresses, so
grepping those for a hostname is a check that cannot fail.

The negative-control loop in the self-check test iterated the output of a grep,
so renaming those helpers would have left it asserting that none of zero lines
is retried. It counts them.
2026-10-09 21:51:45 -07:00
branderson 76a5c5202e Retry the lookup failure the retry was written for
resolve_one returned 0 when dig itself failed, so retry_positive saw an answer
on the first attempt, printed no retry line, and the caller died on an empty
result. A resolver a moment behind the container is exactly that case - dig
fails rather than answering empty - so the transient the retry exists to absorb
was the one it did not cover (#345).

It returns non-zero for both ways of having no address now. Negative checks
read it the same way either way: they want non-zero, and a lookup that did not
come back is not a name that resolved.

The test guarding this was satisfied by the `return 1` already in that function
for the empty-answer case, so it passed while the property was false. It drives
resolve_one against a stubbed dig instead - failing, empty, and answering - and
the third case matters as much as the first two: a lookup that reported failure
on a good answer would spin the retry on every name and fail the boot on all of
them.
2026-10-09 21:51:45 -07:00
branderson 60a319b3ba Merge pull request 'Give the resolver checks a permitted name when the meta URL has no host' (#386) from egress-subject into development
Reviewed-on: #386
Reviewed-by: branderson <brad@branderson.io>
2026-10-09 21:49:44 -07:00
branderson c0bc131fd4 Read the workflow for the sh -n sweep without PyYAML
The sweep added in #382 parsed .gitea/workflows/tests.yml with PyYAML,
which is not in the test image: in CI the test died with
ModuleNotFoundError having checked nothing, and failed confined (static).

It is an indentation scan over the two shapes the file uses now, with
nothing imported from outside the standard library. Bodies come from the
file as written rather than from the copy keys are normalised in, so a
shell line beginning with a dash is not rewritten on its way to sh -n,
and a job setting defaults: is refused rather than guessed at.

A hand-rolled scan that meets a shape it was not written for finds
nothing and reports no failures, so the count it read is checked against
a plain grep of the run: keys in the file.
2026-10-09 21:43:48 -07:00
branderson 00a2df3b0c Give the resolver checks a permitted name when the meta URL has no host
tests / confined (audit) (pull_request_target) Successful in 3m55s
tests / confined (config) (pull_request_target) Successful in 5m21s
tests / confined (conversation) (pull_request_target) Successful in 1m17s
tests / confined (launch) (pull_request_target) Successful in 2m32s
tests / confined (project) (pull_request_target) Successful in 3m37s
tests / confined (providers) (pull_request_target) Successful in 2m16s
tests / confined (proxy) (pull_request_target) Successful in 1m29s
tests / confined (remote) (pull_request_target) Successful in 3m9s
tests / confined (share) (pull_request_target) Successful in 2m17s
tests / confined (shellcheck) (pull_request_target) Successful in 1m35s
tests / runner-env (confined) (pull_request_target) Successful in 1m5s
tests / confined (static) (pull_request_target) Failing after 2m45s
tests / privileged (pull_request_target) Successful in 7m53s
tests / runner-env (privileged) (pull_request_target) Successful in 1m2s
The cached-ranges fixture points EGRESS_FORGE_META_URL at a file:// path, and a
file URL has no host - so checks 3 and 4, which resolve "the meta URL's host",
were handed an empty string. Check 3 asked the resolver to look up "" and
failed the container's boot on it, which took down every privileged run as soon
as the cache started working.

The property those checks state is about the policy: that a permitted name
resolves through the local resolver, and that its address lands in that name's
own set. The meta URL's host was an instance of such a name, not the point of
it - so the subject falls back to a permitted forge-class name, which keeps
checks 3, 4 and 6 doing exactly what they claim. Forge-class specifically,
because check 4 looks the answer up in the forge class's own set and check 6
asserts port 22 is refused on it.

Nothing at all to check is a refusal naming that, rather than a lookup of "".

Check 5 is skipped for a file source, and says so in the log. It asserts that a
permitted destination is reachable THROUGH the policy, and a local file is not
one: reading it again would pass while proving nothing. That is a real
reduction on the runs that use the cache, so it is a line an operator can see
rather than a silent pass - restoring it means choosing a permitted destination
cheap enough to request once per container, which is a separate decision.
2026-10-09 20:54:18 -07:00
branderson e7769bb3d6 Merge pull request 'Write the ranges-cache step in the shell the runner actually gives it' (#382) from ci-sh-fix into development
Reviewed-on: #382
Reviewed-by: branderson <brad@branderson.io>
2026-10-09 18:25:43 -07:00
branderson e346ad3ae2 Write the ranges-cache step in the shell the runner actually gives it
tests / privileged (pull_request_target) Failing after 1m7s
tests / runner-env (privileged) (pull_request_target) Successful in 58s
tests / confined (conversation) (pull_request_target) Successful in 1m52s
tests / confined (proxy) (pull_request_target) Successful in 2m6s
tests / confined (providers) (pull_request_target) Successful in 3m7s
tests / confined (launch) (pull_request_target) Successful in 3m29s
tests / confined (share) (pull_request_target) Successful in 2m43s
tests / runner-env (confined) (pull_request_target) Successful in 1m11s
tests / confined (shellcheck) (pull_request_target) Successful in 1m41s
tests / confined (remote) (pull_request_target) Successful in 4m16s
tests / confined (project) (pull_request_target) Successful in 4m47s
tests / confined (audit) (pull_request_target) Successful in 5m11s
tests / confined (static) (pull_request_target) Failing after 4m12s
tests / confined (config) (pull_request_target) Successful in 6m34s
The step used a bash array. The runner's default shell is `sh -e`, where that
is a syntax error - so the whole step died before any of it ran, and the
privileged job failed on every pull request from the moment #378 merged.

Rewritten as POSIX sh: two curl branches rather than one with a built-up
argument list, and the ranges read once into a variable so the success test and
the count are the same answer. Driven under real dash in all four paths - a
good fetch, a failed fetch, a 200 carrying a rate-limit message instead of
ranges, and no token - with the cache written only in the first.

The sweep that would have caught it goes in beside it: every run: block in the
workflow that does not name its own shell has to parse under `sh -n`. A YAML
parse says only that the string is a string, and nothing local runs the
workflow, so this reached CI green from a machine where the gate passed.
2026-10-09 17:44:17 -07:00
branderson 3f5dae88b5 Merge pull request 'Fetch the forge ranges once per run, and wait out a rate limit when one happens' (#378) from meta-cache into development
Reviewed-on: #378
Reviewed-by: branderson <brad@branderson.io>
2026-10-09 14:15:22 -07:00
branderson 5569e8d2fe Put these tests beside their relatives, not at the end of the file
tests / confined (conversation) (pull_request_target) Successful in 1m52s
tests / confined (proxy) (pull_request_target) Successful in 1m58s
tests / confined (providers) (pull_request_target) Successful in 2m45s
tests / confined (launch) (pull_request_target) Successful in 3m7s
tests / confined (share) (pull_request_target) Successful in 2m17s
tests / runner-env (confined) (pull_request_target) Successful in 1m5s
tests / confined (remote) (pull_request_target) Successful in 3m40s
tests / confined (shellcheck) (pull_request_target) Successful in 1m34s
tests / confined (project) (pull_request_target) Successful in 4m6s
tests / confined (audit) (pull_request_target) Successful in 4m28s
tests / confined (static) (pull_request_target) Successful in 3m34s
tests / confined (config) (pull_request_target) Successful in 5m51s
tests / privileged (pull_request_target) Successful in 12m11s
tests / runner-env (privileged) (pull_request_target) Successful in 1m0s
Appending to test/static.bats is why four branches in a row have had to resolve
a conflict that was only ever about position. The fetch tests go with the rest
of the fetch tests, and the one that reads the workflow file goes next to the
other one that reads it - which is a better home for both and one nothing else
is competing for.
2026-10-09 13:38:36 -07:00
branderson e900c51e20 Say in docs/ci.md why the ranges are fetched once per run
The secondary rate limit is the part that surprises: it is not the hourly
ceiling a token raises, so a valid credential does not prevent it, and the
failure arrives as one container failing to boot rather than as anything about
the API. It sits beside the note on the token, which is where a reader meets
the endpoint.
2026-10-09 13:37:29 -07:00
branderson e91b5771f6 Fetch the forge ranges once per CI run instead of once per container
test/runtime.bats starts twenty-seven container pairs, and every filter fetched
GitHub's /meta for itself - roughly twenty-seven requests in five minutes. That
is well inside the hourly ceiling a token buys, and exactly the burst the
endpoint's SECONDARY limit answers with a 403, which takes one container's boot
down and the job with it (#357).

The privileged job now fetches once into the directory it already shares with
the containers it starts, and runtime.bats points every filter at the result.
One request cannot be a burst.

Best-effort on both sides. The job writes the file only once it parses and
carries ranges, because a rate-limit body is a 200 with a message and no ranges
and a cached one would fail every container instead of one; and the suite falls
back to per-container fetches when the file is absent, which is what happens
outside CI. The worst case is the behaviour this replaces, and each side says
which of the two the run is getting.

egress-policy refuses a file:// source unless SANDBOX_TEST_MODE is set. Those
ranges decide which addresses the forge class reaches, so a launch that could
name its own source could name its own ranges - and that variable is set by
this suite and by nothing in bin/dejima. The mount is read-only and goes to the
filter alone; the session half has no business holding a path that names them.
2026-10-09 13:37:29 -07:00
branderson 3b17604178 Wait as long as a rate limit asks, and say which failure it was
Five attempts over twenty seconds is short for a limit measured in tens of
seconds: the whole sequence could sit inside one window and exhaust itself
without ever reaching the other side (#357).

A rate limit is now recognised from the status - 403 or 429 - or from the body,
because the endpoint will report one with a message and no ranges over a 200.
Those back off at attempt*15 rather than attempt*2, so three attempts cover 45
seconds instead of six. A connection failure keeps the short backoff, because no
wait fixes it.

Retry-After beats both, being the only number here that is not an estimate, and
only in its numeric form: the header may carry an HTTP date instead, and parsing
one to compare against a clock is a wrong answer away from a sleep nobody chose.
A cap stops a value nobody expected turning a boot into a hang.

And the message names the failure. An HTTP error from this endpoint carries a
body saying why - "API rate limit exceeded" rather than "curl exit 0, http 403"
- which was being discarded in favour of curl's stderr, where there is nothing.
curl's own message is what is left when no response carried a body.
2026-10-09 13:37:29 -07:00
branderson 6511a0537c Merge pull request 'Ctrl-Z does not pause the agent, and eleven other documented facts that had drifted' (#377) from review-docs into development
Reviewed-on: #377
Reviewed-by: branderson <brad@branderson.io>
2026-10-09 13:34:20 -07:00
branderson 023efb3171 Merge pull request 'The suite map's blind spot, a test that only skipped, and three cleanups' (#376) from review-tests into development
Reviewed-on: #376
Reviewed-by: branderson <brad@branderson.io>
2026-10-08 17:01:28 -07:00
branderson 23462a3c71 Merge pull request 'The host ssh-agent, a half-landed permit, and four things around them' (#375) from review-sec into development
Reviewed-on: #375
Reviewed-by: branderson <brad@branderson.io>
2026-10-08 16:54:42 -07:00
branderson 2ddcf964c3 Start the log shipper again, and catch the shape that stopped it
tests / confined (audit) (pull_request_target) Successful in 3m57s
tests / confined (config) (pull_request_target) Successful in 5m20s
tests / confined (conversation) (pull_request_target) Successful in 1m18s
tests / confined (launch) (pull_request_target) Successful in 2m31s
tests / confined (project) (pull_request_target) Successful in 3m36s
tests / confined (providers) (pull_request_target) Successful in 2m15s
tests / confined (proxy) (pull_request_target) Successful in 1m27s
tests / confined (remote) (pull_request_target) Successful in 3m8s
tests / confined (share) (pull_request_target) Successful in 2m14s
tests / confined (static) (pull_request_target) Successful in 3m32s
tests / confined (shellcheck) (pull_request_target) Successful in 1m34s
tests / runner-env (confined) (pull_request_target) Successful in 1m6s
tests / runner-env (privileged) (pull_request_target) Successful in 1m0s
tests / privileged (pull_request_target) Successful in 6m46s
`env -u GH_TOKEN -u EGRESS_FORGE_META_AUTH drop_caps ...` cannot run: drop_caps
is a shell function from caps-lib.sh and env can only exec a binary, so the line
was exit 127. The `&` kept that from set -e, so the filter container came up
reporting ready with no shipper and Loki logging went dead without a word.

The prefix is gone. What covers the shipper is order, which is what the test now
asserts: both names are out of this script's environment before the line that
starts it, the raw token before the policy run and the header straight after it.

And the error class has its own test, because the one I wrote only grepped the
start line for the prefix's text - it would have passed on a line that could not
execute. It flattens continuations first, since the prefix was written across two
lines and a per-line grep did not see it, and drops comments after that, or the
line explaining why there is no prefix matches itself. Both spellings verified
to fail it.

runtime.bats already asserts the shipper is running when LOKI_URL is set, which
is the check that would have caught this in the privileged job.
2026-10-08 00:24:05 -07:00
branderson faeafa7f2d Merge pull request 'Three forms that walked past the push and api guards' (#374) from guard-boundary into development
Reviewed-on: #374
Reviewed-by: branderson <brad@branderson.io>
2026-10-08 00:18:26 -07:00
branderson 2cc6057c88 Reflow the two paragraphs this branch left long
tests / confined (audit) (pull_request_target) Successful in 4m53s
tests / confined (config) (pull_request_target) Successful in 6m29s
tests / confined (conversation) (pull_request_target) Successful in 1m21s
tests / confined (launch) (pull_request_target) Successful in 2m36s
tests / confined (project) (pull_request_target) Successful in 4m1s
tests / confined (providers) (pull_request_target) Successful in 2m21s
tests / confined (proxy) (pull_request_target) Successful in 1m35s
tests / confined (remote) (pull_request_target) Successful in 3m0s
tests / confined (share) (pull_request_target) Successful in 2m16s
tests / confined (static) (pull_request_target) Successful in 3m19s
tests / confined (shellcheck) (pull_request_target) Successful in 1m36s
tests / runner-env (confined) (pull_request_target) Successful in 1m1s
tests / runner-env (privileged) (pull_request_target) Successful in 59s
tests / privileged (pull_request_target) Successful in 6m47s
Both were edited in place without rewrapping, so a line in each ran past the
column the rest of the file keeps to. The link stays on one line rather than
being wrapped mid-label.
2026-10-07 23:15:05 -07:00
branderson 1d8725f089 Move the CI-install test beside the other one that reads the workflow
tests / confined (audit) (pull_request_target) Successful in 4m48s
tests / confined (config) (pull_request_target) Successful in 9m30s
tests / confined (conversation) (pull_request_target) Successful in 1m21s
tests / confined (launch) (pull_request_target) Successful in 2m43s
tests / confined (project) (pull_request_target) Successful in 4m2s
tests / confined (providers) (pull_request_target) Successful in 2m23s
tests / confined (proxy) (pull_request_target) Successful in 1m29s
tests / confined (remote) (pull_request_target) Successful in 3m5s
tests / confined (share) (pull_request_target) Successful in 2m13s
tests / confined (static) (pull_request_target) Successful in 4m55s
tests / confined (shellcheck) (pull_request_target) Successful in 1m38s
tests / runner-env (confined) (pull_request_target) Successful in 1m6s
tests / runner-env (privileged) (pull_request_target) Successful in 57s
tests / privileged (pull_request_target) Successful in 6m43s
Three branches appended to the end of test/static.bats, so whichever merged
last had to resolve a conflict that was only ever about position. This one
belongs next to the test that reads the same workflow file for the suite matrix,
which is both a better home and one nothing else is competing for.

Also drops a count that drifts from the suite map's docs rule, and a line in the
comment-stripping block that described doing it the other way.
2026-10-07 23:14:26 -07:00
branderson aa40e0c455 State what the spellings have in common, not what one used to do
tests / confined (conversation) (pull_request_target) Successful in 1m12s
tests / confined (proxy) (pull_request_target) Successful in 1m27s
tests / confined (providers) (pull_request_target) Successful in 2m11s
tests / confined (launch) (pull_request_target) Successful in 2m31s
tests / confined (share) (pull_request_target) Successful in 2m16s
tests / runner-env (confined) (pull_request_target) Successful in 1m7s
tests / confined (remote) (pull_request_target) Successful in 3m8s
tests / confined (shellcheck) (pull_request_target) Successful in 1m37s
tests / confined (project) (pull_request_target) Successful in 3m44s
tests / confined (audit) (pull_request_target) Successful in 4m31s
tests / confined (static) (pull_request_target) Successful in 4m49s
tests / privileged (pull_request_target) Successful in 6m18s
tests / runner-env (privileged) (pull_request_target) Successful in 58s
tests / confined (config) (pull_request_target) Successful in 9m33s
The comment above the refused forms explained the `+` case by what the guard
used to compare. What a reader needs is why every form in the list names the
same branch, which is the two rules the loop exists to cover.
2026-10-07 23:11:57 -07:00
branderson d180581293 Keep the credential from the one daemon the policy does not start
tests / confined (conversation) (pull_request_target) Successful in 1m20s
tests / confined (providers) (pull_request_target) Failing after 42s
tests / confined (proxy) (pull_request_target) Successful in 1m33s
tests / confined (launch) (pull_request_target) Successful in 2m36s
tests / confined (shellcheck) (pull_request_target) Successful in 1m35s
tests / confined (share) (pull_request_target) Successful in 2m14s
tests / confined (audit) (pull_request_target) Successful in 4m46s
tests / confined (remote) (pull_request_target) Successful in 3m14s
tests / confined (project) (pull_request_target) Successful in 3m58s
tests / runner-env (confined) (pull_request_target) Successful in 1m5s
tests / confined (static) (pull_request_target) Successful in 4m50s
tests / confined (config) (pull_request_target) Successful in 9m50s
tests / privileged (pull_request_target) Failing after 6m37s
tests / runner-env (privileged) (pull_request_target) Successful in 57s
log-shipper.sh is started by firewall-up.sh rather than by egress-policy, so
NO_CREDENTIAL never reached it. The unset above it took the raw token away and
left EGRESS_FORGE_META_AUTH exported - and that header carries the same token
in another wrapper, so the shipper held the credential after all.

It now starts under the same `env -u`, and the header is dropped as soon as the
policy run that needed it returns. The raw token still goes before that run,
which is why the test asserts one on each side of it.
2026-10-07 23:11:27 -07:00
branderson f61795381d Stop the README's Ctrl-Z line contradicting the sentence after it
tests / confined (audit) (pull_request_target) Successful in 4m48s
tests / confined (config) (pull_request_target) Successful in 8m56s
tests / confined (conversation) (pull_request_target) Successful in 1m20s
tests / confined (launch) (pull_request_target) Successful in 2m35s
tests / confined (project) (pull_request_target) Successful in 4m0s
tests / confined (providers) (pull_request_target) Successful in 2m18s
tests / confined (proxy) (pull_request_target) Successful in 1m30s
tests / confined (remote) (pull_request_target) Successful in 2m58s
tests / confined (share) (pull_request_target) Successful in 2m12s
tests / confined (shellcheck) (pull_request_target) Successful in 1m36s
tests / privileged (pull_request_target) Successful in 6m31s
tests / runner-env (confined) (pull_request_target) Successful in 1m7s
tests / runner-env (privileged) (pull_request_target) Successful in 1m0s
tests / confined (static) (pull_request_target) Successful in 2m39s
"Backgrounds all of dejima" and "the container keeps running" cannot both be
read as written. The host side is what backgrounds, which is also the
distinction the paragraph exists to draw.

And docs/configuration.md said "this script", which was lifted from
dejima-tty-suspend's own header and has no referent in a document.
2026-10-07 20:45:06 -07:00
branderson 805d9a2c35 Say what the two runner assertions do and do not establish
tests / confined (conversation) (pull_request_target) Successful in 1m18s
tests / confined (launch) (pull_request_target) Successful in 2m27s
tests / confined (project) (pull_request_target) Successful in 3m54s
tests / confined (providers) (pull_request_target) Successful in 2m16s
tests / confined (proxy) (pull_request_target) Successful in 1m33s
tests / confined (remote) (pull_request_target) Successful in 3m15s
tests / confined (share) (pull_request_target) Successful in 2m13s
tests / confined (static) (pull_request_target) Successful in 3m24s
tests / confined (shellcheck) (pull_request_target) Successful in 1m33s
tests / runner-env (confined) (pull_request_target) Successful in 1m0s
tests / runner-env (privileged) (pull_request_target) Successful in 1m2s
tests / confined (audit) (pull_request_target) Successful in 4m33s
tests / confined (config) (pull_request_target) Successful in 5m58s
tests / privileged (pull_request_target) Successful in 6m42s
The comment left where a test used to be described the test. What a reader
needs is the property: each assertion is one job's answer, that the two jobs
land on different runners follows from neither, and a suite cannot see it
because it runs in one job.

The suite map's docs rule gains the same kind of note - a doc-only edit now
runs four suites rather than one, which is the cost of answering for what reads
a file and the direction to err in.
2026-10-07 20:44:48 -07:00
branderson 5884930592 Strip each suite's comments once instead of once per tracked file
The widened walk did a grep pass over every suite for every tracked file, which
took about three times as long as the narrow version it replaced. .dejima.md
warns what a slow test does to a bounded deadline under load, and static.bats
is the long pole in both lanes.

The comment-stripped copies are made once, before the loop. About 14s against
about 32s, for the same answers - verified by reverting the test/*.bats rule and
watching it name the two suites that miss static.

The header described the behaviour before the comment exclusion went in, so it
now says what counts as reading a file is defined below and is narrower than
mentioning it.
2026-10-07 20:44:48 -07:00
branderson 452d49394f State the --repo rule as a property rather than as a correction
tests / confined (audit) (pull_request_target) Successful in 4m31s
tests / confined (config) (pull_request_target) Successful in 9m24s
tests / confined (conversation) (pull_request_target) Successful in 1m12s
tests / confined (launch) (pull_request_target) Successful in 2m29s
tests / confined (project) (pull_request_target) Successful in 3m42s
tests / confined (providers) (pull_request_target) Successful in 2m12s
tests / confined (proxy) (pull_request_target) Successful in 1m28s
tests / confined (share) (pull_request_target) Successful in 2m12s
tests / confined (static) (pull_request_target) Successful in 4m43s
tests / confined (shellcheck) (pull_request_target) Successful in 1m31s
tests / privileged (pull_request_target) Successful in 6m44s
tests / runner-env (confined) (pull_request_target) Successful in 1m4s
tests / runner-env (privileged) (pull_request_target) Successful in 57s
tests / confined (remote) (pull_request_target) Successful in 1m46s
The comment explained git's precedence and then described the reading that had
it backwards. A reader debugging this line needs the rule and what it decides
about an invocation, which is what it says now.
2026-10-07 20:43:00 -07:00
branderson 1cda465898 Read a destination the way git resolves it, not only its full spelling
A refspec destination that does not start with refs/ is matched against the
remote's refs by git's rev-parse rules, so `heads/main` and `main` both name
refs/heads/main. Only the full prefix was stripped, which left
`feature:heads/main` naming a branch no policy entry matched - and
`+feature:heads/main` with it.

Both prefixes come off now, in that order. Every strip has to leave an
unprotected destination alone, which the test asserts for each of them: a
stripping that refuses is not a policy that refuses.
2026-10-07 20:42:59 -07:00
branderson cacb74fb28 Refuse a graphql call whose body is not in the command
--input takes the whole request body from a file, or from stdin with `-`, so
the document is not in argv at any spelling: not as a field value the mutation
check can read, and not in the `=@` shape the file-indirection check catches.
The positional graphql beside it still counted as a readable target, which made
this the most direct spelling left on the path #365 closed for inline bodies.

Refused under the declared subcommand only, in both the separate-value and
attached forms. Outside it, --input is a flag this has nothing to say about,
which the test asserts - otherwise the refusal is about the word rather than
about the body.
2026-10-07 20:42:42 -07:00
branderson 4a9e789ba1 Drop a test call that asserted nothing, and two comments that narrate history
tests / confined (conversation) (pull_request_target) Successful in 1m19s
tests / confined (launch) (pull_request_target) Successful in 2m33s
tests / confined (proxy) (pull_request_target) Successful in 1m33s
tests / confined (providers) (pull_request_target) Successful in 2m17s
tests / confined (shellcheck) (pull_request_target) Successful in 1m34s
tests / confined (audit) (pull_request_target) Successful in 4m41s
tests / confined (project) (pull_request_target) Successful in 3m54s
tests / confined (share) (pull_request_target) Successful in 2m10s
tests / runner-env (confined) (pull_request_target) Successful in 1m3s
tests / confined (remote) (pull_request_target) Successful in 3m1s
tests / confined (static) (pull_request_target) Successful in 4m52s
tests / confined (config) (pull_request_target) Successful in 9m36s
tests / privileged (pull_request_target) Successful in 6m32s
tests / runner-env (privileged) (pull_request_target) Successful in 58s
The empty-key test ran a launch with no key and never looked at the result; the
direction it was standing in for is its own test above it.

permit_address's comment said what the code used to leave standing. It now
states the property - the two adds are not one transaction - and says why the
unconditional delete is the safe direction when the element came from
somewhere else: the record has already rolled back, so a narrower live set
agrees with what `entries` reports.
2026-10-07 20:39:33 -07:00
branderson 9b96d5781b Document that a configured key withdraws the forwarded agent
docs/configuration.md said a key means a forwarded agent "is no longer required
at all", which is now an understatement: it is not mounted. An operator who
also relied on the agent inside the session - a second remote, a private
submodule over ssh - loses it, and that belongs where they choose the key
rather than in a commit message.

docs/security.md says the same in the two places it describes the key as an
alternative to the agent, because "alternative" now means the other one is
closed.
2026-10-07 20:39:33 -07:00
branderson bac3d45814 Scrub the credential where each daemon starts, not upstream of one call path
The unset in firewall-up.sh only covered `apply`. bin/dejima starts the filter
container with -e GH_TOKEN, so the token is in that container's configured
environment - and `dejima-allow` reaches the policy through `docker exec`,
which gets that environment. permit_name and rollback_entries both restart the
resolver, so the first approval of a name put the credential back into dnsmasq.
The ordering test could not see it, because it only read the apply path.

All three daemon starts now carry `env -u GH_TOKEN -u EGRESS_FORGE_META_AUTH`,
so the property holds wherever they were reached from and is visible on the
line that starts them. The post-fetch unset stays, but is no longer the only
thing standing there.

The test asserts the start lines rather than their order, which is what makes
it independent of the call path, and counts them so a daemon dropped from the
sweep is not a daemon that passed it.
2026-10-07 20:39:33 -07:00
branderson fa29e2ac00 Correct eleven documented facts that had drifted from the code
tests / confined (conversation) (pull_request_target) Successful in 1m11s
tests / confined (proxy) (pull_request_target) Successful in 1m24s
tests / confined (providers) (pull_request_target) Successful in 2m11s
tests / confined (share) (pull_request_target) Successful in 2m13s
tests / runner-env (confined) (pull_request_target) Successful in 1m5s
tests / confined (shellcheck) (pull_request_target) Successful in 1m33s
tests / confined (static) (pull_request_target) Successful in 3m31s
tests / confined (launch) (pull_request_target) Successful in 2m27s
tests / confined (remote) (pull_request_target) Successful in 3m5s
tests / confined (project) (pull_request_target) Successful in 3m32s
tests / confined (audit) (pull_request_target) Successful in 3m53s
tests / confined (config) (pull_request_target) Successful in 5m13s
tests / privileged (pull_request_target) Successful in 6m7s
tests / runner-env (privileged) (pull_request_target) Successful in 1m2s
Each was verified against what ships rather than taken from the report:

docs/development.md said a harness manifest declares eleven keys; the schema
has eighteen. The count is gone rather than corrected, since nothing checks it
and it was wrong within a release. The same file's no-Docker suite list omitted
launch.bats and share.bats and put ci-runner.bats among the suites that run
anywhere, when it needs a real runner of either tier; the list now says
run-confined.sh discovers the set, which is why one is not kept there. It also
listed bin/dejima-color-lib.sh twice, with two different descriptions.

docs/configuration.md described the push guard as refusing a remote that is not
the Gitea host. It reads DEJIMA_PUSH_HOSTS and DEJIMA_BLOCKED_BRANCHES, both
resolved from the selected forge and workflow, so it is the same rule whichever
forge is in use.

docs/ci.md said none of the runner posture is checked by anything.
ci-runner.bats checks what a job can see from where it stands - which tier it
landed on, the docker socket in one tier and not the other, the egress rules in
front of the confined job. What it cannot see is the runner's own configuration
file, and that is now what the bullet says.

docs/security.md cited a README section that no longer exists for the forge
token's scopes, and pointed Loki readers at the README's Prerequisites, which
say nothing about logging.

install.sh wrote a config header describing the every-key-commented format it
retired - twelve lines below a comment explaining that defaults.conf is the
complete reference now and this file holds only what the operator decided.

README.md omitted jq from its prerequisites, which install.sh hard-requires and
exits over.

docs/remote-access.md named /run/dejima-notify as the marker root, while its
own diagram twenty lines up has it right: ${XDG_RUNTIME_DIR}/dejima/notify. It
also attributed a "scriptable" claim to the README; it lives in
dejima-attach's own help.

The audit's time-window caveat was stated twice in consecutive sentences in the
README; the one that stays is the one static.bats pins.

The report also called the README's GitHub-push rationale a duplicate of
docs/security.md's, under the "one place" rule. It is not, and the test named
for it says why: the README has to name both properties the port-22 claim rests
on, because the claim stays readable only if a reader can see what would have
to stop being true. Left as it was.
2026-10-07 19:36:31 -07:00
branderson b3f8252c73 Stop two documents saying Ctrl-Z pauses the agent
bin/dejima-tty-suspend's own header says what happens: everything host-side is
in one process group - the script, the container run, the credential proxy, the
conversation shipper - so the kernel stops and continues all of it together.
The container is not in that group. It keeps running, with its mounts, its
firewall grants and its forge token.

docs/security.md says so. The README said "it makes no progress while
suspended" and docs/configuration.md said "a suspended session is stopped, so
it makes no progress until you resume it" - and the operator most likely to
reach for Ctrl-Z is one uneasy about what the agent is doing, which is the
reader those two sentences were worst for.
2026-10-07 19:21:01 -07:00
branderson 720b91a569 Say the resolver-check rationale once per step rather than twice
tests / confined (conversation) (pull_request_target) Successful in 1m16s
tests / confined (proxy) (pull_request_target) Successful in 1m27s
tests / confined (providers) (pull_request_target) Successful in 2m14s
tests / confined (launch) (pull_request_target) Successful in 2m35s
tests / confined (share) (pull_request_target) Successful in 2m15s
tests / runner-env (confined) (pull_request_target) Successful in 1m7s
tests / confined (remote) (pull_request_target) Successful in 3m10s
tests / confined (shellcheck) (pull_request_target) Successful in 1m34s
tests / confined (project) (pull_request_target) Successful in 3m38s
tests / confined (audit) (pull_request_target) Successful in 3m58s
tests / confined (static) (pull_request_target) Successful in 3m40s
tests / confined (config) (pull_request_target) Successful in 5m21s
tests / privileged (pull_request_target) Successful in 6m37s
tests / runner-env (privileged) (pull_request_target) Successful in 56s
The paragraph explaining why the lookup runs before apt was pasted twice inside
each of the four steps that do it. The second copy carried the retry reasoning
and the first carried one detail the second lacked, so the kept paragraph is the
longer one with that detail folded in.
2026-10-07 19:01:46 -07:00
branderson 3086bb884e Stop a test claiming a property one job cannot see
"The two runners are not the same runner" ran in one job, so the branch it took
was whichever of the two assertions above it already applies - one job's answer
restated, under a name claiming both. Comparing the two jobs' results is the
workflow's job, not a suite's, and the comment left in its place says so.

Both directions stay covered, one per job: the confined runner must not have the
docker socket and the privileged one must.
2026-10-07 19:01:46 -07:00
branderson 02f5dd8b19 Make the release pass read its head argument instead of the working tree
The rollback scan diffed $base against the working tree while reporting on
$base...$head, so an explicit head reported on whatever happened to be checked
out. Driven against two refs with base equal to head, the old form named five
providers as added; the new one correctly names none.

All three sites now read the head side through git: the config schema with
`git show`, and the provider directories with `git ls-tree` rather than `find`.
2026-10-07 19:01:46 -07:00
branderson 990ab94a40 Install the tool the port-collision test needs, so it stops skipping in CI
remote.bats skips the dejima-ttyd port-collision refusal without `ss`, and the
confined job installed no iproute2 - so the one check guarding a reverse-proxy
port takeover was a permanent `ok # skip` on every run, which is the same as
not having it.

The test pins both halves: the install line in that job, and that remote.bats
still keys on the tool - otherwise this is an install nothing needs.
2026-10-07 18:50:44 -07:00
branderson 940f632447 Let the suite map's own test see the paths it was rotting under
The meta-test iterated bin/, hooks/, web/, systemd/ and install.sh, so the map
could go narrow under test/, forges/, harnesses/, workflows/, tools/, .dejima/
and docs/ with nothing watching. It now walks every tracked file, and the three
gaps that were there are closed:

A change to test/runtime.bats selected zero confined suites, while static.bats
carries invariants over that file's text - so the --changed lane exited green
having run nothing. A suite file is read as well as run, and now selects static
beside itself.

test/fixtures/* selected proxy and static, but config.bats executes
answer-prompt.py to drive its prompts.

forges/, workflows/ and harnesses/ selected providers and static, while the
audit reads a workflow's briefing, conversation.bats sed-injects a harness.conf
and config.bats copies the whole set in.

The docs rule also moved below the test/ and .dejima/ rules, where it belongs:
a .md under either of those is read by whatever reads that directory, and the
pattern answered for it first.

The test itself now ignores comment lines when asking which suites read a file.
Suites cite each other in prose constantly, and counting those pushed every
rule towards selecting everything - which is a different failure from the one
this guards. Over-selection is still the safe direction; this is about what
"reads it" means.
2026-10-07 18:49:53 -07:00
branderson c8fd2dc605 Treat an empty ssh_key file as no key, rather than refusing it
tests / confined (conversation) (pull_request_target) Successful in 1m12s
tests / confined (proxy) (pull_request_target) Successful in 1m26s
tests / confined (providers) (pull_request_target) Successful in 2m13s
tests / confined (launch) (pull_request_target) Successful in 2m33s
tests / confined (share) (pull_request_target) Successful in 2m17s
tests / runner-env (confined) (pull_request_target) Successful in 1m7s
tests / confined (remote) (pull_request_target) Successful in 3m9s
tests / confined (shellcheck) (pull_request_target) Successful in 1m34s
tests / confined (project) (pull_request_target) Successful in 3m36s
tests / confined (audit) (pull_request_target) Successful in 3m56s
tests / confined (static) (pull_request_target) Successful in 3m32s
tests / confined (config) (pull_request_target) Successful in 5m23s
tests / privileged (pull_request_target) Successful in 6m42s
tests / runner-env (privileged) (pull_request_target) Successful in 56s
Refusing it outright was the first shape of the commit above and it broke most
of the suite: a great many fixtures use an empty file as a stand-in for a path,
which is a configuration dejima used to wave through.

An empty file is not a key - it loads nothing in the container - so it cannot be
what withdraws the forwarded agent either. It now counts as no key at all, which
costs nothing for a workflow that never pushes and lands in the refusal that
already exists for having neither, with the file named.

The fixtures write a byte into those files, because what they were describing
is a path to a key and an empty one no longer is.
2026-10-07 18:44:40 -07:00
branderson ba2158e0e8 Say what the gitea CLIs confine, and what the push hook does not see
"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" overstates it by one
step. origin lives in .git/config, inside the read-write project mount, and the
gitea token is account-wide - so a session that rewrites origin reaches another
repository on the same host. What bounds that is the token's scope, as it is
for gh.

And the push hook's comment claimed git's own options were the only thing a
spelling could vary. A segment's first word still has to be git, so a
`FOO=bar git push` prefix is a push this does not see - the PATH shadow does,
which is the division of labour the pair exists for rather than a gap in it.
2026-10-07 18:44:33 -07:00
branderson 51c0a8bc65 Render the briefing as the session user, and test the pty tty(1) reports on
The renderer reads the project's own .dejima.md and asks the package managers
what is installed, with its cwd inside the workspace a session can write.
Nothing about that needs root - and root got the answer wrong too: the
dependency layers install npm and cargo packages as the sandbox user, into that
user's HOME, so root's cargo home held none of them and every declared cargo
dependency was reported missing. hooks/sandbox-context.sh already reaches the
renderer as that user, since a hook runs inside the session; this is the other
route agreeing with it. The file it writes stays root-owned and read-only,
which is the half that must not be deprivileged.

The pty chown guarded on stdout and then asked `tty`, which reports on stdin.
With one attached and the other not, the path came back empty and the chown
silently did nothing - the nvim clipboard fallback failing with nothing to
point at.
2026-10-07 18:05:24 -07:00
branderson d418de83df Scrub the forge credential before the daemons that inherit it start
The published-ranges fetch is the only thing in the firewall path that needs
GH_TOKEN, and the header derived from it. Everything started after it - the
restricted resolver, the two log readers, the shipper - parses content the
session's own traffic decides, and inherits the environment of the process that
started it.

Both are unset as soon as the request that uses them is made. The test asserts
the ORDER rather than the presence of the unset, because an unset after the
daemons start is the same as none at all - and it also pins that the header is
still there for the fetch, so the scrub cannot pass by breaking the request.
2026-10-07 18:02:43 -07:00
branderson 225abccece Take back the any-port element when a forge-class permit half-lands
permit_address puts a forge-class address in literal_v4 and then in forge_v4.
If the second add failed, the entries file rolled back and the command died -
but the first element stayed, so the address the forge class says is HTTPS-only
was permitted on every port for the rest of the session, and neither `entries`
nor `status` showed it, because the record of it had been undone.

The element goes back too. If that removal itself fails there is nothing left to
try, so it says so and names the restart rather than reporting a narrowing that
did not happen.

Driven with a stub nft, since the real one needs a namespace to act on: the
half-landed case, the whole-success case which must undo nothing, and the
open-class case where there is no second set and so nothing to take back.
2026-10-07 17:58:32 -07:00
branderson 43081852ea Unmount the operator's ssh-agent when a dedicated key is configured
The forwarded agent was mounted whenever the workflow pushes, and entrypoint.sh
merely re-pointed SSH_AUTH_SOCK at a fresh agent holding the sandbox key. The
host agent stayed connectable at the path docker-compose.yml names, so
`SSH_AUTH_SOCK=/ssh-agent ssh ...` signed with the operator's personal
identities for the whole session.

Choosing a dedicated key has to mean the personal agent is not in the container,
not that it is merely not the default. bin/dejima now blanks SSH_AUTH_SOCK when
a key is configured, the same way it does for a workflow that never pushes, so
${SSH_AUTH_SOCK:-/dev/null} mounts nothing.

An empty key file is refused rather than accepted: it passes the is-a-file check,
loads nothing in the container, and is now what stops the agent being mounted -
the three together would be a pushing session with no credential at all.

Both the key spelling and [identity.default] ssh_key are covered, since
config_get resolves the identity's value for that key.
2026-10-07 17:57:28 -07:00
branderson 4adfe9b36c Read a GraphQL document that arrives attached to its flag
tests / confined (conversation) (pull_request_target) Successful in 1m13s
tests / confined (proxy) (pull_request_target) Successful in 1m24s
tests / confined (providers) (pull_request_target) Successful in 2m13s
tests / confined (launch) (pull_request_target) Successful in 2m31s
tests / confined (share) (pull_request_target) Successful in 2m15s
tests / runner-env (confined) (pull_request_target) Successful in 1m5s
tests / confined (remote) (pull_request_target) Successful in 3m7s
tests / confined (shellcheck) (pull_request_target) Successful in 1m33s
tests / confined (project) (pull_request_target) Successful in 3m36s
tests / confined (audit) (pull_request_target) Successful in 3m58s
tests / confined (static) (pull_request_target) Successful in 3m32s
tests / confined (config) (pull_request_target) Successful in 5m19s
tests / privileged (pull_request_target) Successful in 6m50s
tests / runner-env (privileged) (pull_request_target) Successful in 1m1s
The mutation check and the file-indirection check ran after the loop had
skipped every dash-prefixed argument, so a flag carrying its value attached put
the document somewhere neither looked: `--field=query=mutation{...}` and
`-Fquery=mutation{...}` passed, while the positional `graphql` beside them
still counted as a readable target. With the github forge the push token is
write-capable, so that is the escalation the inline spelling was closed against.

Both checks now run over every argument. Neither needs to know which of the
CLI's flags take a separate value, which is what lets them look at all of them
- unlike the repository scoping below, which does, and which still sees only
the positionals.
2026-10-07 17:23:36 -07:00
branderson 12a222cace Let a positional remote beat --repo, the way git documents it
git: "this option is equivalent to the <repository> argument. If both are
specified, the command-line argument takes precedence." This read it the other
way round, so `git push --repo=origin other-remote feature` resolved and
approved origin's URL, treated other-remote as a refspec, and let git push to a
host nothing had checked against DEJIMA_PUSH_HOSTS.

The library's own header says a disagreement between this parser and git's has
to fail as a refusal. This one failed as an unchecked push - the test for it
shows ssh reaching the unvetted host when the precedence is put back.

`git push --repo=origin main` is a push to a remote called "main" under that
rule, which is how git reads it too, so it is now refused as an unresolvable
remote rather than by the branch policy.
2026-10-07 17:21:33 -07:00
branderson af0fce3326 Read the force marker as part of the refspec, not of the branch
`git push origin +main` put the destination through the branch policy as
"+main", which matched no entry, so the one spelling that escaped was the
destructive one. `+refs/heads/main` escaped the same way, since the prefix also
stopped the refs/heads/ strip from landing.

One leading `+` now comes off before the destination is read. A `+` on a branch
nothing protects is still an ordinary push, and the test says so - otherwise the
strip could be doing the refusing rather than the policy.
2026-10-07 17:19:20 -07:00
branderson 2217d6d3c3 Merge pull request 'Close the two gaps a review found in the guards, and rewrap what was written to a forge's rules' (#373) from review-1007 into development
Reviewed-on: #373
Reviewed-by: branderson <brad@branderson.io>
2026-10-07 16:51:38 -07:00
branderson 300097dcae Make the mutation boundary symmetric
tests / confined (conversation) (pull_request_target) Successful in 1m13s
tests / confined (proxy) (pull_request_target) Successful in 1m26s
tests / confined (providers) (pull_request_target) Successful in 2m12s
tests / confined (launch) (pull_request_target) Successful in 2m27s
tests / confined (share) (pull_request_target) Successful in 2m12s
tests / runner-env (confined) (pull_request_target) Successful in 1m5s
tests / confined (remote) (pull_request_target) Successful in 3m5s
tests / confined (shellcheck) (pull_request_target) Successful in 1m33s
tests / privileged (pull_request_target) Successful in 6m37s
tests / confined (project) (pull_request_target) Successful in 3m33s
tests / confined (audit) (pull_request_target) Successful in 3m52s
tests / confined (static) (pull_request_target) Successful in 3m29s
tests / confined (config) (pull_request_target) Successful in 5m12s
tests / runner-env (privileged) (pull_request_target) Successful in 57s
The right side still required whitespace or a brace, and GraphQL treats commas
and comments as ignored tokens - so `mutation,{mergePullRequest}` and
`mutation#c` followed by the body on the next line are valid documents that sat
just outside it. The bare word with nothing after it did too.

Both boundaries are now anything that cannot continue an identifier. The class
excludes `_` on both sides, so a field called foo_mutation is still not a
mutation, and the three honest queries in the test still pass - a check that
refused one would cost an agent the only route the API has to review threads.
2026-10-07 10:05:21 -07:00
branderson 30a2a5c288 Cut the second and third consequence from two comments
tests / confined (conversation) (pull_request_target) Successful in 1m15s
tests / confined (proxy) (pull_request_target) Successful in 1m26s
tests / confined (providers) (pull_request_target) Successful in 2m10s
tests / confined (remote) (pull_request_target) Successful in 3m5s
tests / confined (shellcheck) (pull_request_target) Successful in 1m33s
tests / confined (project) (pull_request_target) Successful in 3m29s
tests / confined (audit) (pull_request_target) Successful in 3m47s
tests / confined (static) (pull_request_target) Successful in 3m24s
tests / confined (config) (pull_request_target) Successful in 5m8s
tests / privileged (pull_request_target) Successful in 6m49s
tests / confined (launch) (pull_request_target) Successful in 2m27s
tests / confined (share) (pull_request_target) Successful in 2m10s
tests / runner-env (confined) (pull_request_target) Successful in 1m4s
tests / runner-env (privileged) (pull_request_target) Successful in 57s
Both state a real reason and then keep going. The entrypoint's shadow comment
spent three sentences where the first carries the bug, and the harness.conf one
restated the entrypoint's fail-closed story when the harness-local fact is
that it cannot host the guards, not that nothing does.

Measured against this repo's own standard for comments, which asks for the
first consequence and leaves the chain to a reader who needs it.
2026-10-07 09:23:34 -07:00
branderson ab67534887 Rewrap the prose that was written to a forge body's rules
The host-entry section went in as four single lines of 236 to 487 characters,
in a file hard-wrapped at eighty. One-long-line-per-paragraph is for pull
request and issue bodies, where the forge turns a source newline into a visible
break; a repository document has no such reader.

Also pins the precondition behind the repository rules: they are applied to the
commands merge_block and disclose name, because those are the only keys that
name a CLI - so a forge declaring repo_flags and neither of them would get the
rule applied to nothing, silently, there being no command for it to be absent
from. Stated in providers.md for a third-party manifest, and asserted over the
shipped forges so the statement cannot quietly stop being true.
2026-10-07 09:22:15 -07:00
branderson bb4f06657f Read a mutation that is not the document's first operation
The left boundary was whitespace or `=`, so a GraphQL document carrying more
than one operation - `query A{x}mutation B{mergePullRequest(...)}` - put `}`
immediately before the mutation and walked past the check. Whether a given CLI
can select that second operation is beside the point: the boundary should not
be the thing deciding.

It is now anything that cannot continue an identifier, which still excludes
`_`, so a field named foo_mutation is not read as a mutation. Both halves are
tested, because a check that refused an honest query would cost an agent the
one route the API has to review threads.
2026-10-07 09:20:20 -07:00
branderson 6d77293880 Let the push hook see the one spelling that reaches neither layer
`/usr/bin/git push origin main` stepped over the PATH shadow by naming the
absolute path, and the hook compared the whole first word, so it did not see a
git invocation at all. Both layers allowed it.

That form is the example guard-lib.sh's own header gives as the reason the two
layers exist, and the identical spelling was already basenamed and tested for
the forge CLI beside it. The hook now basenames too, as the shadow does by
reading its own $0.

A basename is not a suffix match: `mygit push` and `git-annex push` stay
commands this has nothing to say about, and the test says so.
2026-10-07 09:18:56 -07:00
branderson c7521c7b6d Merge pull request 'One copy of the push and merge policy, and the forms that walked past it' (#372) from guards-365 into development
Reviewed-on: #372
Reviewed-by: branderson <brad@branderson.io>
2026-10-06 23:13:49 -07:00
branderson 3474c96f95 Prove in a container that the shadow can load its policy
tests / confined (conversation) (pull_request_target) Successful in 1m17s
tests / confined (proxy) (pull_request_target) Successful in 1m26s
tests / confined (providers) (pull_request_target) Successful in 2m12s
tests / confined (launch) (pull_request_target) Successful in 2m29s
tests / confined (share) (pull_request_target) Successful in 2m10s
tests / runner-env (confined) (pull_request_target) Successful in 1m4s
tests / confined (remote) (pull_request_target) Successful in 3m4s
tests / confined (shellcheck) (pull_request_target) Successful in 1m31s
tests / confined (project) (pull_request_target) Successful in 3m32s
tests / confined (audit) (pull_request_target) Successful in 3m52s
tests / confined (static) (pull_request_target) Successful in 3m52s
tests / confined (config) (pull_request_target) Successful in 5m9s
tests / privileged (pull_request_target) Successful in 6m50s
tests / runner-env (privileged) (pull_request_target) Successful in 1m0s
Every git command in a session now goes through a shadow that sources a file
the Dockerfile copies in, so a COPY line that stopped matching the path would
break git outright in every session - and the confined suites would stay green,
because they point the guards at the checkout rather than the image.

The file-wide pair sets no DEJIMA_PUSH_HOSTS, which fails closed, so any push
is refused and the refusal itself is the evidence the policy loaded. The
pass-through half is asserted beside it: everything that is not a push has to
reach real git, or the shadow is a broken git rather than a guarded one.
2026-10-06 22:39:31 -07:00
branderson 4c81605df6 Say that the hooks and the shadows read the same decision
Three documents described the hooks as regex-matching a command string while
the shadows compared argv, which is what made the overlap read as two
implementations rather than two entry points. development.md's table gains the
library; configuration.md says which pushes are refused for being unreadable
rather than checked, since that is the part an operator meets as a refusal.

merge-guard.sh is also no longer only about merges, so configuration.md says
what it applies.
2026-10-06 22:38:42 -07:00
branderson 07d863d6dc Close the push and api forms that walked past the guards
Four bypasses, all of them a parser resolving exactly one thing and checking
that (#365).

`--all` and `--mirror` were opaque flags, and only the first positional after
the remote was read. So `git push --all origin` from a feature branch pushed
main, and `git push origin feature main` checked feature alone. Every
positional after the remote is now a refspec and every one is checked; --all
is checked against every local branch, since that is what it pushes. --mirror
is refused outright: it writes every ref AND deletes the remote refs this
checkout does not have, so a mirror from a repository with no main is how the
remote's main disappears, and no branch list makes that readable off the
command.

`--git-dir` and `--work-tree` were consumed and ignored, so a push carrying
one resolved the remote in the mounted project, found it allowed, and then ran
against a repository whose origin points somewhere else. Refused for a push -
and only for a push, since they say nothing about any other git command.
Honouring them would mean reimplementing how they combine with -C and with
each other.

`gh api` was scoped only when the endpoint sat under the declared segment, so
`user/repos`, `/orgs/<org>/repos` and `graphql` named no repository for the
guard to compare and nothing was refused. Under that subcommand the question
is now inverted: a target the guard CAN read has to be present. Telling an
endpoint apart from a header or field value would mean knowing which of the
CLI's flags take a separate value, which is the coupling this guard exists to
avoid - asking whether a readable target is there needs no such knowledge.

graphql stays readable, because dejima's own github-pr reads review threads
through it and the API has no other route to them, and a query can only read
what the token already could. A mutation does not: it is what an agent reaches
for once `gh pr merge` refuses, and no repository scoping can be read out of a
GraphQL document. A field value read from a file is refused under the same
subcommand, since it keeps the document out of the command entirely.
2026-10-06 22:34:31 -07:00
branderson 2d3a8ef32a Put the push and merge policy in one place both layers read
Each rule existed twice: as a PATH shadow over the command, and as a
PreToolUse hook over the command string a harness reports. The overlap is
deliberate - a hook sees `/usr/bin/git push`, which walks past the shadow, and
a shadow sees a push from an editor integration or a script, which the harness
never reports - so neither can be dropped. What they cannot do is disagree,
and they had: the shadow skipped `-o` and its value and read `--repo`, the
hook did neither, so `git push -o x origin main` was fine to one and a hard
refusal from the other for the same invocation in the same session.

docker/guard-lib.sh now holds the decision and all four call it. Each hook's
job is to turn a command string into the argv the library reads, which is also
how the hooks inherit the shadows' indifference to spacing and quoting - and
why merge-guard.sh no longer escapes regex metacharacters out of a block
pattern, since there is no regex left to inject into.

The library takes the stricter reading wherever the two differed: `HEAD`
resolves to the current branch, `--repo` names the remote, and a flag's value
is skipped rather than read as a positional.

entrypoint.sh derives the set of CLIs it links the shadow over from the same
function the hook checks a command against, so what is shadowed and what is
checked cannot drift apart either.
2026-10-06 22:32:04 -07:00
branderson f0cf3b98f7 Merge pull request 'Finish the check gaps, and clamp the host entry to the ports it was told to open' (#371) from checks-364 into development
Reviewed-on: #371
Reviewed-by: branderson <brad@branderson.io>
2026-10-06 21:55:33 -07:00
branderson 513187cef3 Merge pull request 'Correct the documents that stated the opposite of the code' (#370) from docs-contradictions into development
Reviewed-on: #370
Reviewed-by: branderson <brad@branderson.io>
2026-10-06 21:45:59 -07:00
branderson 4caf9d8d76 Keep the harness explanation out of the opening pitch
tests / confined (conversation) (pull_request_target) Successful in 1m13s
tests / confined (proxy) (pull_request_target) Successful in 1m27s
tests / confined (providers) (pull_request_target) Successful in 2m10s
tests / confined (launch) (pull_request_target) Successful in 2m26s
tests / confined (share) (pull_request_target) Successful in 2m8s
tests / runner-env (confined) (pull_request_target) Successful in 1m4s
tests / confined (remote) (pull_request_target) Successful in 3m4s
tests / confined (shellcheck) (pull_request_target) Successful in 1m29s
tests / confined (project) (pull_request_target) Successful in 3m30s
tests / confined (audit) (pull_request_target) Successful in 3m47s
tests / confined (static) (pull_request_target) Successful in 3m19s
tests / confined (config) (pull_request_target) Successful in 5m8s
tests / privileged (pull_request_target) Successful in 6m5s
tests / runner-env (privileged) (pull_request_target) Successful in 1m0s
The README's first paragraphs are the shortest attention budget in the project,
and a sentence there grew into three explaining which harness install.sh seeds.
That belongs in the install steps, where a reader meets the file and can be
surprised by it, so step 4 now says the starter config states claude-code and
which line to change.

The budget test stopped at the pitch, so the paragraph immediately after it -
the same real estate - had none. It now covers everything before "What bounds
it:", which is where the opening ends. Verified by putting the long version
back: 189 words against a budget of 160.
2026-10-06 21:24:58 -07:00
branderson bad60b2cbe Give the connection log a permitted destination that is not the host
tests / confined (conversation) (pull_request_target) Successful in 1m11s
tests / confined (proxy) (pull_request_target) Successful in 1m23s
tests / confined (providers) (pull_request_target) Successful in 2m6s
tests / confined (launch) (pull_request_target) Successful in 2m26s
tests / confined (share) (pull_request_target) Successful in 2m9s
tests / runner-env (confined) (pull_request_target) Successful in 1m4s
tests / confined (remote) (pull_request_target) Successful in 3m0s
tests / confined (shellcheck) (pull_request_target) Successful in 1m30s
tests / confined (project) (pull_request_target) Successful in 3m27s
tests / confined (audit) (pull_request_target) Successful in 3m48s
tests / confined (static) (pull_request_target) Successful in 3m20s
tests / confined (config) (pull_request_target) Successful in 5m7s
tests / privileged (pull_request_target) Successful in 6m27s
tests / runner-env (privileged) (pull_request_target) Successful in 1m0s
The log test used the bridge gateway on port 9 for its permitted half, which
worked because the host entry was open on every port - so the clamp turned its
allow record into a refusal and the test failed in CI.

It needs a destination nothing has to answer on, since the allow record is
written when the first packet is accepted rather than when a service replies,
and waiting for a reply would make this a test of the network. A documentation
address permitted as a literal is that: in @literal_v4, on any port, routed
nowhere. Permitted live rather than declared at launch, so the file-wide pair
keeps the permitted set a real launch has.
2026-10-06 17:51:14 -07:00
branderson b906f33ab5 Export the two host-entry variables the launcher passes through
tests / confined (conversation) (pull_request_target) Successful in 1m20s
tests / confined (proxy) (pull_request_target) Successful in 1m26s
tests / confined (providers) (pull_request_target) Successful in 2m10s
tests / confined (launch) (pull_request_target) Successful in 2m25s
tests / confined (share) (pull_request_target) Successful in 2m9s
tests / runner-env (confined) (pull_request_target) Successful in 1m4s
tests / confined (remote) (pull_request_target) Successful in 3m4s
tests / confined (shellcheck) (pull_request_target) Successful in 1m31s
tests / confined (project) (pull_request_target) Successful in 3m28s
tests / confined (audit) (pull_request_target) Successful in 3m48s
tests / confined (static) (pull_request_target) Successful in 3m18s
tests / confined (config) (pull_request_target) Successful in 5m10s
tests / privileged (pull_request_target) Failing after 7m3s
tests / runner-env (privileged) (pull_request_target) Successful in 1m2s
Every other name in filter_env is derived here, so it is exported where it is
assigned. These two come from the operator's own environment, and `docker run
-e NAME` reads the launcher's environment rather than its shell variables - so
a value set without being exported would reach the filter container as nothing
at all, and the clamp's escape hatch would silently do nothing.
2026-10-06 14:59:42 -07:00
branderson 2f921a0d6a Correct the two documents that describe the host as reachable
development.md described the runtime assertion as proving a gateway-bound
service reachable, which is the behaviour the clamp removes; the positive
control it explains now comes from a second container given one port.

remote-access.md said the policy deliberately permits traffic to the
default-route address. The address is still the one a sandbox can route to,
and that is what makes a front door bound to it an escape hatch - but no port
on it is open now, so the filter refuses such a door too. Said as the second
layer rather than the first, because binding the proxy correctly is still the
thing to get right.
2026-10-06 14:57:37 -07:00
branderson 79e18b916a Prove the host clamp holds against a real listener
The test asserted that a host service bound to the bridge gateway was
reachable, which is the behaviour the clamp removes. It now asserts both
halves: the file-wide pair, which names no ports, cannot reach a listener on
the gateway address, and a pair given that one port can reach it and not a
second listener on the same address at another port.

The loopback half stays - the clamp does not replace it, since a host service
bound to 127.0.0.1 is not on the gateway address at all - and the positive
control stays for the reason it was written: without one, a nonzero status is
equally satisfied by docker exec failing or bash missing from the image.
2026-10-06 14:46:56 -07:00
branderson ba2fb5b434 Describe the host entry as what it reaches
The allowlist said "the Docker host itself (not its whole subnet)", which is
accurate about addresses and said nothing about ports - so a reader checking
whether the boundary held would not have learned that every port the host
published was reachable, and now would not learn that none is.

Its own section, because the reason it is clamped is the reason it exists: it
is the one destination nobody declared.
2026-10-06 14:46:56 -07:00
branderson 03b96b2da8 Clamp the host entry to the ports it was told to open
Every other destination in the policy is there because something declared it.
The host entry is not: it is the gateway of the container's default route,
derived from the routing table, and it was permitted on every port. Docker
publishes container ports on that address, so a session reached the host's
sshd, anything bound to 0.0.0.0, and every published port of every other
container on the machine - including dejima's own remote-access front door,
which is a host shell behind a tmux prefix key (#363).

It now opens none by default. Nothing in a launch needs it: a host-resident
forge, a host-resident Loki and a self-served model endpoint are each reached
through their own entry, by name or as a literal address, and each lands in a
set of its own. EGRESS_HOST_PORTS takes TCP ports and low-high ranges for
anything else, `any` restores the unclamped rule explicitly, and a value that
is not a port stops the container rather than becoming a rule nobody meant.

The address stays named, with its own refusal rather than a fall-through to the
end of the chain, so reaching for the host is a counted refusal.

bin/dejima now passes EGRESS_HOST_PORTS and EGRESS_HOST_ADDR to the filter
container. Neither has a config key and both default inside egress-policy, so
without that the clamp would have no escape hatch an operator could reach.
2026-10-06 14:46:47 -07:00
branderson f8726b2ea9 Ask the kernel for the forwarder's port instead of naming five
proxy.bats named 18398, 18399, 18443, 18444 and 18445, while run-confined.sh
states that the suites binding a socket ask for a free one. The forwarder takes
its port as configuration rather than asking for one itself, so a test has to
supply a number - but it can supply a number the kernel just said was free.

That leaves a window where something else takes the port between the probe and
the bind. It is the smaller of the two failure modes: a constant fails on every
run on a machine already using it, and these suites run on developer machines.
2026-10-06 14:38:35 -07:00
branderson 1d91d07d5c State the free port the ttyd tests were reading from the machine
stub_ss_no_listeners existed and said in its own comment that it was the
precondition for every other ttyd test; one test called it. Eleven others wrote
ttyd_port = 12345 and asserted success, so they failed on any host holding that
port - the same shape as #56, and as the default-port case that produced this
helper.

It is now called beside every stub_ttyd but one. That one is the port-in-use
refusal, which binds a real socket and needs the real ss to see it; the
exclusion is stated there rather than left to be rediscovered.

Verified by holding 127.0.0.1:12345 and running the suite: green with the
stubs, six failures without them.
2026-10-06 14:38:35 -07:00
branderson 172988a67c Test the only validation standing between a forge's document and the allowlist
egress-policy pipes the fetched ranges and the operator's own forge addresses
through cidr-collapse and loads whatever comes back, so both halves of it are
load-bearing: the collapse keeps the interval sets small, and the validation is
the only thing that looks at what the published document actually contained.
Nothing exercised either.

Six cases, over documentation addresses: the merge, the refusal to over-merge
into addresses the forge never published, per-family output with v4 first,
stdin with both separators, a bare address and a host-bits range, and empty
input exiting clean so egress-policy's own empty-list message is reached. The
refusal case checks that nothing usable reaches stdout, since a caller
substituting partial output would load a short allowlist and look fine.

Verified by mutation: swallowing the ValueError instead of exiting fails the
refusal case, and collapsing both families together fails the merge case.
2026-10-06 14:34:55 -07:00
branderson a7d7ec9dda Stop the gate counting a skipped test as one that ran
bats emits a skip as `ok N <name> # skip <reason>`, so the summary counted a
test that declined to run as a test that passed, and named it nowhere. Several
suites skip on host state - an empty /opt/dejima, a missing ss or node - so the
number in the ran column was the only thing an operator had, and it was
overstating how much of the suite the run covered.

Skips now have their own column and their names are printed under the table.
The ran column is what executed. "ran nothing" still keys off the TAP lines
emitted, so a suite bats refused outright is still caught.
2026-10-06 14:33:44 -07:00
branderson 6d6e40d243 Make an absence assertion mean absent rather than unreadable
grep exits 2 for a file it could not read, which satisfies `-ne 0` exactly as
well as "the pattern is not there". Eighty-nine assertions in this suite are
the dangerous-thing-is-absent ones - no sudo in the Dockerfile, no docker
socket in compose, no ~/.ssh mount - so a file renamed or moved out from under
them would turn all eighty-nine green having checked nothing.

`-eq 1` is the status that means the file was read and the pattern was not in
it. All 503 tests still pass, so every one of these does read a real file
today; pointing one at a path that does not exist now fails it.

The new test walks the line after each `run grep` with awk rather than
grepping this file for the shape, which would match its own text.
2026-10-06 14:25:30 -07:00
branderson eb5b34f3be Refuse to build a page that silently drops a source
tests / confined (config) (pull_request_target) Successful in 5m55s
tests / confined (conversation) (pull_request_target) Successful in 1m20s
tests / confined (launch) (pull_request_target) Successful in 2m31s
tests / confined (project) (pull_request_target) Successful in 3m43s
tests / confined (providers) (pull_request_target) Successful in 2m15s
tests / confined (proxy) (pull_request_target) Successful in 1m34s
tests / confined (remote) (pull_request_target) Successful in 2m49s
tests / confined (share) (pull_request_target) Successful in 2m0s
tests / confined (static) (pull_request_target) Successful in 3m13s
tests / confined (shellcheck) (pull_request_target) Successful in 1m31s
tests / privileged (pull_request_target) Successful in 6m27s
tests / runner-env (confined) (pull_request_target) Successful in 1m4s
tests / runner-env (privileged) (pull_request_target) Successful in 56s
tests / confined (audit) (pull_request_target) Successful in 1m57s
A src/*.js omitted from CLIENT_SOURCES is hashed into the manifest and fails
no test - the staleness check and the Sources stamp both agree with the tree.
It is simply never inlined, so the page ships missing a module.

The list cannot become a glob, because its order is load-bearing: each file
attaches to the shared DEJIMA object and later ones parse through earlier
ones. So the build reads src/ and refuses when the directory holds a module
the list does not. Verified by removing osc52.js from the list.
2026-10-06 14:10:44 -07:00
branderson 9329c722cd Name the briefing and the guards by what they are, not by one route
The README said a SessionStart hook briefs the session. Neither shipped
harness takes that route: claude-code gets the briefing as a system prompt
through briefing_args, cline reads the file entrypoint.sh writes root-owned.
The hook is the fallback for a harness doing neither, which is a detail for
docs/configuration.md rather than the page someone reads first.

The blast-radius row - identical in security.md and threat-model.md, and
pinned identical by a test - credited the PreToolUse hooks for bounding an
agent that is not yet hostile, in a table that is otherwise harness-agnostic.
The push and merge guards are PATH shadows and hold for every harness; only
the denylist is a hook. The row's own radius column already claims both.
2026-10-06 14:10:38 -07:00
branderson 978480c73d Hash the builder into its own manifest
tests / confined (conversation) (pull_request_target) Successful in 1m42s
tests / confined (proxy) (pull_request_target) Successful in 1m51s
tests / confined (providers) (pull_request_target) Successful in 2m40s
tests / confined (launch) (pull_request_target) Successful in 2m57s
tests / confined (share) (pull_request_target) Successful in 2m13s
tests / runner-env (confined) (pull_request_target) Successful in 1m5s
tests / confined (remote) (pull_request_target) Successful in 3m33s
tests / confined (shellcheck) (pull_request_target) Successful in 1m32s
tests / confined (project) (pull_request_target) Successful in 4m7s
tests / confined (audit) (pull_request_target) Successful in 4m58s
tests / confined (static) (pull_request_target) Successful in 4m34s
tests / confined (config) (pull_request_target) Successful in 8m15s
tests / privileged (pull_request_target) Successful in 11m34s
tests / runner-env (privileged) (pull_request_target) Successful in 1m1s
web/build.mjs was not among the sources it stamps, so editing it - the
escaping, the fill order, which files get inlined - produced a different page
with a byte-identical manifest and stamp. The staleness check passed on a
page nobody had rebuilt, and web/README.md defends committing the artifact
precisely so nobody has to run the build, which is what makes that check the
only thing standing there.

The test hashes the same set, so the two cannot disagree about what counts as
a source. Verified by editing the builder and leaving the page alone, which
now fails.
2026-10-06 13:54:49 -07:00
branderson 4f0fbff39c Stop a comment claiming no harness hosts the guards, beside one that does
claude-code declares four. The comment sat next to cline's decision to declare
none and gave the reason as "no harness hosts them any more" - which is the
reasoning that made the missing git shadow a hole rather than a cost, since
the shadow was not installed under the default workflow either.

It now says what is true: this harness cannot host them, the PATH shadows
cover a harness that does not, and those shadows are installed whatever the
workflow says - so declaring none here costs the hook layer, not the guard.
2026-10-06 13:54:49 -07:00
branderson 34ae5c4044 Correct three documents that stated the opposite of the code
Two harness keys were typed `?template` where the schema says `?args`, and
the code's own comment explains the distinction exists because calling them
template "promised a substitution that does not happen" - re-promised to
exactly the audience writing a manifest against that table. assumed_backend
was in the prose and missing from the table of every key, and it is the one
key there that writes into the operator's config.

[github] token_file was documented as read whichever forge is selected. Both
it and the github forge's push_token_file fill GH_TOKEN, the forge's wins,
and the launch says so - which is the half that decides which token can push.
providers.md already had it right.

README said neither harness is a default without saying install.sh writes a
starter config selecting one. Both are true: dejima has no default, which is
why the generated file has to state a choice. Left as it was, a reader is
surprised by their own config.
2026-10-06 13:54:49 -07:00
branderson c400ff3ad0 Merge pull request 'The checks everything else leans on: shellcheck coverage, release pass, suite map' (#369) from review-checks into development
Reviewed-on: #369
Reviewed-by: branderson <brad@branderson.io>
2026-10-06 13:46:32 -07:00
branderson 57c76aa573 Select every suite that reads a changed path, not the one that owns it
tests / confined (conversation) (pull_request_target) Successful in 1m8s
tests / confined (proxy) (pull_request_target) Successful in 1m19s
tests / confined (providers) (pull_request_target) Successful in 2m6s
tests / confined (launch) (pull_request_target) Successful in 2m20s
tests / confined (share) (pull_request_target) Successful in 2m2s
tests / runner-env (confined) (pull_request_target) Successful in 58s
tests / confined (remote) (pull_request_target) Successful in 2m56s
tests / confined (shellcheck) (pull_request_target) Successful in 1m23s
tests / confined (project) (pull_request_target) Successful in 3m22s
tests / confined (audit) (pull_request_target) Successful in 3m42s
tests / confined (static) (pull_request_target) Successful in 3m10s
tests / confined (config) (pull_request_target) Successful in 5m3s
tests / privileged (pull_request_target) Successful in 6m9s
tests / runner-env (privileged) (pull_request_target) Successful in 55s
bin/dejima-provider-lib.sh selected `providers` alone while config.bats sets
LIB= to that very file; bin/dejima-harness-lib.sh selected a suite that does
not mention it at all. Twenty rules were narrower than the suites that read
them, and static.bats - which asserts over the whole tree - was missing from
nearly all of them.

The --changed lane is what runs between edits, so a narrow rule reports an
edit as green from a selection that never loaded the file.

.dejima.md says nothing can tell that a rule is too narrow. A reader cannot;
a test can: for each mapped path, every suite whose text names it has to be in
that rule's answer. Over-selecting costs a suite run and under-selecting costs
a regression, so the rules now err wide.

The check matches on a basename, and on the full path where the tree holds
more than one file by that name - without which web/README.md inherits every
mention of the root one.
2026-09-25 17:19:06 -07:00
branderson d188eb20be Name a manual check for the boundary in the release pass
Its patterns covered the launcher, the forges, the guards and the web page,
and nothing in the egress policy, the capability floor or the credential
path. A release that rewrote the firewall was therefore reported as touching
nothing that needs a manual check - over exactly the things this project
exists to hold, in the one place that says what nothing has verified.

Three patterns, each naming what only a session can show: a refusal that is
immediate rather than a hang, a capability set read from a running container,
and the credential paths the suites reach least.

The test drives each boundary path against the script's own patterns, so a
pattern edited to stop matching is caught rather than the list here drifting.
2026-09-25 17:19:06 -07:00
branderson 88779bd656 Shellcheck the two libraries the shebang filter dropped
The list matches a shebang, with named exceptions for the sourced fragments
that carry none - and that exception was written as a pattern rather than a
roll of names precisely so nobody would have to remember to extend it. It was
still short by one directory: bin/*-lib.sh, which is dejima-harness-lib.sh,
the code deciding which host paths may be mounted where.

47 files became 49. The one finding it surfaced is SC2015 on an `A && B || C`
that is correct - there is no then-branch and the block exits - so it is
rewritten as the `if` it means rather than suppressed.

The test derives what must be covered from the tree rather than from a list,
by reading each file's shebang, so a fragment added in a new place fails this
instead of being skipped in silence.
2026-09-25 17:19:06 -07:00
branderson 7535f4795b Merge pull request 'Three HIGH findings from the full-tree review' (#368) from report-high into development
Reviewed-on: #368
Reviewed-by: branderson <brad@branderson.io>
2026-09-25 16:57:49 -07:00
branderson bd01875c7f Say what length arrived, end the connection, and test the chunked half
tests / confined (conversation) (pull_request_target) Successful in 1m7s
tests / confined (proxy) (pull_request_target) Successful in 1m20s
tests / confined (providers) (pull_request_target) Successful in 2m10s
tests / confined (launch) (pull_request_target) Successful in 2m31s
tests / confined (share) (pull_request_target) Successful in 2m12s
tests / runner-env (confined) (pull_request_target) Successful in 59s
tests / confined (remote) (pull_request_target) Successful in 3m8s
tests / confined (shellcheck) (pull_request_target) Successful in 1m27s
tests / confined (project) (pull_request_target) Successful in 3m34s
tests / confined (audit) (pull_request_target) Successful in 3m53s
tests / confined (static) (pull_request_target) Successful in 3m19s
tests / confined (config) (pull_request_target) Successful in 5m15s
tests / privileged (pull_request_target) Successful in 6m10s
tests / runner-env (privileged) (pull_request_target) Successful in 54s
Three small things around the body cap.

The refusal log named the cap rather than the value, so `Content-Length: -1`
and a genuinely oversized upload read identically - while the comment on the
413 path promises the log says what came in.

The 413 left the connection open with the refused body still queued, so the
next parse took those bytes as a request line and a keep-alive client got
garbage 400s instead of one clean refusal.

And only the Content-Length path was tested. The two share a helper today, so
a refactor that inlines the chunked branch would restore the unbounded read
with nothing failing; the chunked shape now has its own case.
2026-09-25 16:38:28 -07:00
branderson d8263120e3 Find a scrubbed value by its components, not by re-reading its path
The display path is ambiguous and always was: a dict key spelled `x[0]` and
element 0 of a sibling list `x` produce the same string, and so does a key
containing a dot. Re-parsing it to find the value again deleted the bystander
and left the secret in the file for the first, and raised KeyError out of the
scrub for the second - after which bin/dejima copies the file in unscrubbed.

That spelling cannot change: finding_id hashes it, and the hash is what a
standing answer in credential-scan.decisions is keyed by. So walk_json now
yields the components it built the path from, and everything that has to find
the value again takes those. The string stays what an operator reads.

The string-parsing deleter is gone rather than left unused, so a future caller
cannot find its way back onto the ambiguous path.

One thing this does not fix: when two locations genuinely share a display path
they share a finding id, so scrubbing removes both. That is the fail-safe
direction - the secret goes - and disambiguating the string would orphan every
recorded decision.
2026-09-25 16:38:28 -07:00
branderson c302aaae96 Test the shadow against re-gating in any spelling, and the real guard
The regression test matched an `if` carrying one of the two lists, so re-gating
written as `[ -n "${DEJIMA_PUSH_HOSTS:-}" ] && ln -sf ...` passed it while
putting the bug straight back - no shadow under the shipped default workflow.
It now requires the link to sit at the top level of the file, carry no test of
its own, and open no conditional in the block it lives in. All three forms were
run against it.

The empty-host refusal was proven by a copy of the guard's loop written in the
test, which is the shape this repository keeps getting caught by: it passes
when a fail-open short-circuit is added above the loop, and fails when the loop
is rewritten harmlessly. It now runs git-guard.sh against a real repository
with the list empty, and checks a permitted host is not refused for the same
reason - so the refusal is the empty list rather than a broken guard.
2026-09-25 16:38:27 -07:00
branderson a899d60d59 Delete scrubbed JSON paths in an order that does not shift the rest
tests / confined (conversation) (pull_request_target) Successful in 1m37s
tests / confined (proxy) (pull_request_target) Successful in 1m45s
tests / confined (providers) (pull_request_target) Successful in 2m32s
tests / confined (launch) (pull_request_target) Successful in 2m50s
tests / confined (share) (pull_request_target) Successful in 2m3s
tests / runner-env (confined) (pull_request_target) Successful in 56s
tests / confined (remote) (pull_request_target) Successful in 3m22s
tests / confined (shellcheck) (pull_request_target) Successful in 1m23s
tests / confined (project) (pull_request_target) Successful in 3m49s
tests / confined (audit) (pull_request_target) Successful in 4m11s
tests / confined (static) (pull_request_target) Successful in 3m8s
tests / confined (config) (pull_request_target) Successful in 5m32s
tests / privileged (pull_request_target) Successful in 12m27s
tests / runner-env (privileged) (pull_request_target) Successful in 54s
del cur[i] moves every later element of a list down one, so applying findings
in document order deleted the wrong ones from the second onwards. Two secrets
in one array left the second secret in the file and removed an innocent value
instead - and the staged file is mounted into the container, so the operator
answers "keep it out" and it goes in silently.

Reachable from anything that carries several token-shaped strings in one list,
which an mcpServers args array is.

Deepest path and highest index first makes each deletion independent of the
ones after it.
2026-09-23 17:27:31 -07:00
branderson 4425d83d88 Refuse a negative Content-Length instead of reading to EOF
int() accepts a negative, a negative passes the `> MAX_BODY_BYTES` test, and a
buffered reader takes read(-1) as "until EOF" - so `Content-Length: -1` walked
past the cap and read an unbounded body into host RAM. The cap's own comment
says it is there because this runs on the host as the operator's user.

The same check now covers chunk sizes, which had the same shape.

A value that is not a number is deliberately left to raise. The suppression
around handle_error is meant to be specific to the client going away, and the
test that proves it uses a non-hex chunk length as the genuine fault that must
still surface - refusing it cleanly here would remove that canary. The bug was
the value that passes the cap and then reads to EOF, not untidy input.
2026-09-23 17:27:31 -07:00
branderson c73fb4702b Install the git shadow always, because an empty host list refuses
DEJIMA_PUSH_HOSTS fails closed: empty means no host may be pushed to, and the
hook's header says that is how a no-forge or non-pushing workflow becomes
enforced rather than merely described. The entrypoint linked the shadow only
when that list or the blocked-branch list was non-empty, so the guard was
absent in exactly the case it exists for.

The shipped default workflow is local-only, which sets push off and no blocked
branches, so that was the ordinary launch rather than a corner. With
claude-code the PreToolUse hook still covered it. cline declares no guards, on
the stated reasoning that the PATH shadows hold regardless - so a cline
local-only session had no push guard of any kind, while the launch report told
the operator the only gap was a full-path git invocation.

This reverses a deliberate decision, and the test that pinned it now carries
the reversal and the reason rather than being deleted. Leaving PATH as the
image left it is right for a shadow with nothing to enforce; the forge shadow
stays conditional for that reason, since an unset merge block genuinely blocks
nothing. A fail-closed list is the opposite case.
2026-09-23 17:27:31 -07:00
branderson 896773bfc5 Merge pull request 'Give the remote access page a favicon' (#362) from web-favicon into development
Reviewed-on: #362
Reviewed-by: branderson <brad@branderson.io>
2026-09-17 18:04:38 -07:00
branderson 8b579fdd59 Merge pull request 'Tell a session that a hang is not the allowlist' (#360) from briefing-refusal into development
Reviewed-on: #360
Reviewed-by: branderson <brad@branderson.io>
2026-09-17 18:04:03 -07:00
branderson f44fe47657 Give the remote access page a favicon
tests / confined (conversation) (pull_request_target) Successful in 1m6s
tests / confined (proxy) (pull_request_target) Successful in 1m15s
tests / confined (providers) (pull_request_target) Successful in 1m59s
tests / confined (launch) (pull_request_target) Successful in 2m16s
tests / confined (share) (pull_request_target) Successful in 1m56s
tests / runner-env (confined) (pull_request_target) Successful in 52s
tests / confined (remote) (pull_request_target) Successful in 2m48s
tests / confined (shellcheck) (pull_request_target) Successful in 1m18s
tests / confined (project) (pull_request_target) Successful in 3m16s
tests / confined (audit) (pull_request_target) Successful in 3m37s
tests / confined (static) (pull_request_target) Successful in 3m0s
tests / confined (config) (pull_request_target) Successful in 4m57s
tests / privileged (pull_request_target) Successful in 6m9s
tests / runner-env (privileged) (pull_request_target) Successful in 49s
Inlined as a data URI, like everything else on it: ttyd serves one file and
answers no path a /favicon.ico could be fetched from, so a link to one would
404 and the tab would keep the generic icon.

build.mjs hashes its manifest from bytes rather than from a utf8 read, so a
binary source can sit in the list beside the text ones. Every existing hash
is unchanged by that - hashing a utf8 string hashes exactly those bytes -
which is checked rather than assumed: only template.html's entry moved, and
only because this commit edits it.

"the terminal page fetches nothing at page load" refused the new link, and
its reasons - ttyd serves one file, the sandbox restricts outbound traffic -
are both about fetching. A data URI carries its bytes in the attribute and
fetches nothing, so it is exempt and nothing else is. The match is now per
attribute rather than per line, so a data URI cannot carry a real reference
past the check beside it, and the test builds a copy with a real external
reference to prove the exemption did not open a hole.
2026-09-17 16:54:38 -07:00
branderson 378ad85d8e Tell a session that a hang is not the allowlist
tests / confined (conversation) (pull_request_target) Successful in 1m4s
tests / confined (proxy) (pull_request_target) Successful in 1m12s
tests / confined (providers) (pull_request_target) Successful in 2m0s
tests / confined (launch) (pull_request_target) Successful in 2m16s
tests / confined (share) (pull_request_target) Successful in 2m1s
tests / runner-env (confined) (pull_request_target) Successful in 53s
tests / confined (remote) (pull_request_target) Successful in 2m51s
tests / confined (shellcheck) (pull_request_target) Successful in 1m20s
tests / confined (project) (pull_request_target) Successful in 3m19s
tests / confined (audit) (pull_request_target) Successful in 3m40s
tests / confined (static) (pull_request_target) Successful in 3m22s
tests / confined (config) (pull_request_target) Successful in 4m58s
tests / privileged (pull_request_target) Successful in 6m16s
tests / runner-env (privileged) (pull_request_target) Successful in 51s
The policy rejects rather than drops, and its own chain comment gives the
reason: silently dropping would make every blocked destination look like a
hang. The briefing then described both as failing to connect, which throws
that signal away.

Two agents reported the same non-problem in one day as a result - a service
on the operator's network reachable only on a port their own firewall
exposes, met as a timeout, reported as dejima blocking an allowlisted
domain. From inside a sandbox the two are indistinguishable except by
timing: a refusal here is immediate, and a timeout is something past the
boundary.

The test pins the advice to the behaviour it rests on, so a policy that
started dropping would fail the test that teaches the distinction rather
than leaving a briefing that quietly became wrong.
2026-09-17 12:03:43 -07:00
branderson 9c1735de00 Merge pull request 'Release: the egress filter and the container image, both rewritten clean-room' (#347) from development into main
Reviewed-on: #347
Reviewed-by: branderson <brad@branderson.io>
2026-09-17 02:15:55 -07:00
branderson 6522ad56e0 Merge pull request 'Finish the release's documentation review, and two things it found in code' (#359) from release-followups into development
tests / confined (conversation) (pull_request_target) Successful in 1m4s
tests / confined (proxy) (pull_request_target) Successful in 1m13s
tests / confined (providers) (pull_request_target) Successful in 1m58s
tests / confined (launch) (pull_request_target) Successful in 2m16s
tests / confined (share) (pull_request_target) Successful in 1m57s
tests / runner-env (confined) (pull_request_target) Successful in 52s
tests / confined (remote) (pull_request_target) Successful in 2m50s
tests / confined (shellcheck) (pull_request_target) Successful in 1m18s
tests / confined (project) (pull_request_target) Successful in 3m15s
tests / confined (audit) (pull_request_target) Successful in 3m34s
tests / confined (static) (pull_request_target) Successful in 3m1s
tests / confined (config) (pull_request_target) Successful in 4m50s
tests / privileged (pull_request_target) Successful in 5m57s
tests / runner-env (privileged) (pull_request_target) Successful in 47s
Reviewed-on: #359
Reviewed-by: branderson <brad@branderson.io>
2026-09-17 01:29:05 -07:00
branderson b91b8f5fb3 Map fixtures and dejima-allow to the suites that actually exercise them
tests / confined (config) (pull_request_target) Successful in 4m28s
tests / confined (conversation) (pull_request_target) Successful in 1m11s
tests / confined (launch) (pull_request_target) Successful in 2m11s
tests / confined (project) (pull_request_target) Successful in 2m54s
tests / confined (providers) (pull_request_target) Successful in 1m59s
tests / confined (proxy) (pull_request_target) Successful in 1m19s
tests / confined (remote) (pull_request_target) Successful in 2m35s
tests / confined (share) (pull_request_target) Successful in 1m22s
tests / confined (static) (pull_request_target) Successful in 2m30s
tests / confined (shellcheck) (pull_request_target) Successful in 1m12s
tests / privileged (pull_request_target) Successful in 6m44s
tests / runner-env (confined) (pull_request_target) Successful in 51s
tests / runner-env (privileged) (pull_request_target) Successful in 48s
tests / confined (audit) (pull_request_target) Successful in 1m49s
test/fixtures/fake-anthropic.py is executed as proxy.bats' upstream, but
test/* mapped every non-bats test file to static alone, so a fixture-only
change ran a suite that never touches it and skipped the one that does.
bin/dejima-allow likewise: static.bats asserts on its class wiring, and the
project-only rule skipped the suite holding that guard.

runtime.bats consumes the fixture too, but no confined selection can carry
it - that half stays CI's.
2026-09-16 17:21:51 -07:00
branderson e9e1c2bc52 Walk the whole image for file capabilities, not a union of named roots
Setuid gets a whole-filesystem find in the derived-image test; capabilities
were covered only inside the sweeper's own roots plus the four directories it
skips, so one at a top-level path nobody enumerated was asserted by nothing.
-xdev keeps the walk to the image itself - /proc, /sys and the bind mounts
are their own filesystems - and a missing getcap prints a marker rather than
an empty result that reads as clean.

Not run here: test/runtime.bats needs Docker. CI's privileged job is what
exercises it.
2026-09-16 17:21:01 -07:00
branderson cb793edead Assert the connection log's verdict values, not the field's presence
The verdict rides on which NFLOG group a rule named, and nothing asserted
either value - the two groups swapped would have logged every refusal as
"allowed" with the suite green, and a supervisor reading the log would
believe it. The refused probe now has to be recorded as blocked, and a
permitted attempt - the bridge gateway, which is in @host_v4 whether or not
the host answers on the port - has to be recorded as allowed, closing the
half the comment promised and the test never drove.

Not run here: this is test/runtime.bats, which needs Docker. CI's privileged
job is what exercises it.
2026-09-16 17:19:57 -07:00
branderson 832c4a4f6d Fold case in record_entries' exemption too, the fourth site of four
df7a744 fixed the comparison in build_entries; the exemption decision one
function up still compared INTERNAL_OK to the entry verbatim, on case and on
a trailing dot. A pair spelled differently passed the launcher's folded check
and build_entries' folded check, was recorded strict here, and the boot then
refused the name's internal answer - telling the operator to configure the
exemption they had already configured.

The existing test now spells the two sides apart in case and dot both, and
asserts the exemption that was recorded rather than only that the boot
proceeded.
2026-09-16 17:17:58 -07:00
branderson 0daae02833 Anchor the exit audit to the lines the proxy writes, not phrases in them
The classifier asked whether 'answered N' appeared anywhere and called every
log without one a credential failure. Both halves were wrong. The log carries
text chosen elsewhere - an upstream error-body prefix, an exception echoing
the session's request path - so a token endpoint whose 400 body said
'answered 503' minted a status count and the retry caveat for a session that
made no request. And a log whose only lines were connection failures was
reported as the session being unable to authenticate, sending an operator to
log in again over a network problem.

Three sections now, each on its own evidence: statuses counted from the
proxy's anchored 'upstream answered N for' lines, the credential section from
its 'cannot load|refresh credentials:' lines plus the remedy the proxy writes
after them, and anything else shown under a header that claims neither. A
session that earned both gets both.

The two new cases are driven like the existing pair, against logs the proxy
really wrote - the fixture's oauth-reject gained ?say= so a test can choose
the upstream's text.
2026-09-16 17:16:03 -07:00
branderson a88393f769 Keep the credential proxy's log out of the directory the session mounts
docs/security.md's new rule says what the proxy learned from the provider
stays on the host - and the log holds exactly that, statuses and error-body
prefixes, while living in the socket directory that is bind-mounted read-only
into the session. The session user's uid is the host user's, so the 0644 log
inside the 0700 mount was readable from inside, including token-endpoint
error bodies the container-facing message deliberately withholds.

The log is its own mktemp file now, removed by the exit trap, and the trap
never inherits its name from the environment - same rule as the other names
cleanup() removes but does not create.
2026-09-16 17:11:33 -07:00
branderson b8c34bb98c Make the privilege-sweep test require the sweep, not the spelling
tests / confined (conversation) (pull_request_target) Successful in 1m8s
tests / confined (proxy) (pull_request_target) Successful in 1m14s
tests / confined (providers) (pull_request_target) Successful in 1m57s
tests / confined (launch) (pull_request_target) Successful in 2m14s
tests / confined (share) (pull_request_target) Successful in 1m53s
tests / runner-env (confined) (pull_request_target) Successful in 51s
tests / confined (remote) (pull_request_target) Successful in 2m47s
tests / confined (shellcheck) (pull_request_target) Successful in 1m16s
tests / confined (project) (pull_request_target) Successful in 3m13s
tests / confined (audit) (pull_request_target) Successful in 3m33s
tests / confined (static) (pull_request_target) Successful in 2m57s
tests / confined (config) (pull_request_target) Successful in 4m50s
tests / privileged (pull_request_target) Successful in 6m38s
tests / runner-env (privileged) (pull_request_target) Successful in 48s
Both halves were substring greps, and the sweeper's own path appears in the
line that chmods it. So a Dockerfile whose last instruction only copies or
chmods the sweeper satisfied the test, and so did a derived layer whose
invocation had been commented out - the sweep could be disabled with the test
green, leaving Debian's setuid set in the image with no_new_privs as the only
lock, which the sweeper's header declines to rely on.

It now asks whether the sweeper is invoked as a command, and reads the
derived Dockerfile the generator produces rather than grepping for a string
in the generator, since the order relative to the layer's own installs is the
whole property.

My first attempt at this was the same defect again - a tighter regex that
still matched `chmod +x <path>`. Three mutations, all of which had passed:
the base invocation removed, the derived one commented out, and the derived
one moved above its installs.

The runtime check also gained the directories the sweeper deliberately does
not walk. --check covers the sweeper's own roots, which is right for proving
the sweep worked and leaves nothing asserting that the image ships no file
capability outside them; the whole-filesystem getcap this replaced did cover
them.
2026-09-16 01:20:40 -07:00
branderson df7a74481c Fold case where the policy checks internal_domains, the third site of three
dejima_class_for and dejima_mark_internal fold; build_entries compared
verbatim. So a pair the launcher had already accepted was refused one layer
down, with a message saying the name was permitted by nothing - a correct
configuration that will not start, over a spelling difference DNS does not
recognise.
2026-09-16 01:17:54 -07:00
branderson 337b2bce04 Route every entry into the open class through the class rule, not just one
fixed-domains.sh said the forge class wins wherever the two overlap. Only the
approved-domains loop asked. The built-in open list carries
release-assets.githubusercontent.com, which sits under a built-in forge
suffix, so with the shipped defaults it kept the open class and took a
resolver line more specific than the suffix entry - feeding the set with no
port restriction.

The 443 clamp then rests on the forge's published ranges covering that
address, which is the one-layer-where-there-are-two that firewall-up.sh's own
comment warns about. The ranges do cover it today. The last release did not
depend on that: with no line of its own, the name fell to the suffix entry
and was restricted.

Every route into the open class is normalised now, so a harness or backend
domain under a forge suffix cannot arrive the same way. The comment states
the mechanism rather than asserting the outcome.

The test drives the composition with nothing else set, because the shipped
defaults are the configuration every operator has before declaring anything,
and that was the case nothing covered.
2026-09-16 01:17:05 -07:00
branderson d8845206c4 Finish the release's documentation review, and two things it found in code
tests / confined (conversation) (pull_request_target) Successful in 1m0s
tests / confined (proxy) (pull_request_target) Successful in 1m13s
tests / confined (providers) (pull_request_target) Successful in 2m8s
tests / confined (launch) (pull_request_target) Successful in 2m23s
tests / confined (share) (pull_request_target) Successful in 2m14s
tests / runner-env (confined) (pull_request_target) Successful in 1m0s
tests / confined (remote) (pull_request_target) Successful in 3m13s
tests / confined (shellcheck) (pull_request_target) Successful in 1m26s
tests / confined (project) (pull_request_target) Successful in 3m46s
tests / confined (audit) (pull_request_target) Successful in 4m14s
tests / confined (static) (pull_request_target) Successful in 3m47s
tests / confined (config) (pull_request_target) Successful in 5m43s
tests / privileged (pull_request_target) Successful in 6m22s
tests / runner-env (privileged) (pull_request_target) Successful in 50s
The documentation pass that ran mid-release checked what existed then. Four
pull requests landed after it. These are what they left behind.

The exit audit announced every proxy log as "The model API answered with an
error this session", counted statuses out of it, and closed by saying a
retried one may still have succeeded. A credential that could not be loaded
produces no status line at all, because no request was made - so that case
printed the header, no counts, and an invitation to hope. All three are false
for exactly the failure the credential work routes there deliberately. It now
says what happened and prints the one line that says what to do.

permit_name still spelled out its own set name, which is the fourth place
deciding which set a class feeds and the one set_for_class's comment claimed
did not exist. A live add looking in a different set from the one being
filled reports a permitted name as unreachable, or the reverse. The static
test now refuses any spelling of a set name outside that helper, rather than
naming the three sites it knew about.

Documentation: the rule the credential proxy's comment cites - what may cross
into a session and what may not - was not written anywhere, and the change
that made a repo-authored remedy cross needed it to be. The boot check that
now refuses a name whose address is missing from its class's set was
undocumented, as was the widening of the launch check from counting dots to
asking for a host name. And an approval granted before the two-label rule now
stops a launch with no remove verb to clear it, so the file to edit is named.

The audit's two paths are driven against logs this proxy really wrote, rather
than lines written in the test, because the classification only works if the
two files agree about what a line looks like.
2026-09-16 00:23:48 -07:00
branderson c0935a52c3 Merge pull request 'Name the login that fixes an expired credential, where it is met' (#358) from cred-expiry into development
tests / confined (conversation) (pull_request_target) Successful in 1m6s
tests / confined (proxy) (pull_request_target) Successful in 1m9s
tests / confined (providers) (pull_request_target) Successful in 1m59s
tests / confined (launch) (pull_request_target) Successful in 2m15s
tests / confined (share) (pull_request_target) Successful in 1m58s
tests / runner-env (confined) (pull_request_target) Successful in 53s
tests / confined (remote) (pull_request_target) Successful in 2m49s
tests / confined (shellcheck) (pull_request_target) Successful in 1m19s
tests / confined (project) (pull_request_target) Successful in 3m16s
tests / confined (audit) (pull_request_target) Successful in 3m36s
tests / confined (static) (pull_request_target) Successful in 3m2s
tests / confined (config) (pull_request_target) Successful in 4m49s
tests / privileged (pull_request_target) Successful in 5m40s
tests / runner-env (privileged) (pull_request_target) Successful in 47s
Reviewed-on: #358
Reviewed-by: branderson <brad@branderson.io>
2026-09-15 23:29:49 -07:00
branderson 16025633a1 Merge pull request 'Release: the egress filter in a container the session cannot reach' (#337) from development into main
Reviewed-on: #337
Reviewed-by: branderson <brad@branderson.io>
2026-09-11 15:36:37 -07:00
branderson 1005257774 Merge pull request 'Release: the harness and backend axes, and a credential proxy that is neither's' (#321) from release-pass-base into main
Reviewed-on: #321
Reviewed-by: branderson <brad@branderson.io>
2026-09-03 18:19:28 -07:00
branderson 110925c44a Merge pull request 'Release: a second harness, identities, and a capability floor' (#259) from release-2026-08-27 into main
tests / confined (providers) (pull_request_target) Successful in 1m42s
tests / confined (proxy) (pull_request_target) Successful in 49s
tests / confined (remote) (pull_request_target) Successful in 2m7s
tests / confined (audit) (pull_request_target) Successful in 3m7s
tests / confined (conversation) (pull_request_target) Successful in 50s
tests / confined (project) (pull_request_target) Successful in 2m31s
tests / runner-env (privileged) (pull_request_target) Successful in 41s
tests / confined (config) (pull_request_target) Successful in 3m19s
tests / confined (shellcheck) (pull_request_target) Successful in 1m3s
tests / privileged (pull_request_target) Successful in 3m30s
tests / runner-env (confined) (pull_request_target) Successful in 45s
tests / confined (share) (pull_request_target) Failing after 40s
tests / confined (static) (pull_request_target) Successful in 1m38s
Reviewed-on: #259
Reviewed-by: branderson <brad@branderson.io>
2026-08-27 17:24:04 -07:00
branderson c7797d05ac Merge pull request 'Release: harness axis, Cline, forge parity, the web toolbar, and a release pass' (#207) from release-2026-08-24 into main
Reviewed-on: #207
Reviewed-by: branderson <brad@branderson.io>
2026-08-24 08:16:05 -07:00
branderson e04131064c Merge development into main for release
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 37s
tests / confined (shellcheck) (pull_request_target) Successful in 52s
tests / confined (providers) (pull_request_target) Successful in 2m24s
tests / privileged (pull_request_target) Successful in 3m46s
tests / confined (remote) (pull_request_target) Successful in 3m55s
tests / runner-env (privileged) (pull_request_target) Successful in 40s
tests / confined (static) (pull_request_target) Successful in 4m46s
tests / confined (project) (pull_request_target) Successful in 5m25s
tests / confined (config) (pull_request_target) Successful in 7m13s
tests / confined (audit) (pull_request_target) Successful in 9m53s
Made on a branch off main rather than by merging main into development
first. The reconciliation the cherry-pick in #173 made necessary has to
happen somewhere, and doing it here means it happens once, in a pull
request with a diff someone can read - the other order produces a merge
with no file changes at all, which is a confusing thing to ask anyone to
approve.

Three files conflicted, all of them main's cherry-picked state against
what development made of it. Resolved to development in each case: the
harness hook list is a strict superset, the entrypoint's seed loop is the
same loop relocated, and the guard descriptions were reworded when the
PATH shadows stopped depending on the harness.

The merged tree is byte-identical to development's, which is the property
a release should have and the one worth checking rather than assuming.
2026-08-24 03:43:55 -07:00
branderson c57c6ba778 Merge pull request 'Bring the plugin fix and the launch optimisation to main, without Cline' (#173) from main-without-cline into main
Reviewed-on: #173
Reviewed-by: branderson <brad@branderson.io>
2026-08-22 01:44:54 -07:00
branderson 1e6711d1b2 Carry the two static-test helpers the plugin change depends on
tests / confined (conversation) (pull_request_target) Successful in 58s
tests / confined (proxy) (pull_request_target) Successful in 1m12s
tests / confined (providers) (pull_request_target) Successful in 2m1s
tests / confined (remote) (pull_request_target) Successful in 3m31s
tests / runner-env (privileged) (pull_request_target) Successful in 34s
tests / confined (project) (pull_request_target) Successful in 4m48s
tests / confined (audit) (pull_request_target) Successful in 8m22s
tests / runner-env (confined) (pull_request_target) Successful in 35s
tests / confined (shellcheck) (pull_request_target) Successful in 53s
tests / confined (static) (pull_request_target) Successful in 2m59s
tests / privileged (pull_request_target) Successful in 3m51s
tests / confined (config) (pull_request_target) Successful in 6m7s
Both came from the Cline branch, which is not on this branch, and the plugin
change was written against them.

lib() runs a function from the provider library in a subshell, the way
providers.bats does. Its absence here is not only a missing convenience: the
test added with the briefing work already calls it, so `lib manifest_get
harness claude-code briefing_args` ran as a command that does not exist, `run`
captured the 127 and its message as output, and the assertion that the output
is non-empty passed on that. The test has been asserting nothing on this
branch since it landed.

The other is the rule that a plugin function whose name starts with an
underscore is the plugin's own helper rather than a misspelled hook. The
plugin change adds _plugin_version_mounts, which without this reads as a hook
core never calls.

Both are byte-identical to the versions on development, so the merge that
eventually brings Cline across sees the same content on both sides rather
than two spellings of it.
2026-08-22 01:23:36 -07:00
branderson 8d61332742 Let a session update a plugin, by seeding the tree instead of mounting it
Cherry-picked from #172, which landed on development after the Cline harness.
Cline is deliberately not carried across with it: it has not been exercised
beyond its own tests yet, and holding it back is the point of taking this
route rather than merging development whole.

Three hunks needed adapting, all of them places where the Cline branch had
added something adjacent: the harness hook list gains harness_seed without
Cline's two, docker-compose passes SANDBOX_HARNESS_SEED without the
briefing_path variable that only Cline uses, and entrypoint.sh keeps the
argument-only briefing block this branch already had rather than the
two-route version. Nothing else differed.

The change itself is unmodified. ~/.claude/plugins was mounted read-only,
which meant Claude Code could neither materialise a plugin update nor record
it, so the plugin was left at a version the catalogue no longer matched -
presenting as a plugin that would not load, fixable only by running Claude
Code on the host and restarting. What the harness rewrites is now seeded as a
writable copy it owns, and each already-installed version stays a read-only
mount of the host's.
2026-08-22 01:23:36 -07:00
branderson d356191859 Merge commit 'efc5d63' into main-without-cline 2026-08-22 01:09:15 -07:00
branderson 86e1a5be71 Merge pull request 'Release: the harness-decoupling programme, and a briefing that actually arrives' (#168) from development into main
Reviewed-on: #168
Reviewed-by: branderson <brad@branderson.io>
2026-08-21 20:12:04 -07:00
branderson fce3dbc2ac Merge pull request 'Release: epitaph, the threat model, and the development branch process' (#132) from development into main
Reviewed-on: #132
Reviewed-by: branderson <brad@branderson.io>
2026-08-13 17:53:32 -07:00
77 changed files with 10122 additions and 1334 deletions
+71 -38
View File
@@ -89,20 +89,13 @@ jobs:
- providers
- conversation
- share
- mirror
name: confined (${{ matrix.suite }})
container:
# Debian bookworm plus a node binary - node is what actions/checkout
# needs to run at all, and apt-get is what everything below needs.
image: node:20-bookworm
steps:
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
# allowlist, not the package - and ci-runner.bats, which would say so
# precisely, cannot run yet because installing it is what just failed.
#
# Uses only what the image already has: getent is libc, and the
# resolver's address is in resolv.conf.
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
@@ -114,7 +107,8 @@ jobs:
# job on a single lookup would be stricter than the step it is
# protecting. Six attempts over ~15s, then a diagnosis.
#
# Uses only what the image already has: getent is libc.
# Uses only what the image already has: getent is libc, and the
# resolver's address is in resolv.conf.
- name: Check the job network can resolve before installing anything
run: |
for attempt in 1 2 3 4 5 6; do
@@ -147,7 +141,11 @@ jobs:
- name: Install test dependencies
run: |
apt-get update
apt-get install -y --no-install-recommends bats jq python3 tmux ca-certificates
# iproute2 for `ss`: the one test asserting dejima-ttyd refuses to
# start when something already holds its port skips without it, and a
# permanent skip on the check guarding a reverse-proxy port takeover
# is the same as not having it.
apt-get install -y --no-install-recommends bats jq python3 tmux ca-certificates iproute2
# The code under test, named explicitly - see the header. Without `ref`
# this checks out the base branch and the suite passes without ever
# seeing the change.
@@ -174,7 +172,11 @@ jobs:
# otherwise refuses --jobs outright when GNU parallel is absent.
- run: |
chown -R node:node .
su node -c "TERM=xterm-256color bats --jobs $(nproc) --no-parallelize-across-files test/${{ matrix.suite }}.bats"
# DEJIMA_TEST_CONCURRENCY is what this job asks the machine for, which
# the fixtures that wait on a bounded deadline multiply their bounds
# by - see test/fixtures/loadscale.py. One suite per job here, so it
# is the --jobs value and nothing more.
su node -c "TERM=xterm-256color DEJIMA_TEST_CONCURRENCY=$(nproc) 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,
@@ -251,14 +253,6 @@ jobs:
# nothing to do with what the test asserts. That test is the containment
# argument for the ttyd front door, so it is not one to skip in CI.
steps:
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
# allowlist, not the package - and ci-runner.bats, which would say so
# precisely, cannot run yet because installing it is what just failed.
#
# Uses only what the image already has: getent is libc, and the
# resolver's address is in resolv.conf.
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
@@ -270,7 +264,8 @@ jobs:
# job on a single lookup would be stricter than the step it is
# protecting. Six attempts over ~15s, then a diagnosis.
#
# Uses only what the image already has: getent is libc.
# Uses only what the image already has: getent is libc, and the
# resolver's address is in resolv.conf.
- name: Check the job network can resolve before installing anything
run: |
for attempt in 1 2 3 4 5 6; do
@@ -442,6 +437,58 @@ jobs:
fi
echo "GitHub ranges lookup is authenticated - ceiling is $limit requests/hour."
# One fetch for the whole run, into the directory the job already shares
# with the containers it starts. test/runtime.bats starts twenty-seven
# container pairs and each filter fetched the ranges for itself, which is
# roughly twenty-seven requests in five minutes - comfortably inside the
# 5000/hour a token allows, and exactly the burst GitHub's SECONDARY limit
# answers with a 403 (#357). One request cannot be a burst.
#
# Best-effort: a failure here leaves no file, and runtime.bats falls back
# to each container fetching for itself. That is the behaviour this
# replaces, so the worst case is the status quo rather than a red job -
# and the step says which of the two the run is getting.
- name: Fetch the GitHub ranges once for the whole run
env:
GH_TOKEN: ${{ secrets.GH_META_TOKEN }}
run: |
# POSIX sh, like every other run: block here - the runner's default
# shell is `sh -e`, where an array is a syntax error and the step dies
# before any of this runs. Two curl branches rather than one with a
# built-up argument list, for the same reason.
mkdir -p /tmp/dejima-ci
out=/tmp/dejima-ci/github-meta.json
rm -f "$out" "$out.part"
fetched=0
if [ -n "${GH_TOKEN:-}" ]; then
if curl -fsS --max-time 30 -H "Authorization: Bearer $GH_TOKEN" \
https://api.github.com/meta -o "$out.part" 2>/dev/null; then
fetched=1
fi
else
if curl -fsS --max-time 30 \
https://api.github.com/meta -o "$out.part" 2>/dev/null; then
fetched=1
fi
fi
# Written only once it parses AND carries ranges, because a rate-limit
# body is a 200 with a message and no ranges - and a cached one would
# then fail every container in the run instead of one.
ranges=""
if [ "$fetched" = 1 ]; then
ranges="$(jq -r '[.git[]?, .api[]?, .web[]?] | unique | .[]' < "$out.part" 2>/dev/null || true)"
fi
if [ -n "$ranges" ]; then
mv "$out.part" "$out"
echo "Cached GitHub ranges for this run: $(printf '%s\n' "$ranges" | wc -l) CIDRs."
else
rm -f "$out.part"
echo "Could not cache the ranges, so each container will fetch its own."
echo "That is the pre-#357 behaviour, not a new failure - but it is what"
echo "trips the secondary limit, so a 403 in a container boot below is"
echo "explained by this line."
fi
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
@@ -536,14 +583,6 @@ jobs:
container:
image: node:20-bookworm
steps:
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
# allowlist, not the package - and ci-runner.bats, which would say so
# precisely, cannot run yet because installing it is what just failed.
#
# Uses only what the image already has: getent is libc, and the
# resolver's address is in resolv.conf.
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
@@ -555,7 +594,8 @@ jobs:
# job on a single lookup would be stricter than the step it is
# protecting. Six attempts over ~15s, then a diagnosis.
#
# Uses only what the image already has: getent is libc.
# Uses only what the image already has: getent is libc, and the
# resolver's address is in resolv.conf.
- name: Check the job network can resolve before installing anything
run: |
for attempt in 1 2 3 4 5 6; do
@@ -607,14 +647,6 @@ jobs:
container:
image: node:20-bookworm
steps:
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
# allowlist, not the package - and ci-runner.bats, which would say so
# precisely, cannot run yet because installing it is what just failed.
#
# Uses only what the image already has: getent is libc, and the
# resolver's address is in resolv.conf.
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
@@ -626,7 +658,8 @@ jobs:
# job on a single lookup would be stricter than the step it is
# protecting. Six attempts over ~15s, then a diagnosis.
#
# Uses only what the image already has: getent is libc.
# Uses only what the image already has: getent is libc, and the
# resolver's address is in resolv.conf.
- name: Check the job network can resolve before installing anything
run: |
for attempt in 1 2 3 4 5 6; do
+237
View File
@@ -0,0 +1,237 @@
# Changelog
[← dejima README](README.md)
What each release changed, newest first, for one question: **should I update,
and what will I be getting.** `dejima --version` says which release the
checkout you are running is on.
A release is a date - `2026.10.14` - and the git tag of that name on `main` is
where it lives. A date rather than a version number because the useful fact
about a release is how old it is, and because `MAJOR.MINOR` would be a promise
about compatibility and patch lines that this project does not make.
**No release here is supported.** The tip of `main` is the only supported
version, and that is not a formality: there are no maintenance branches, no
backports and no security releases for older tags. A tag is a record of what
shipped so you can tell what you are missing - not a version to stay on. See
[SECURITY.md](SECURITY.md).
Entries name what an operator would notice. The full detail is in the pull
requests, which are linked.
## Unreleased
The release pull request that merges `development` into `main` replaces this
heading with the date it merges, and tags `main` with the same date.
### New
- **Every session has a name**, in both front doors' listings and as the tmux
window name, so `choose-window` and the status line can tell a dozen
sessions of one project apart. It is the project directory by default and
`dejima --name <text>` when you choose one; a name already in use takes the
first free `-N` rather than refusing the launch.
- **The session list says which sessions want you.** `waiting` and `working`
where every row used to say `attachable`, from the session's own report
rather than from the notification markers, so it works with notifications
switched off.
- **The picker is three columns** - name, state, age - where it was five. On a
phone the old row wrapped and put half of each session under the next
session's number. The tmux label and the container id stay in `--list`.
- **`dejima-attach --watch`**, the picker redrawn on a timer: one window for
which sessions are working and which are waiting.
- **`dejima-attach --count-waiting`** for a tmux status line - the same
question as one number, and the one path that asks Docker nothing.
**`--counts`** answers for every state in the same pass, so two numbers on a
status bar cost one invocation rather than two, and **`--state-dir`** says
where the registry and the reported states are for a reader that is not
dejima.
- **Inside tmux the picker switches your client** to the session's own window
instead of attaching. tmux refuses to nest, so on your own machine the
picker could list every session and reach none of them.
- **The web front door's SESS button no longer reloads the page.** It opens a
new WebSocket instead, which is all ttyd needs to run a different command,
so the half a megabyte of inlined terminal is not fetched and re-run to
reach the picker. On a phone that was most of the wait.
### Changed
- **The GitHub mirrors carry a rewritten history.** AI co-author trailers are
removed from commit messages, so GitHub credits no tool as a contributor,
and operator-specific values are replaced throughout. Commits keep their
authors and dates, but a forge SHA no longer resolves on GitHub.
### Fixed
- **The session listing is five times faster on a host running a dozen
sessions**, which is the wait between pressing SESS on the web front door
and the picker drawing. It asked Docker once per session and tmux once per
session, and forked a subshell per row for each of four lookups; it now asks
Docker twice and tmux once for the whole listing, whatever that holds.
Measured against an eighteen-session fixture: 2259ms before, 468ms after.
- **The credential proxy refuses an oversized request body before inviting
it.** It answered `100 Continue` and only then read the declared length, so
whether a session saw the `413` or only the `100` was timing. The cap itself
always held - nothing oversized was read into host memory - but the
conversation was a race, and it surfaced as an intermittently red `proxy`
job.
([#400](https://gitbox.branderson.io/dejima/dejima/issues/400))
## 2026.10.14
Nothing a session does has changed. This release is about where dejima can be
read.
### New
- **Read-only mirrors on GitHub.** dejima, dejima-badge and the three sister
projects are published to [github.com/dejima-org](https://github.com/dejima-org):
`main` and its release tags, synced hourly. Development, issues and pull
requests stay on the forge, and the mirrors accept neither. A clone of a
mirror has GitHub links and clone URLs where the forge's copy has the forge's.
([#406](https://gitbox.branderson.io/dejima/dejima/pulls/406))
### Rollback
This release adds no config key and changes nothing a session runs, so rolling
back changes nothing an operator would notice.
## 2026.10.13
The first release with a version number. Everything before it is `unreleased`,
and a checkout from before this tag reports itself that way.
144 commits, PRs #360 to #399.
### New
- **`dejima --isolated`** runs a session with nothing before it and nothing
after it: no project mounted, no memory or transcript across either
boundary, no forge credential unless the same command names a forge, and a
`~/shared` made fresh for that session alone. For a disposable experiment,
or anything that should not be able to read what an earlier session did.
Supported on every shipped harness.
([#390](https://gitbox.branderson.io/dejima/dejima/pulls/390),
[docs](docs/configuration.md#isolated-sessions))
- **`dejima-allow` takes up to twenty domains at once**, printed back one per
line and confirmed before any is added. One domain is unchanged.
([#383](https://gitbox.branderson.io/dejima/dejima/pulls/383))
- **`dejima --version`**, a version on the startup banner, and an update
notice that names the release you would be getting rather than only a commit
count.
### Tightened
Each of these refuses or withholds something a previous release allowed.
- **The host entry is clamped to the ports it was told to open**, and the
clamp is now matched *before* the wider rules. Configuring one host-resident
service opened every port the host publishes, including sshd and the
remote-access front door.
([#371](https://gitbox.branderson.io/dejima/dejima/pulls/371),
[#399](https://gitbox.branderson.io/dejima/dejima/pulls/399))
- **The staged `~/.claude.json` carries the launching project's entry and no
other.** A session was handed the name of every project on the host, a
sample of each one's filenames, and what each had cost.
([#381](https://gitbox.branderson.io/dejima/dejima/pulls/381))
- **The real model credential is mounted only when it is this backend's own
credential and the proxy is off for it.** A backend needing no credential at
all - a model you serve yourself - was handed the real subscription login.
([#399](https://gitbox.branderson.io/dejima/dejima/pulls/399))
- **git's `--config-env` no longer walks past the push guard.** It sets a
pushurl the way `-c` does, and in its separated spelling the guard read its
value as the subcommand - so a push checked against neither the host
allowlist nor the blocked branch.
([#399](https://gitbox.branderson.io/dejima/dejima/pulls/399))
- **The push and merge guards are one policy, read out of argv**, closing
several spellings that walked past them.
([#372](https://gitbox.branderson.io/dejima/dejima/pulls/372),
[#374](https://gitbox.branderson.io/dejima/dejima/pulls/374))
- **Every store dejima keeps out of a session's reach is warned about** when
it sits inside the mounted project, not three of the six.
([#399](https://gitbox.branderson.io/dejima/dejima/pulls/399))
### What an existing config stops being able to do
Read this part before updating. All of these fail closed, and the first fails
quietly.
- **A host-resident Loki or self-served model configured by NAME needs
`EGRESS_HOST_PORTS` set to its port.** The clamp is matched before the open
class now, so an address permitted by name no longer reaches the host on
every port. A literal address with an explicit port is handled for you. The
model fails loudly - the session cannot reach it - but **the log shipper
fails silently**: network logging simply stops. The policy's own summary
line, `host <addr> on ports none`, is where that shows up.
- **A configured `ssh_key` now withdraws your forwarded ssh-agent from the
container entirely.** Choosing a dedicated key has to mean your personal
identities are not in there, rather than merely not the default. If a
session used the key for the forge and the agent for anything else - a
second remote, a private submodule - the second half stops working.
([#375](https://gitbox.branderson.io/dejima/dejima/pulls/375))
- **An empty `ssh_key` file now counts as no key at all.** A pushing workflow
with an empty key file and no agent is refused where it used to start and
fail at the push. With an agent running it starts, and now says that the
forwarded agent is still signing - which is what configuring a key is meant
to prevent.
([#375](https://gitbox.branderson.io/dejima/dejima/pulls/375))
- **Under the github forge, `gh api` is held to this repository.** A call
naming no endpoint under `repos/<this repo>/` is refused - `gh api user`,
`rate_limit`, an org endpoint - as are graphql mutations and reading a body
from a file (`-F x=@file`, `--input`). An agent that used `gh api` for
anything outside the repository will notice.
([#372](https://gitbox.branderson.io/dejima/dejima/pulls/372),
[#373](https://gitbox.branderson.io/dejima/dejima/pulls/373))
### Known limitations
Not changes - things this release does not do, worth knowing before relying
on the ones above.
- **The host clamp covers the bridge gateway only.** The host's other
addresses - its `docker0` address when the run network is a different
bridge, and its LAN address - are not in it, so a service configured at the
host's LAN IP is still permitted on every port by the open class.
([#401](https://gitbox.branderson.io/dejima/dejima/issues/401))
- **The merge block reads subcommands, not API endpoints.** `gh api -X PUT
repos/<owner>/<name>/pulls/<n>/merge` names this repository, so the scoping
check passes and nothing else sees a merge in it.
([#402](https://gitbox.branderson.io/dejima/dejima/issues/402))
### Fixed
- The egress policy's resolver checks had no name to check when the forge
ranges came from a cache file rather than fetched, which is how CI supplies
them - so every container in a CI run failed to come up. An ordinary
launch, whose ranges URL always has a host, was unaffected.
([#386](https://gitbox.branderson.io/dejima/dejima/pulls/386))
- GitHub's `/meta` ranges are fetched once per CI run rather than once per
container, and a rate limit is waited out instead of failing the run.
([#378](https://gitbox.branderson.io/dejima/dejima/pulls/378))
- Test-suite reliability: a self-check retry that could not see its own
failure, deadlines that scale with what a run asked of the machine, and a
teardown race that failed CI intermittently.
([#379](https://gitbox.branderson.io/dejima/dejima/pulls/379),
[#385](https://gitbox.branderson.io/dejima/dejima/pulls/385))
- A harness's per-run configuration is seeded from a staging directory that
goes away with the launch, rather than written onto the host where it will
live. For cline that is what lets `--isolated` work at all; for an ordinary
launch the end state is unchanged.
([#393](https://gitbox.branderson.io/dejima/dejima/issues/393))
### Documentation
Two review passes over the whole tree corrected documents that stated the
opposite of the code - including what Ctrl-Z does, what the sandbox tells a
session about a hung connection, and what dejima gives you on its own, without
any of the sister projects around it.
([#370](https://gitbox.branderson.io/dejima/dejima/pulls/370),
[#377](https://gitbox.branderson.io/dejima/dejima/pulls/377),
[#391](https://gitbox.branderson.io/dejima/dejima/pulls/391))
### Rollback
This release adds no config key, so the previous `main` accepts any config
this one accepts. Rolling back restores the behaviour the "stops being able
to" section describes, including the host entry being reachable on every port.
+3
View File
@@ -8,6 +8,9 @@ anonymous issues or pull requests. So there is currently no way for you to
file either, and that is a property of where this lives rather than a comment
on the contribution.
The repositories under [github.com/dejima-org](https://github.com/dejima-org)
are read-only mirrors of `main`, with issues and pull requests turned off.
That will change: this project will provide a way to submit issues and pull
requests. Until it does, if you are using dejima and need it to do something
different, **fork it and make the change in your fork**. Nothing here depends
+43 -16
View File
@@ -4,10 +4,10 @@
browser is set - so anything conditional shows dark ink on a dark page to
a reader whose browser prefers light, and does it silently.
The suffix names the mode a file is FOR, not the colour of its ink:
-darkmode is the pale wordmark that belongs on a dark page. A mirror on a
light-themed host swaps the filename below for dejima-wordmark-lightmode.png,
which is committed alongside for exactly that. One line, on the mirror,
where someone can see whether it worked.
-darkmode is the pale wordmark that belongs on a dark page. GitHub does
follow its own theme setting through <picture>, so the GitHub mirror
replaces the tag below with one that falls back to
dejima-wordmark-lightmode.png; see mirror/github-mirror.sh.
Every &nbsp; here is spacing, and it has to be a character rather than CSS
or a presentation attribute. Gitea's sanitizer strips `style` from the tag
and from any wrapper, `hspace`/`vspace` survive sanitizing only to be
@@ -151,6 +151,9 @@ what each one buys, what goes wrong without it, and where each one's boundary
ends. None of them is required to run dejima; what they change is what the
layer below the container costs, and whether anyone finds out.
**→ [Using dejima on its own](docs/threat-model.md#using-dejima-on-its-own)** -
what holds without them, what does not, and what to do about it.
**→ [Why a container, and not a microVM?](docs/threat-model.md#why-a-container-and-not-a-microvm)** -
the most common question about this design, and why the effort went into the
layers below the container rather than into a harder one.
@@ -160,8 +163,8 @@ layers below the container rather than into a harder one.
This repo only holds the tooling; no project lives here. Point it at a project
on disk and that project is bind-mounted into the container at runtime.
**Prerequisites:** Docker with the `docker compose` v2 plugin, and `python3`
(stdlib only, no packages) for the host-side credential proxy.
**Prerequisites:** Docker with the `docker compose` v2 plugin, `jq`, and
`python3` (stdlib only, no packages) for the host-side credential proxy.
Only the first block below is required. The rest are independent, so take the
ones you want.
@@ -184,7 +187,9 @@ ones you want.
config.
3. **Put `~/.local/bin` on your `PATH`** if it isn't already.
4. **Set `[core] harness`.** It has no default and a launch is refused without
it, which is what stops a later release changing your selection for you.
it, which is what stops a later release changing your selection for you. The
starter config states `claude-code`, because a file with no default to fall
back on has to name a choice; change that line for Cline.
That is enough to run `dejima`, and no push credential is needed to get this
far. Everything else runs at its default, which means `[core] forge = none` and
@@ -245,6 +250,7 @@ Applications, scopes `write:repository` and `write:issue`) into
```sh
dejima # sandbox the current directory
dejima ~/repos/some-project
dejima --isolated # no project at all - see below
```
Point it at a project, ask for a change, and let the session work. What happens
@@ -293,10 +299,30 @@ dejima --ro ~/other-project ~/myproject
**→ [docs/configuration.md](docs/configuration.md#one-off-mounts-for-a-single-launch)** -
both forms, and which host paths a mount is refused over and why.
**Ctrl-Z backgrounds all of dejima**, and `fg` brings back a working session.
It makes no progress while suspended, though, so for one that should keep
working while you are away, run dejima inside tmux and use
[`dejima-attach`](docs/remote-access.md).
### A session with no project
`dejima --isolated` is the other direction: nothing of your machine is
mounted, no memory or transcript crosses either boundary, no forge credential
unless the same command names a forge, and the one thing that outlives the
session is a `~/shared` made fresh for it. Run it from anywhere - the
directory you are in is not used, and naming one is refused rather than
ignored, so a launch cannot look like it is working on a project it cannot
see.
It narrows what a session can read of the host and what credential it carries.
It is not a stronger sandbox: the firewall, the credential boundary and the
capability drops are what they are for every other session.
**→ [docs/configuration.md](docs/configuration.md#isolated-sessions)** - the
table of what differs, and what the mode gives up.
**Ctrl-Z backgrounds dejima's host side**, and `fg` brings back a working
session. It gets your terminal back rather than pausing the agent - the
container keeps running - so for a session you want to leave working and pick
up elsewhere, run dejima inside tmux and use
[`dejima-attach`](docs/remote-access.md). Every session names its tmux window
after the project, or after `--name <text>`, with a `-2` on the second one -
which is what tells a dozen parallel sessions of one project apart.
→ [why that takes doing](docs/configuration.md#suspending-a-session)
## Sign in on the host first
@@ -406,7 +432,8 @@ broken tool. IPv6 is refused outright rather than allowlisted: a sandbox has no
IPv6 egress at all, and one table states that in the same transaction as
everything else, so there is no moment where the family is partly filtered.
The allowlist covers what the sandbox needs to function - DNS, the Docker host,
The allowlist covers what the sandbox needs to function - DNS, the Docker host
on the ports a host-resident service was configured on and no others,
GitHub on port 443 for read access whichever forge is selected, whatever the
selected forge needs, the harness's own update endpoints, and the selected
backend's API endpoint when the credential proxy is off - plus whatever a
@@ -503,7 +530,7 @@ at a fixed host path.
`.dejima.md` at a project's root is that project's agent-instruction file, the
harness-agnostic equivalent of `CLAUDE.md`, loaded into the session's context
with no authority over the sandbox itself. A `SessionStart` hook also briefs the
with no authority over the sandbox itself. dejima also briefs the
session on where it is running and on the instructions belonging to whichever
workflow you selected, so a session under `local-only` is told something
different from one under `worktree-pr`.
@@ -562,9 +589,8 @@ After the container exits it diffs them and tells you what changed.
It is **advisory, not a block**, firing after the session has ended, so a
finding is something to clean up before your next `git` command or session in
that repo. Its diff is time-windowed: a finding says a path changed while the
session was running, not that the session changed it. Findings are a time
window, not a verdict on who caused them.
that repo. Its findings are a time window, not a verdict on who caused them:
one says a path changed while the session was running.
`dejima-audit show` indexes what is waiting and `dejima-audit ack` walks it, one
finding at a time. `[audit] strict` refuses a headless launch while CRITICAL
@@ -701,6 +727,7 @@ and how to run them.
| [ci.md](docs/ci.md) | What push access grants on your CI host, and how to bound it |
| [remote-access.md](docs/remote-access.md) | Picking and verifying a bind address, and the SSH and web front doors |
| [development.md](docs/development.md) | The file map, the test suites, running them |
| [CHANGELOG.md](CHANGELOG.md) | What each release changed, for deciding whether to update |
| [SECURITY.md](SECURITY.md) | What dejima claims, where to report a vulnerability, and what is promised in return |
| [ATTRIBUTION.md](ATTRIBUTION.md) | Who holds the dejima marks and what may be done with them |
+4
View File
@@ -75,6 +75,10 @@ Whoever deploys this is responsible for the result.
The tip of `main`, and nothing else. There are no maintenance branches, no
backports and no security releases for older tags or commits.
Releases are tagged by date so you can tell what you are running -
`dejima --version` - and [CHANGELOG.md](CHANGELOG.md) says what each one
changed. A tag is a record of what shipped, not a version anyone may stay on.
## No warranty
The software is provided without warranty of any kind. See sections 15 and 16
+3 -3
View File
@@ -5,9 +5,9 @@ page that renders dark, `-lightmode` for one that renders light. The suffix
names the mode a file is for, not the colour of its ink, so `-darkmode` is the
pale one.
The README points at `-darkmode` unconditionally. A mirror on a light-themed
host changes that one line; the comment above the tag says why it is not
conditional.
The README points at `-darkmode` unconditionally; the comment above the tag
says why it is not conditional. The GitHub mirror rewrites that tag to pick
between the two by the reader's theme.
**These are trademarks and are not covered by this repository's GPL licence.**
Who holds them, and what may be done with them, is stated once for the whole
+928 -65
View File
File diff suppressed because it is too large Load Diff
+221 -103
View File
@@ -6,11 +6,12 @@
# actually needs, it should tell you the exact command to run here rather
# than try to work around the firewall.
#
# Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>
# Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>...
# dejima-allow crates.io # this running session only
# dejima-allow --global crates.io # persist for every project
# dejima-allow --project crates.io # persist for the project in $PWD
# dejima-allow --project ~/myproj crates.io # persist for one named project
# dejima-allow dl.example.com files.example.org # several, confirmed first
#
# --project and --global are approvals for future sessions and deliberately do
# not touch a running container: a scope flag means "record this", not "patch
@@ -25,32 +26,32 @@ case "${1:-}" in
cat <<'DEJIMA_ALLOW_HELP'
dejima-allow - let a sandbox reach a domain the egress firewall blocks
Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>
Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>...
Up to twenty. More than one is confirmed first, defaulting to no, which
needs a terminal - so a script does them one at a time.
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. 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.
if exactly one is running. With several it offers them to
pick from on a terminal, by project and by the number the
dejima-attach picker gives each; anywhere else it lists
them and stops, a prompt being 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. 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.
--global every future session in every project on this host.
They combine. --project and --global deliberately do NOT touch a session that
is already running: they are approvals for future ones. Pass --container as
well to do both at once.
They combine, and --project/--global deliberately do NOT touch a running
session: they are approvals for future ones, so pass --container to do both.
Prefer --project to --global - a domain one project needs is that project's,
and a global grant widens every other project's firewall too.
Prefer --project to --global. A domain one project needs is that project's, and
a global grant widens every other project's firewall too.
With --container, the domain is verified reachable afterwards and a warning is
With --container, each domain is verified reachable afterwards and a warning is
printed if it is not, so a typo or a blocked port does not look like success.
One that fails stops there naming what landed; a scope flag has them all by
then regardless.
See docs/configuration.md; docs/security.md explains what the allowlist bounds.
DEJIMA_ALLOW_HELP
@@ -73,34 +74,138 @@ source "$SCRIPT_DIR/../docker/fixed-domains.sh"
source "$SCRIPT_DIR/dejima-tui-lib.sh"
usage() {
echo "Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>" >&2
echo "Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>..." >&2
exit 1
}
# 1: the domain is the required trailing positional, which is what tells
# parse_scope_args whether a token after --project is a path or the domain.
# The most domains one command will take. A cap rather than no limit, because
# the confirmation below only works if the operator reads the list, and a list
# long enough to scroll is one nobody reads to the end of - which is where
# something would be hidden. Twenty is enough for the real case (a toolchain
# that fetches from several hosts) and short enough to take in at once.
readonly DEJIMA_ALLOW_MAX=20
# 1: at least one trailing positional, which is what tells parse_scope_args
# whether a token after --project is a path or a domain. It is a minimum, not
# an exact count - several domains may follow.
parse_scope_args 1 "$@" || usage
[ "${#dj_positional[@]}" -eq 1 ] || usage
domain="${dj_positional[0]}"
[ "${#dj_positional[@]}" -ge 1 ] || usage
domains=("${dj_positional[@]}")
global="$dj_global"
project=""
if [ "$dj_project_set" -eq 1 ]; then
# parse_scope_args consumes a token after --project as the path whenever
# enough positionals follow it, which with several domains is any of them.
# A path that is not a directory and does read as a domain was the first
# domain: `dejima-allow --project a.com b.com` means both of those, in the
# project in $PWD. A directory wins, so a project whose name happens to
# look like a hostname still works.
if [ -n "$dj_project" ] && [ ! -d "$dj_project" ] \
&& [[ "$dj_project" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,62}\.)*[a-zA-Z0-9-]{1,63}$ ]]; then
domains=("$dj_project" "${domains[@]}")
dj_project=""
fi
project="$(cd "${dj_project:-$PWD}" && pwd)"
disclose_project_symlink "$project"
fi
container="$dj_container"
# Validated up front, not just passed as a positional arg below: this also
# has to be a well-formed hostname to be a valid dnsmasq server=/domain/...
# line (e.g. no embedded "/"), on top of ruling out shell metacharacters. A
# sandboxed agent can suggest this exact command to the user to copy-paste
# (see sandbox-context.sh), so a prompt-injected agent handing back a
# malicious "domain" is a real path here, not just a theoretical one.
if [[ ! "$domain" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,62}\.)*[a-zA-Z0-9-]{1,63}$ ]]; then
echo "Not a valid domain: $domain" >&2
# Repeats dropped, order kept. A list with the same name twice asks the
# operator to approve it twice and makes the count they are reading wrong.
_seen=""
_unique=()
for _d in "${domains[@]}"; do
case "$_seen" in *"|${_d,,}|"*) continue ;; esac
_seen="$_seen|${_d,,}|"
_unique+=("$_d")
done
domains=("${_unique[@]}")
# What to call this request where it is described as a whole, rather than once
# per name. Anything outside the per-domain loops below has no $domain to use,
# and under set -u reaching for one is fatal rather than merely wrong - which is
# how the container picker's own prompt took this script down.
if [ "${#domains[@]}" -eq 1 ]; then
domain_label="${domains[0]}"
else
domain_label="${#domains[@]} domains"
fi
if [ "${#domains[@]}" -gt "$DEJIMA_ALLOW_MAX" ]; then
echo "That is ${#domains[@]} domains, and this takes at most $DEJIMA_ALLOW_MAX at once." >&2
echo "" >&2
echo "The limit is there because the confirmation below is only worth anything if" >&2
echo "you read the whole list. Split it up, and read each part." >&2
exit 1
fi
# Every domain validated BEFORE any of them is shown or recorded, and the whole
# command refused if any fails. Two reasons beyond the obvious one.
#
# The list the operator confirms has to be the list that gets added: validating
# as we went would ask them to approve names, then add a prefix of them and
# fail. And the confirmation rests on the list being readable as printed - this
# pattern allows letters, digits, hyphens and dots and nothing else, so no
# entry can carry a carriage return or an escape sequence that rewrites the
# lines above it on the way past.
#
# It also has to be a well-formed hostname to be a valid dnsmasq
# server=/domain/... line (e.g. no embedded "/"), on top of ruling out shell
# metacharacters. A sandboxed agent can suggest this exact command for the
# operator to copy-paste (see sandbox-context.sh), so a prompt-injected agent
# handing back a malicious "domain" is a real path here, not a theoretical one.
#
# All of them reported, not the first: an operator fixing a typo in a list of
# ten should not have to run it ten times to find the second one.
_bad=()
for _d in "${domains[@]}"; do
[[ "$_d" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,62}\.)*[a-zA-Z0-9-]{1,63}$ ]] || _bad+=("$_d")
done
if [ "${#_bad[@]}" -gt 0 ]; then
if [ "${#_bad[@]}" -eq 1 ]; then
echo "Not a valid domain: ${_bad[0]}" >&2
else
echo "Not valid domains:" >&2
printf ' %s\n' "${_bad[@]}" >&2
fi
exit 1
fi
# One domain goes through as it always has. More than one is confirmed first,
# because a list is where something unwanted can ride along unread - and the
# agent that suggested the command is exactly what might have put it there.
#
# Default no, and the list on stdout one per line so nothing is off the right
# edge of a narrow terminal.
#
# No terminal means no confirmation is possible, so it refuses rather than
# assuming either answer. A script that wants several does them one at a time,
# which is the behaviour this had before and still has for one.
if [ "${#domains[@]}" -gt 1 ]; then
echo "About to allow ${#domains[@]} domains:"
printf ' %s\n' "${domains[@]}"
echo ""
_where=()
[ -n "$project" ] && _where+=("every future session in $project")
[ "$global" -eq 1 ] && _where+=("every future session in every project")
[ -n "$container" ] && _where+=("the running session $container")
[ "${#_where[@]}" -gt 0 ] || _where=("the running session")
printf 'This grants them to: %s\n' "$(printf '%s; ' "${_where[@]}" | sed 's/; $//')"
echo ""
if [ ! -t 0 ]; then
echo "Several domains at once needs a terminal to confirm on, and this is not one." >&2
echo "Run them one at a time, which needs no confirmation." >&2
exit 1
fi
printf 'Add all %s? [y/N] ' "${#domains[@]}"
read -r _reply || _reply=""
case "$_reply" in
y|Y|yes|YES|Yes) ;;
*) echo "Nothing was added."; exit 1 ;;
esac
echo ""
fi
# Recorded first, and unconditionally: --project/--global are the user's
# approval decision for future sessions, not a claim about this one. Gating
# either on a live container would break the documented workflow outright -
@@ -111,6 +216,7 @@ fi
# given together, and each persists to its own store.
if [ -n "$project" ]; then
store="$(ensure_project_store "$project")"
for domain in "${domains[@]}"; do
approve_entry "$store" allowed-domains "$domain"
echo "Added $domain to the approved domains for $project (applies to every future session in it)."
# Printed only after the approval above, and guarded rather than left to
@@ -124,11 +230,14 @@ if [ -n "$project" ]; then
echo "The approval above IS recorded and applies to every future session in it;" >&2
echo "only the project's own .dejima declaration could not be written." >&2
fi
done
fi
if [ "$global" -eq 1 ]; then
store="$(ensure_global_store)"
approve_entry "$store" allowed-domains "$domain"
echo "Added $domain to the global approved domains (applies to every future session)."
for domain in "${domains[@]}"; do
approve_entry "$store" allowed-domains "$domain"
echo "Added $domain to the global approved domains (applies to every future session)."
done
fi
# A scope flag means "record this approval", not "patch what is running now" -
@@ -225,7 +334,7 @@ pick_container() {
"$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"
tui_pick "${#CT_IDS[@]}" "allow $domain_label in"
container="${CT_NAMES[$((TUI_CHOICE - 1))]}"
}
@@ -313,7 +422,7 @@ if [ "$session_wanted" -eq 1 ]; then
echo "inside it - either half of the pair is accepted here. If the session has" >&2
echo "already exited, record the domain for the next one instead:" >&2
echo "" >&2
echo " dejima-allow --project $domain" >&2
echo " dejima-allow --project ${domains[*]}" >&2
exit 1
fi
filter="$container"
@@ -348,83 +457,92 @@ if [ "$session_wanted" -eq 1 ]; then
# on the same name - with the failed launch as the operator's first hint
# that the two commands had different ideas.
#
# $domain reaches the container as an argument rather than inside the
# command string: it is validated above, and this keeps it that way if that
# validation is ever loosened.
# Each domain reaches the container as an argument rather than inside the
# command string: they are validated above, and this keeps it that way if
# that validation is ever loosened.
#
# Guarded on the file existing: config_get says so on stderr when it does
# not, which is right for a launch and noise for this, where a host with no
# configuration at all is an ordinary case and the answer is simply "not
# listed". A config that exists and is malformed still reports itself.
internal=0
if [ -f "$(config_path)" ]; then
# `|| [ -n "$_d" ]`, because config_get prints its value with no
# trailing newline - so a single-entry list is one line read never
# returns, and the loop body would not run at all.
# Case-folded, like the class decision below and dejima_mark_internal:
# a live add that misses the exemption the operator configured leaves
# the name without it until the next launch, which then refuses to
# start on the same mismatch.
while IFS= read -r _d || [ -n "$_d" ]; do
[ "${_d,,}" = "${domain,,}" ] && internal=1
done < <(config_get core internal_domains | tr ':' '\n')
fi
permit_args=(permit "$domain")
[ "$(dejima_class_for "$domain")" = forge ] && permit_args+=(--forge)
[ "$internal" -eq 1 ] && permit_args+=(--internal-ok)
echo "Permitting $domain in $filter, this session's egress control..."
if ! docker exec -u root "$filter" /usr/local/sbin/egress-policy "${permit_args[@]}"; then
echo "" >&2
echo "The permitted set was left as it was - the reason is above." >&2
if [ "$internal" -eq 0 ]; then
echo "" >&2
echo "If $domain resolves inside your own network, say so in the config and" >&2
echo "relaunch - an answer pointing inside is refused for every name that is" >&2
echo "not listed there, whether or not it is approved:" >&2
echo "" >&2
echo " [core]" >&2
echo " internal_domains = $domain" >&2
echo "" >&2
echo "It still needs an approval to be reachable at all:" >&2
echo "" >&2
echo " dejima-allow --global $domain" >&2
# One domain at a time, because each is its own change to the running
# policy: the class it belongs to, whether the operator declared it
# internal, and whether it then answers. A failure stops the loop rather
# than carrying on, so what did and did not land is the order printed
# above - and the names after it are still recorded under any scope flag
# given, which happened before this and is not undone by a live add
# failing.
for domain in "${domains[@]}"; do
internal=0
if [ -f "$(config_path)" ]; then
# `|| [ -n "$_d" ]`, because config_get prints its value with no
# trailing newline - so a single-entry list is one line read never
# returns, and the loop body would not run at all.
# Case-folded, like the class decision below and dejima_mark_internal:
# a live add that misses the exemption the operator configured leaves
# the name without it until the next launch, which then refuses to
# start on the same mismatch.
while IFS= read -r _d || [ -n "$_d" ]; do
[ "${_d,,}" = "${domain,,}" ] && internal=1
done < <(config_get core internal_domains | tr ':' '\n')
fi
exit 1
fi
echo "$domain allowed for the running session ($container)."
permit_args=(permit "$domain")
[ "$(dejima_class_for "$domain")" = forge ] && permit_args+=(--forge)
[ "$internal" -eq 1 ] && permit_args+=(--internal-ok)
# TCP-level truth check from inside the very container that was just
# updated - this script's job is the firewall, so "TCP connect succeeded"
# is the claim to verify. curl exit 6 (resolve failed), 7 (connect
# refused/blocked) and 28 (connect or overall timeout) mean still
# unreachable; anything else (0, or a TLS/HTTP-layer code like 35/52) means
# the connection itself went through the firewall fine. --max-time bounds
# the whole request, not just the connect phase - without it, a server that
# accepts the TCP connection and then stalls (never sends TLS/HTTP bytes)
# would hang this script indefinitely; curl maps that case to the same
# exit 28 the case below already handles, so no new branch is needed. This
# is a report on this run, not a gate on either scope's approval: both
# --project and --global are already recorded above, deliberately - a
# declared host may legitimately not answer on 443 (an internal service,
# an SSH-only host, an apex that only serves a subdomain), and the natural
# moment to add a global approval is precisely when nothing is running to
# probe in the first place. The exit 2 below still means what it says:
# the live patch to this session did not verify.
echo "Verifying $domain is reachable from $container..."
rc=0
docker exec "$container" curl -sI --connect-timeout 5 --max-time 10 "https://$domain/" >/dev/null 2>&1 || rc=$?
case "$rc" in
6|7|28)
echo "WARNING: $domain is still NOT reachable from $container (curl exit $rc)." >&2
echo "If the service doesn't listen on 443 this check can fail spuriously;" >&2
echo "otherwise the allow did not take effect - re-check the container ID." >&2
exit 2
;;
*)
echo "Verified: $domain is reachable from $container."
;;
esac
echo "Permitting $domain in $filter, this session's egress control..."
if ! docker exec -u root "$filter" /usr/local/sbin/egress-policy "${permit_args[@]}"; then
echo "" >&2
echo "The permitted set was left as it was - the reason is above." >&2
if [ "$internal" -eq 0 ]; then
echo "" >&2
echo "If $domain resolves inside your own network, say so in the config and" >&2
echo "relaunch - an answer pointing inside is refused for every name that is" >&2
echo "not listed there, whether or not it is approved:" >&2
echo "" >&2
echo " [core]" >&2
echo " internal_domains = $domain" >&2
echo "" >&2
echo "It still needs an approval to be reachable at all:" >&2
echo "" >&2
echo " dejima-allow --global $domain" >&2
fi
exit 1
fi
echo "$domain allowed for the running session ($container)."
# TCP-level truth check from inside the very container that was just
# updated - this script's job is the firewall, so "TCP connect succeeded"
# is the claim to verify. curl exit 6 (resolve failed), 7 (connect
# refused/blocked) and 28 (connect or overall timeout) mean still
# unreachable; anything else (0, or a TLS/HTTP-layer code like 35/52) means
# the connection itself went through the firewall fine. --max-time bounds
# the whole request, not just the connect phase - without it, a server that
# accepts the TCP connection and then stalls (never sends TLS/HTTP bytes)
# would hang this script indefinitely; curl maps that case to the same
# exit 28 the case below already handles, so no new branch is needed. This
# is a report on this run, not a gate on either scope's approval: both
# --project and --global are already recorded above, deliberately - a
# declared host may legitimately not answer on 443 (an internal service,
# an SSH-only host, an apex that only serves a subdomain), and the natural
# moment to add a global approval is precisely when nothing is running to
# probe in the first place. The exit 2 below still means what it says:
# the live patch to this session did not verify.
echo "Verifying $domain is reachable from $container..."
rc=0
docker exec "$container" curl -sI --connect-timeout 5 --max-time 10 "https://$domain/" >/dev/null 2>&1 || rc=$?
case "$rc" in
6|7|28)
echo "WARNING: $domain is still NOT reachable from $container (curl exit $rc)." >&2
echo "If the service doesn't listen on 443 this check can fail spuriously;" >&2
echo "otherwise the allow did not take effect - re-check the container ID." >&2
exit 2
;;
*)
echo "Verified: $domain is reachable from $container."
;;
esac
done
fi
+380 -61
View File
@@ -19,12 +19,21 @@ case "${1:-}" in
cat <<'DEJIMA_ATTACH_HELP'
dejima-attach - list live dejima sessions and attach to one
Usage: dejima-attach [run-id | --list]
Usage: dejima-attach [run-id | --watch | --list | --count-waiting]
(no argument) numbered picker of live sessions; attaches to the choice
run-id attach to one directly, which is what a notification's deep
link and ttyd's --url-arg both produce
--watch the same picker, redrawn every few seconds: which sessions
are working and which are waiting for you, in one window
--list one tab-separated row per live session, for scripting
--count-waiting how many are waiting for you, for a tmux status line
--counts one line of key=value for all of them, counted in one pass
--state-dir where the registry and the reported states live
Inside tmux the picker switches this client to the session's own window
instead of attaching, so it is a window switcher there and a front door
everywhere else.
This is the shared core behind both remote front doors: it runs as a forced SSH
command and as ttyd's command, so what an SSH key can reach and what the web
@@ -72,16 +81,42 @@ source "$SCRIPT_DIR/dejima-tui-lib.sh"
# 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.
# Coloured by meaning, not by name: waiting is the one an operator is looking
# for, working is the one they can leave alone, and stale/no-tmux are the two
# that will refuse if picked.
STATE_COLOR=""
state_color() {
case "$1" in
attachable) printf '%s' "$C_OK" ;;
stale) printf '%s' "$C_ERR" ;;
*) printf '%s' "$C_WARN" ;;
working|attachable) STATE_COLOR="$C_OK" ;;
stale) STATE_COLOR="$C_ERR" ;;
*) STATE_COLOR="$C_WARN" ;;
esac
}
# What the session last said about itself, from the level directory
# hooks/dejima-notify.sh maintains. Empty for a session that has said nothing
# yet, for a harness that reports nothing, and for a launch from before the
# directory existed - all three render as the plain reachability state.
#
# The enum is iterated rather than the directory read, so a name the session
# invented is never looked at. `done` comes before `running` deliberately: the
# hook clears the siblings before writing, so holding both means a session has
# contradicted itself, and the answer that sends an operator to look is the
# safe one. -L as well as -e because a dangling symlink is a file a session can
# plant as easily as an empty one, and it is still the session reporting on
# itself either way.
SESSION_LEVEL=""
session_level() {
local dir="$STATE_DIR/notify/$1/level" event
SESSION_LEVEL=""
for event in 'done' waiting running; do
if [ -e "$dir/$event" ] || [ -L "$dir/$event" ]; then
SESSION_LEVEL="$event"
return 0
fi
done
}
# Only for a registry entry written before pid= existed, i.e. a session that
# outlived an upgrade: how long after its own started= it may still have no
# container before the prune treats it as gone. Sized against a cold first
@@ -96,6 +131,7 @@ PRE_PID_STARTUP_GRACE=900
read_entry() {
local file="$1" line
entry_workspace=""; entry_pane=""; entry_label=""; entry_started=""; entry_pid=""
entry_name=""
[ -r "$file" ] || return 1
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
@@ -104,6 +140,7 @@ read_entry() {
label=*) entry_label="${line#*=}" ;;
started=*) entry_started="${line#*=}" ;;
pid=*) entry_pid="${line#*=}" ;;
name=*) entry_name="${line#*=}" ;;
esac
done < "$file" || return 1
# pid reaches `kill`, which takes a job spec as well as a number: "%1" or
@@ -122,36 +159,97 @@ read_entry() {
esac
}
# The container that reports this run ID. bin/dejima passes DEJIMA_RUN_ID in
# as env precisely so this correlation is possible without the container
# having any write access to the registry.
# Every running sandbox's container, keyed by the run id it reports.
# bin/dejima passes DEJIMA_RUN_ID in as env precisely so this correlation is
# possible without the container having any write access to the registry.
#
# Exit status distinguishes "no such container" (1) from "couldn't ask" (2):
# the caller prunes on the former, and pruning on the latter would permanently
# unregister every running sandbox the moment dockerd happens to be restarting.
container_for() {
local run_id="$1" names name env unasked=""
# Built once per listing rather than once per session: this used to run
# `docker ps` and then inspect containers one by one until it found the match,
# for every entry in the registry in turn, so a dozen sessions cost a dozen
# `docker ps` calls and up to a hundred and forty-four inspects. The listing
# is what both front doors open on, and that is time an operator waits.
#
# CONTAINER_MAP_UNASKED is what keeps "no such container" (1) and "couldn't
# ask" (2) apart downstream: the caller prunes on the former, and pruning on
# the latter would permanently unregister every running sandbox the moment
# dockerd happened to be restarting. One unanswerable inspect anywhere makes
# every miss a 2, which is the same reach the per-session loop had.
declare -A CONTAINER_BY_RUN=()
CONTAINER_MAP_UNASKED=""
load_container_map() {
local names line id name
CONTAINER_BY_RUN=()
CONTAINER_MAP_UNASKED=""
names="$(docker ps --filter "label=com.docker.compose.project=dejima" \
--filter "status=running" --format '{{.Names}}' 2>/dev/null)" || return 2
for name in $names; do
# Captured rather than piped straight into grep: a pipeline reports
# only grep's status, which would turn "inspect couldn't answer for
# this container" into "this isn't the one" - and one such container
# anywhere in the list would prune an unrelated live session.
env="$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$name" 2>/dev/null)" \
|| { unasked=1; continue; }
if printf '%s\n' "$env" | grep -qxF "DEJIMA_RUN_ID=$run_id"; then
printf '%s' "$name"
return 0
fi
done
[ -z "$unasked" ] || return 2
--filter "status=running" --format '{{.Names}}' 2>/dev/null)" \
|| { CONTAINER_MAP_UNASKED=1; return 0; }
[ -n "$names" ] || return 0
# One inspect for every container rather than one each. Two round trips to
# the daemon where there were one plus one per container, which on a host
# running a dozen sessions was most of the wait before the picker drew.
#
# Both values as JSON, which is what makes one line per container a
# property rather than a hope: a newline inside an environment value is
# escaped rather than printed, so a line cannot be split and the name
# cannot drift away from the environment beside it. A missing container
# takes its line with it instead of shifting the rest.
#
# Read with shell string operations rather than a JSON parser: the only
# question here is which element STARTS with DEJIMA_RUN_ID=, and an
# unescaped quote is exactly where an element starts. A value that merely
# contained that text would carry a \" there and not match.
local -a name_list=()
mapfile -t name_list <<< "$names"
local out
# A container that went away between the two calls makes inspect exit
# nonzero while still answering for the others. Unverified rather than
# gone, which is the same answer one failed inspect gave when there was one
# per container: pruning on that unregisters a live session for good.
out="$(docker inspect --format '{{json .Name}} {{json .Config.Env}}' \
"${name_list[@]}" 2>/dev/null)" || CONTAINER_MAP_UNASKED=1
while IFS= read -r line; do
case "$line" in *'"DEJIMA_RUN_ID='*) ;; *) continue ;; esac
id="${line#*\"DEJIMA_RUN_ID=}"
id="${id%%\"*}"
valid_run_id "$id" || continue
# {{json .Name}} is a quoted "/name"; docker ps gave the same names
# without the slash, and the registry is keyed by neither.
name="${line%% *}"
name="${name#\"}"; name="${name%\"}"; name="${name#/}"
CONTAINER_BY_RUN["$id"]="$name"
done <<< "$out"
}
# The container reporting this run id, out of the map above. The answer comes
# back in a variable rather than on stdout, like the two readers below: every
# one of these is called once per session, and `$(...)` is a forked subshell
# per call - which on a host running a dozen sessions was more of the wait than
# the work.
CONTAINER_NAME=""
container_for() {
CONTAINER_NAME="${CONTAINER_BY_RUN[$1]:-}"
[ -z "$CONTAINER_NAME" ] || return 0
[ -z "$CONTAINER_MAP_UNASKED" ] || return 2
return 1
}
# Every pane on the server, asked once. This was a `tmux list-panes` and a
# grep per session, so a dozen sessions meant a dozen round trips to the tmux
# server and two dozen processes, for one answer that does not change while a
# listing is being built.
# Every caller of pane_alive loads this first: list_sessions and count_waiting
# once for the listing they are building, and attach() again at the moment it
# matters. A path that forgets reads an empty list and calls every session
# stale, which is loud rather than subtle - it is what the attach tests catch.
PANE_LIST=""
load_pane_list() {
PANE_LIST=$'\n'"$(tmux list-panes -a -F '#{pane_id}' 2>/dev/null || true)"$'\n'
}
pane_alive() {
[ -n "$1" ] || return 1
tmux list-panes -a -F '#{pane_id}' 2>/dev/null | grep -qxF "$1"
case "$PANE_LIST" in *$'\n'"$1"$'\n'*) return 0 ;; esac
return 1
}
# Does session $1 actually hold pane $2? The only question that separates the
@@ -197,16 +295,31 @@ launcher_alive() {
# no matching container - and old enough that it should have had one - is
# pruned outright; one whose pane is gone is shown as stale rather than
# offered for attachment.
#
# Oldest first, by start time, which is the one order that holds still while an
# operator is reading it: the numbers in the picker are how a session is
# selected, so a list that re-sorted itself by state - the tempting order for a
# live view - would renumber the rows between reading one and typing it. A new
# session appears at the end and changes nothing above it.
list_sessions() {
local file run_id container state now rc warned=""
# The sort key is prepended by the loop and dropped here, so the columns
# the picker and --list read are unchanged.
list_sessions_unsorted | sort -n | cut -f2-
}
list_sessions_unsorted() {
local file run_id container state project now rc warned=""
[ -d "$SESSIONS_DIR" ] || return 0
now="$(date +%s)"
load_container_map
load_pane_list
for file in "$SESSIONS_DIR"/*; do
[ -f "$file" ] || continue
run_id="$(basename "$file")"
valid_run_id "$run_id" || continue
rc=0
container="$(container_for "$run_id")" || rc=$?
container_for "$run_id" || rc=$?
container="$CONTAINER_NAME"
if [ "$rc" -eq 1 ] && launcher_alive "$file" "$now"; then
# Registered, but bin/dejima is still building - "not started yet"
# is a third answer, and pruning on it would unregister a session
@@ -249,12 +362,102 @@ list_sessions() {
else
state="stale"
fi
printf '%s\t%s\t%s\t%dm\t%s\t%s\n' \
"$run_id" "${entry_label:-?}" "$(basename "$entry_workspace")" \
# One column for two questions, because they do not overlap: a session
# that cannot be attached to has nothing useful to say about whether it
# wants attention, and for every session that CAN be attached to,
# "attachable" is the answer to a question nobody asked. The words are
# distinct, so the column stays unambiguous while the row stays narrow
# enough for a phone.
# ${x##*/} rather than basename, which is a process per row.
project="${entry_workspace##*/}"
[ -n "$project" ] || project="$entry_workspace"
if [ "$state" = attachable ]; then
session_level "$run_id"
# `done` and `waiting` are the harness events the hook reports -
# a turn ended, and something was asked for. One state to an
# operator, who does the same thing about either.
case "$SESSION_LEVEL" in
done|waiting) state="waiting" ;;
running) state="working" ;;
esac
fi
# The name is second, before the columns that were already here, so a
# row reads id-name-project rather than ending on the one field an
# operator scans for. It is empty for a session launched without
# --name, which is what the picker's fallback to the project is for.
printf '%d\t%s\t%s\t%s\t%s\t%dm\t%s\t%s\n' \
"$(( 10#$entry_started ))" \
"$run_id" "$entry_name" "${entry_label:-?}" "$project" \
"$(( (now - 10#$entry_started) / 60 ))" "$state" "${container:0:12}"
done
}
# What every live session is doing, counted in one pass, for a status line to
# interpolate. Registry, panes and levels only: no docker, and nothing pruned.
#
# A status line re-runs this every few seconds, where --list is read once by a
# person, so the one question that costs a round trip to the daemon per
# container is the one question this does not ask. What that gives up is
# noticing a session whose container died without taking bin/dejima with it -
# counted here, shown as gone by every listing - and telling a session whose
# image is still building from one that is simply quiet; both read as
# `attachable`. The pane list is one tmux call for all of them, because the
# alternative is one per session.
#
# The words are the picker's, so that a number on a status bar and a row on a
# screen say the same thing about the same session rather than two vocabularies
# meeting in an operator's head.
declare -A SESSION_COUNTS=()
count_states() {
local file run_id now state
SESSION_COUNTS=([waiting]=0 [working]=0 [attachable]=0 [stale]=0 [no-tmux]=0 [total]=0)
[ -d "$SESSIONS_DIR" ] || return 0
now="$(date +%s)"
load_pane_list
for file in "$SESSIONS_DIR"/*; do
[ -f "$file" ] || continue
run_id="$(basename "$file")"
valid_run_id "$run_id" || continue
# A session whose launcher is gone is one the exit trap did not get to
# remove. Not counted at all rather than counted as broken: the listing
# is where an operator goes to find out about those, and a status bar
# that never reaches zero stops being read.
launcher_alive "$file" "$now" || continue
read_entry "$file" || continue
if [ -z "$entry_pane" ]; then
state=no-tmux
elif ! pane_alive "$entry_pane"; then
state=stale
else
session_level "$run_id"
case "$SESSION_LEVEL" in
done|waiting) state=waiting ;;
running) state=working ;;
*) state=attachable ;;
esac
fi
SESSION_COUNTS[$state]=$(( SESSION_COUNTS[$state] + 1 ))
SESSION_COUNTS[total]=$(( SESSION_COUNTS[total] + 1 ))
done
}
count_waiting() {
count_states
printf '%s\n' "${SESSION_COUNTS[waiting]}"
}
# One line of key=value, in the order a reader cares about them. A line rather
# than a number because two numbers cost two invocations otherwise, and this
# one is run on a timer.
counts_line() {
local key out=""
count_states
for key in waiting working attachable stale no-tmux total; do
out="$out$key=${SESSION_COUNTS[$key]} "
done
printf '%s\n' "${out% }"
}
# Attaches through a GROUPED session rather than the operator's own. Grouped
# sessions share windows but track their own current window, so a phone can
# sit on a different sandbox than the desktop, and destroy-unattached disposes
@@ -278,6 +481,9 @@ attach() {
echo "Session $run_id was not started from tmux - nothing to attach to." >&2
exit 1
fi
# Freshly, not from whatever a listing left behind: the pane may have gone
# away in the seconds between an operator reading the row and picking it.
load_pane_list
if ! pane_alive "$entry_pane"; then
echo "Session $run_id is stale - its tmux pane is gone." >&2
exit 1
@@ -306,6 +512,20 @@ attach() {
echo "Session $run_id is stale - its tmux pane went away." >&2
exit 1
fi
# Already inside tmux: move this client rather than attaching. Everything
# below is written for a client that has no tmux of its own - a phone, an
# ssh forced command - and tmux refuses to nest a session inside one of its
# own panes, so on the operator's own machine the picker could list
# sessions and then fail to reach any of them. Switching is also the
# cheaper thing by a long way: no throwaway session to create, verify and
# reap, and the window the operator lands on is the real one.
#
# switch-client rather than select-window, so a sandbox launched from some
# other tmux session is reachable too and not just a window of this one.
if [ -n "${TMUX:-}" ]; then
tmux switch-client -t "=$base:$win"
return 0
fi
if tmux has-session -t "=$group" 2>/dev/null; then
# Verified exactly like the session created below, and for the same
# reason: nothing here created this one, so carrying the name is not
@@ -366,51 +586,150 @@ attach() {
trap - EXIT
}
# An age a glance can read: minutes for the first hour, then hours, then days.
# `--list` keeps the minutes it has always printed, because a number is what a
# script wants; three characters is what lets the row fit the phone, and "2h"
# is what tells an operator a session has been working since before lunch
# where "137m" is a sum to do first.
AGE_SHORT=""
age_short() { # <minutes, as --list prints them>
local m="${1%m}"
if [ "$m" -lt 60 ]; then AGE_SHORT="${m}m"
elif [ "$m" -lt 2880 ]; then AGE_SHORT="$(( m / 60 ))h"
else AGE_SHORT="$(( m / 1440 ))d"
fi
}
# Draws the list and reads a choice. With a timeout it returns 1 when nothing
# was typed, which is what --watch loops on.
picker() {
local -a ids=() labels=() projects=() ages=() states=() containers=()
local i choice run_id label project age state container
local w_label=0 w_project=0 w_age=0 w_state=0
# Split on tabs only, so a space in a project name stays part of one field.
# The columns are measured on the way in because they are padded to the
# widest value rather than to a guess: a project name is a directory name,
# and a fixed width either truncates a long one or indents every short one
# off the right edge of a phone.
while IFS=$'\t' read -r run_id label project age state container; do
ids+=("$run_id"); labels+=("$label"); projects+=("$project")
ages+=("$age"); states+=("$state"); containers+=("$container")
[ "${#label}" -le "$w_label" ] || w_label="${#label}"
[ "${#project}" -le "$w_project" ] || w_project="${#project}"
[ "${#age}" -le "$w_age" ] || w_age="${#age}"
local timeout="${1:-}"
local -a ids=() names=() ages=() states=()
local i choice run_id name label project age state container
local w_name=0 w_state=0
# Three columns, because the front door this is read through is a phone.
# The tmux label and the container id are in `--list` and not here: the
# label is a coordinate an operator no longer has to act on, since picking
# a session goes to it, and the container id shares a prefix with every
# other one, so a column of them says nothing. Carrying all five made a row
# about eighty columns wide, which wrapped onto a second line and put the
# state and the age under the NEXT session's number.
#
# The name is first, against the index, because those two are what an
# operator matches up: the number is only useful next to the thing it
# selects. It is measured on the way in rather than padded to a guess, and
# capped, since the padding decides where the state sits.
# Split on a unit separator, not on the tab the rows are built with. A tab
# is IFS whitespace, so a RUN of them collapses into one delimiter: a row
# whose name is empty - every entry written before that field existed -
# loses the field entirely and shifts the five after it along. What that
# looked like was the tmux label sitting where the name goes, the state
# sitting in the age column, and the row coloured from the container id,
# so a stale session was neither red nor labelled stale where it was read.
while IFS=$'\037' read -r run_id name label project age state container; do
# The project stands in for an entry written before the name existed.
[ -n "$name" ] || name="$project"
# The two answers the container column carried that were not
# decoration, where they are read rather than in a column of their
# own: a session whose image is still building is not an ordinary
# attachable one, and "?" is already said once on stderr for the whole
# listing when docker could not be asked at all.
[ "$container" != starting ] || state=starting
ids+=("$run_id"); names+=("$name"); states+=("$state")
age_short "$age"
ages+=("$AGE_SHORT")
[ "${#name}" -le "$w_name" ] || w_name="${#name}"
[ "${#state}" -le "$w_state" ] || w_state="${#state}"
done < <(list_sessions)
done < <(list_sessions | tr '\t' '\037')
banner
if [ "${#ids[@]}" -eq 0 ]; then
printf ' %sNo live sessions.%s\n\n' "$C_MUTED" "$C_OFF"
# A live view waits for one to appear, where the one-shot picker has
# its answer and is done. Launching a session is exactly when an
# operator is watching this, so exiting here would close the window
# they opened to watch it in.
[ -z "$timeout" ] || { sleep "$timeout"; return 1; }
exit 0
fi
# The container column stays, and last, because it is not decoration: it
# reads "starting" for a session bin/dejima is still building and "?" when
# docker could not be asked at all, and dropping it would make either one
# look like an ordinary attachable session.
# The name column takes whatever the terminal has left after the fixed
# parts, rather than a width picked here. A fixed one is wrong in both
# directions at once: a desktop has room for every name and would cut them
# anyway, and a narrow phone would still overflow and wrap, which is the
# failure this layout exists to avoid. 80 when there is no terminal to ask,
# which is the width of a pipe by convention.
#
# 15 is the row minus the name: two leading spaces, two for the index, two
# spaces, the dot, a space, two spaces after the name, two before the age
# and three for the age itself.
#
# Asked of the controlling terminal, not of stdin. `tput cols` reads the
# size from fd 0, and this picker's stdin is whatever the front door handed
# it - a pipe wherever the choice is fed in rather than typed - where tput
# answers with terminfo's 80 and names are cut on a phone that has fewer.
local cols budget
cols="$(stty size </dev/tty 2>/dev/null | cut -d' ' -f2 || true)"
case "$cols" in ''|*[!0-9]*) cols=80 ;; esac
# A pty nobody set a size on reports zero columns, which is not a width but
# an unanswered question: taken literally it clamps every name to the floor
# below, on a terminal that may be perfectly wide.
[ "$cols" -ge 20 ] || cols=80
budget=$(( cols - 15 - w_state ))
# A terminal too narrow for a name at all still gets something to read.
[ "$budget" -ge 8 ] || budget=8
[ "$w_name" -le "$budget" ] || w_name="$budget"
for i in "${!ids[@]}"; do
local sc; sc="$(state_color "${states[$i]}")"
printf ' %s%2d%s %s%s%s %s%-*s%s %s%-*s%s %s%*s%s %s%-*s%s %s%s%s\n' \
state_color "${states[$i]}"
# Cut names are marked. A name quietly losing its last characters is
# worse than a short column: two projects can share twenty-four
# characters and differ in the twenty-fifth, and nothing on the row
# would say so.
#
# An ASCII marker even where the rest of the glyphs are not, because
# this one sits INSIDE a padded field: bash measures a multi-byte
# character as its bytes wherever the locale is not UTF-8, and printf
# then pads the field to that many bytes, so the row would come out two
# columns short and take the state column with it. This script runs
# under whatever locale a forced ssh command or the ttyd unit has.
if [ "${#names[$i]}" -gt "$w_name" ]; then
names[$i]="${names[$i]:0:$(( w_name - 1 ))}~"
fi
printf ' %s%2d%s %s%s%s %s%-*s%s %s%-*s%s %s%3s%s\n' \
"$C_WARN" "$((i + 1))" "$C_OFF" \
"$sc" "$GLYPH_DOT" "$C_OFF" \
"$C_BOLD$C_TEXT" "$w_project" "${projects[$i]}" "$C_OFF" \
"$C_LINK" "$w_label" "${labels[$i]}" "$C_OFF" \
"$C_MUTED" "$w_age" "${ages[$i]}" "$C_OFF" \
"$sc" "$w_state" "${states[$i]}" "$C_OFF" \
"$C_SUBTLE" "${containers[$i]}" "$C_OFF"
"$STATE_COLOR" "$GLYPH_DOT" "$C_OFF" \
"$C_BOLD$C_TEXT" "$w_name" "${names[$i]}" "$C_OFF" \
"$STATE_COLOR" "$w_state" "${states[$i]}" "$C_OFF" \
"$C_MUTED" "${ages[$i]}" "$C_OFF"
done
tui_pick "${#ids[@]}" 'attach to'
tui_pick "${#ids[@]}" 'attach to' "$timeout" || return 1
choice="$TUI_CHOICE"
attach "${ids[$((choice - 1))]}"
}
# How often --watch redraws. Five seconds, because a redraw is a full listing -
# one `docker ps`, one inspect per container - and the thing being watched is a
# session either finishing a turn or starting one, which is not a sub-second
# event. The screen clear in the banner is what keeps a redraw from scrolling.
WATCH_INTERVAL=5
# The at-a-glance view: the same picker, redrawn until something is typed.
# Deliberately not a second tool. A separate monitor would have to grow its own
# list, its own columns and its own idea of what a session is, and the thing an
# operator does next after seeing which session wants them is to go to it -
# which this can already do, through the same numbers.
watch_picker() {
while ! picker "$WATCH_INTERVAL"; do :; done
}
case "${1:-}" in
'') picker ;;
--watch) watch_picker ;;
--list) list_sessions ;;
--count-waiting) count_waiting ;;
--counts) counts_line ;;
# Where the registry and the reported states live, for anything on this
# host that wants to read them without re-deriving which of
# XDG_RUNTIME_DIR and /tmp this user got.
--state-dir) printf '%s\n' "$STATE_DIR" ;;
*)
# This argument arrives from ttyd's --url-arg, i.e. from whoever can
# reach the web front door. Validate its shape before it is used as a
+4 -1
View File
@@ -974,7 +974,10 @@ tcb_snapshot() {
# backends/ likewise: a backend.conf names the upstream every request
# is forwarded to and the host path the credential is read from, so an
# edit here redirects where a real credential is sent.
for dir in bin hooks docker test forges workflows harnesses tools backends; do
#
# mirror/ runs on the host from a timer, holding a key that can push
# to every repository in the GitHub organisation.
for dir in bin hooks docker test forges workflows harnesses tools backends mirror; do
if [ -d "$ws/$dir" ]; then
while IFS= read -r -d '' f; do
stage_entry "tcb:${f#"$ws"/}" "$f"
+65 -7
View File
@@ -400,29 +400,81 @@ class Handler(BaseHTTPRequestHandler):
# forwarded upstream is a worse outcome than a rejected one.
MAX_BODY_BYTES = 32 * 1024 * 1024
def _length(self, raw, what):
"""A byte count from the wire, or BodyTooLarge.
`int()` alone accepts a negative, and a negative passes a `> MAX` test
and then means "read to EOF" to a buffered reader - so the cap above,
whose whole reason is that this runs on the host as the operator, was
bypassed by sending `Content-Length: -1`.
A value that is not a number still raises out of here, deliberately: the
suppression around handle_error is meant to be specific to the client
going away, and a malformed chunk length is the fault the test for that
uses to prove it. Refusing it cleanly here would remove the canary and
trade noise for blindness.
"""
n = int(raw, 16) if what == "chunk size" else int(raw)
if n < 0 or n > self.MAX_BODY_BYTES:
# The offending value, not the cap. The host log said "refused a
# request body: 33554432 bytes" whatever arrived, so a hostile -1
# and a genuinely oversized upload read identically - and the
# comment on the 413 path promises the log says what came in.
raise BodyTooLarge("%s of %d, cap %d"
% (what, n, self.MAX_BODY_BYTES))
return n
def handle_expect_100(self):
"""Refuse an oversized body before inviting it.
The default answers `100 Continue` and returns True, and it runs
before do_* - so the proxy invited the upload, read the length in
_read_body, refused it and closed mid-flight. Whether the client saw
the 413 or only the 100 was timing, which is a race a client cannot
be expected to win and a test cannot be expected to pass reliably.
The declared length is all there is to go on before a body arrives.
A chunked request declares nothing and is capped as it is read, which
is the one case where the conversation still ends mid-upload - there
is no earlier moment to end it at.
"""
try:
self._length(self.headers.get("Content-Length", 0), "Content-Length")
except BodyTooLarge as e:
# Closed for the same reason the other 413 closes: a client that
# sends anyway - curl gives up waiting after a second - leaves its
# bytes queued, and the next parse would read them as a request
# line and answer with garbage 400s instead of one clean refusal.
self.close_connection = True
self._fail(413, "credential proxy: request body too large",
log_message="refused a request body: %s" % e)
return False
return super().handle_expect_100()
def _read_body(self):
if self.headers.get("Transfer-Encoding", "").lower() == "chunked":
chunks = []
total = 0
while True:
size = int(self.rfile.readline().split(b";")[0], 16)
size = self._length(self.rfile.readline().split(b";")[0], "chunk size")
if size == 0:
self.rfile.readline()
return b"".join(chunks)
total += size
if total > self.MAX_BODY_BYTES:
raise BodyTooLarge("%d bytes" % self.MAX_BODY_BYTES)
raise BodyTooLarge("chunked body of %d or more, cap %d"
% (total, self.MAX_BODY_BYTES))
chunks.append(self.rfile.read(size))
self.rfile.read(2)
length = int(self.headers.get("Content-Length", 0))
if length > self.MAX_BODY_BYTES:
raise BodyTooLarge("%d bytes" % self.MAX_BODY_BYTES)
length = self._length(self.headers.get("Content-Length", 0), "Content-Length")
return self.rfile.read(length)
def _fail(self, status, message, log_message=None):
# log_message (host stderr) may contain upstream response bytes or
# exception detail; message (sent to the container in the response
# body) never should - see docs/security.md.
# exception detail; message (sent to the container in the response body)
# never should. The one thing that does cross is text written in this
# file naming something the operator must do - see CredentialNeedsOperator,
# and docs/security.md for why that is not an exception to the rule.
self.log_error("%s", log_message if log_message is not None else message)
body = json.dumps(
{"type": "error", "error": {"type": "proxy_error", "message": message}}
@@ -519,6 +571,12 @@ class Handler(BaseHTTPRequestHandler):
# 413 rather than a traceback and a 500: the session gets a status
# that says what to do about it, and the host log says what came
# in. Nothing is forwarded upstream.
#
# And the connection ends here. The body was refused before it was
# read, so its bytes are still queued; left open, the next parse
# takes them as a request line and answers a keep-alive client with
# garbage 400s instead of one clean refusal.
self.close_connection = True
return self._fail(413, "credential proxy: request body too large",
log_message="refused a request body: %s" % e)
try:
+45 -21
View File
@@ -160,17 +160,31 @@ def classify(path, value):
return (None, None)
def walk_json(doc, prefix=""):
def walk_json(doc, prefix="", parts=()):
"""Yields (display path, value, components).
The display path is what an operator reads and what finding_id hashes, so
its spelling is fixed - it is recorded in the decisions file and a change
would orphan every standing answer. It is also ambiguous: a dict key of
`x[0]` and element 0 of a list `x` produce the same string, and so do a key
containing a dot and the traversal a dot denotes.
The components are the same walk as a tuple, which is not ambiguous. Anything
that has to FIND the value again uses those; re-parsing the string deleted
the wrong element, and for a dotted key raised KeyError out of the scrub,
leaving the file to be copied unscrubbed.
"""
if isinstance(doc, dict):
for k, v in doc.items():
yield from walk_json(v, "%s.%s" % (prefix, k) if prefix else str(k))
yield from walk_json(v, "%s.%s" % (prefix, k) if prefix else str(k),
parts + (("key", k),))
elif isinstance(doc, list):
# Indexed, so a decision recorded against one element does not silently
# cover a different one later.
for i, v in enumerate(doc):
yield from walk_json(v, "%s[%d]" % (prefix, i))
yield from walk_json(v, "%s[%d]" % (prefix, i), parts + (("idx", i),))
else:
yield (prefix, doc)
yield (prefix, doc, parts)
def ini_logical_lines(text):
@@ -227,17 +241,28 @@ def walk_ini(text):
yield (key, value)
def delete_json_path(doc, path):
# Mirrors walk_json's construction exactly, including list indices.
parts = re.findall(r"[^.\[\]]+|\[\d+\]", path)
cur = doc
for part in parts[:-1]:
cur = cur[int(part[1:-1])] if part.startswith("[") else cur[part]
last = parts[-1]
if last.startswith("["):
del cur[int(last[1:-1])]
else:
del cur[last]
def components_sort_key(parts):
"""Orders paths so a list element is deleted after any later sibling.
`del cur[i]` shifts every later index down, so applying findings in document
order deletes the wrong elements from the second onwards: two secrets in one
array left the second one in the file and removed an innocent value instead,
and the file is then mounted into the container - the operator answers "keep
it out" and it goes in. Deepest and highest index first makes each deletion
independent of the ones after it.
"""
return [(1, p[1]) if p[0] == "idx" else (0, p[1]) for p in parts]
def delete_json_components(doc, drop):
"""Every component path, in an order under which no deletion invalidates
another: deepest and highest index first, so a list deletion cannot shift an
element that is still to be removed."""
for parts in sorted(drop, key=components_sort_key, reverse=True):
cur = doc
for kind, item in parts[:-1]:
cur = cur[item]
del cur[parts[-1][1]]
def drop_ini_paths(text, paths):
@@ -311,21 +336,20 @@ def main():
print("%s is not readable as JSON" % args.label, file=sys.stderr)
return 2
else:
pairs = list(walk_ini(text))
pairs = [(p, v, ()) for p, v in walk_ini(text)]
if args.scrub:
wanted = set(load_list(args.scrub))
drop = [path for path, value in pairs
drop = [(path, parts) for path, value, parts in pairs
if finding_id(args.label, path) in wanted]
if not drop:
return 0
if args.format == "json":
doc = json.loads(text)
for path in drop:
delete_json_path(doc, path)
delete_json_components(doc, [parts for _path, parts in drop])
out = json.dumps(doc, indent=2) + "\n"
else:
out = drop_ini_paths(text, drop)
out = drop_ini_paths(text, [p for p, _parts in drop])
with open(args.file, "w") as f:
f.write(out)
return 0
@@ -350,7 +374,7 @@ def main():
return True
return False
for path, value in pairs:
for path, value, _parts in pairs:
fid = finding_id(args.label, path)
if fid in decided or judged_safe("%s\t%s" % (args.label, path)):
continue
+10 -2
View File
@@ -308,10 +308,10 @@ emit_host_exec() {
echo "Harness plugin emitted a host-exec leaf or label containing a tab or newline" >&2
exit 1 ;;
esac
[ -n "$leaf" ] && [ -n "$label" ] || {
if [ -z "$leaf" ] || [ -z "$label" ]; then
echo "Harness plugin emitted a host-exec surface with no leaf or label" >&2
exit 1
}
fi
DEJIMA_HOST_EXEC+=("$ns"$'\t'"$leaf"$'\t'"$path"$'\t'"$label")
}
@@ -495,6 +495,14 @@ _emit_seed_record() {
DEJIMA_SEED+=("$rel"$'\t'"$src")
}
# Called twice by core, and the second call is not a mistake. The first is
# early, with the other two list loads, because the mount guard has to see
# every host path a plugin names before anything is mounted. The second is
# after harness_prepare_config, whose job is to PRODUCE per-run files - a
# hook that makes a file has to run before the list that mounts it.
#
# So this resets rather than appends: the second call rebuilds the list, and a
# plugin that emits the same entries twice gets them once.
harness_seed_load() {
DEJIMA_SEED=()
harness_hook harness_seed
+124 -20
View File
@@ -1661,7 +1661,7 @@ BRIEFING_FRAGMENT_MAX=16384
# is a hard refusal (_briefing_substitute below), not a literal passthrough -
# an unrecognized {{key}} reaching an agent's context verbatim is exactly the
# kind of silent drift this whole function exists to prevent.
_BRIEFING_WHITELIST="pr_command issue_command forge_name forge_ssh_host blocked_branches container_id project_dir workspace_dir session_id resume_command writable_mounts network_allowlist no_confirm_args harness_state_dir"
_BRIEFING_WHITELIST="pr_command issue_command forge_name forge_ssh_host blocked_branches container_id project_dir workspace_dir session_id resume_command writable_mounts network_allowlist no_confirm_args harness_state_dir workspace_posture project_posture"
_briefing_whitelisted() {
_set_contains "$_BRIEFING_WHITELIST" "$1"
@@ -1671,14 +1671,30 @@ _briefing_whitelisted() {
# allowlist is load-bearing for the agent too. Lives here, not in any
# provider directory - see the module-level comment. A quoted heredoc, so
# backticks and $ signs are literal and need no escaping.
_briefing_core_preamble() {
cat <<'BRIEFING_EOF'
You are running inside the dejima Docker container, not directly on
the user's machine, with
{{no_confirm_args}} active (no tool-use confirmations).
What this means:
- The workspace directory is bind-mounted read-write and is the real
# What the session is told about where it is: which of the host it can see,
# what it may hand back, and what survives it.
#
# Two variants rather than one paragraph with caveats, because the ordinary
# one states four things an isolated launch makes false - that the workspace
# is the real project, that ~/shared is shared with every other session
# against it, that the harness keeps state on the host, and that the work is
# recoverable there afterwards. A session acts on these: one that believes
# its transcript outlives the container leaves things for a resume that
# cannot happen.
#
# Continuation lines carry the two spaces the preamble's bullet list uses,
# since this is substituted into one.
_briefing_workspace_posture() {
if [ "${DEJIMA_ISOLATED:-0}" = 1 ]; then
printf '%s' "Nothing of the user's machine is mounted here and this session has no
past: no project checkout, no memory, and no transcript of an earlier
run. Your working directory is the container's own and goes with it, as
does anything you clone or write into it. ~/shared is the single
exception - a real host directory, made fresh for this session, which no
earlier session wrote to and no later one will read. Put there whatever
the user should still have afterwards."
else
printf '%s' "The workspace directory is bind-mounted read-write and is the real
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,
@@ -1687,8 +1703,37 @@ What this means:
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
packages, anything outside the mounted paths, and the container itself go
briefing."
fi
}
# The same split for what the session should expect to FIND. The ordinary
# text is about a mounted checkout holding things that are not code; an
# isolated session has no checkout to be careful with, and the useful
# instruction is the opposite one - ask rather than look.
_briefing_project_posture() {
if [ "${DEJIMA_ISOLATED:-0}" = 1 ]; then
printf '%s' "There is no project directory, so nothing you find in this container
came from the user. If a task needs a file of theirs, say what you need
rather than going looking for it: ~/shared is the only way they can hand
you one."
else
printf '%s' "The project directory is mounted whole, so it can hold things that are not
code: a .env, a database dump, a key someone left in the tree. Say so if
you find one, and do not open, copy or send it anywhere unless the task
actually requires it."
fi
}
_briefing_core_preamble() {
cat <<'BRIEFING_EOF'
You are running inside the dejima Docker container, not directly on
the user's machine, with
{{no_confirm_args}} active (no tool-use confirmations).
What this means:
- {{workspace_posture}}{{writable_mounts}} Everything else is disposable: installed system
packages, anything the mounts do not cover, and the container itself go
away when this run ends.
- A headless browser is installed: playwright, with chromium, importable
from any directory in either module system. If you change something with a
@@ -1696,8 +1741,10 @@ What this means:
and leave the screenshot in ~/shared.
- Outbound network is restricted to an allowlist (Anthropic's API, the npm
registry, {{network_allowlist}}, plus anything
approved via dejima-allow). Requests to anything else will simply
fail to connect. That restriction is not only a limit on your reach: it
approved via dejima-allow). Anything else is refused at once rather than
left to time out, so a connection that HANGS is not this allowlist - it is
something past the sandbox, and from in here the timing is the only thing
that tells the two apart. That restriction is not only a limit on your reach: it
is also what bounds where a prompt injection or a compromised dependency
in this session could send the user's code or fetch its next instruction
from. You cannot audit yourself for that from the inside, so the
@@ -1709,6 +1756,10 @@ What this means:
dejima-allow --container {{container_id}} <domain> # just this session
dejima-allow --project <path> <domain> # this project, always
dejima-allow --global <domain> # every project
Several domains can go on one line, up to twenty, and more than one is
printed back for the operator to confirm before anything is added. So ask
for the whole set a task needs in one command rather than one at a time -
but ask only for what it needs, because the list is read.
Pick deliberately rather than asking for all three: a domain this project
needs is --project, not --global - a global grant widens every other
project's firewall too, which is rarely what one project's dependency
@@ -1720,13 +1771,10 @@ What this means:
payment or production API acts on the world the moment it is called, with
no diff for anyone to review afterwards. Do not ask for one of those to
finish a task - say what you need and let the operator decide.
- The project directory is mounted whole, so it can hold things that are not
code: a .env, a database dump, a key someone left in the tree. Say so if
you find one, and do not open, copy or send it anywhere unless the task
actually requires it.
- {{project_posture}}
- Stay within the task you were given. Since there are no permission
prompts here, be more conservative than usual about anything destructive,
irreversible, or outside the project directory.
irreversible, or outside your working directory.
BRIEFING_EOF
}
@@ -1736,8 +1784,36 @@ BRIEFING_EOF
# workflow can only opt into it (requires_worktree = on), never edit or omit
# it. _briefing_resolve_worktree splices this into the workflow fragment at
# its `<!-- core: worktree -->` marker.
_briefing_core_worktree() {
# Where a branch's working tree must live, and why.
#
# The ordinary rule rests on one fact - that the bind-mounted project
# directory is the only path backed by host storage - and an isolated launch
# has no such path, so every clause of it would be advice about a checkout
# that is not there. What stays true is the question the rule answers: where
# work can be put such that it is not thrown away.
#
# Both worktree workflows stated the first bullet identically in their own
# fragments, which left the same argument in three places and only two of
# them substitutable. It is core's now, so a workflow declaring
# requires_worktree says where the block goes and nothing about what it says.
_briefing_core_worktree_isolated() {
cat <<'BRIEFING_EOF'
- A worktree here is just a directory, and nothing in this container is the
user's checkout: clone what you need into your working directory and work
there. The rule about keeping a worktree under the mounted project does
not apply, because there is no mounted project, and a `git config` in
here reaches no repository of theirs. What leaves this container is what
you push and what you leave in ~/shared.
BRIEFING_EOF
}
_briefing_core_worktree() {
[ "${DEJIMA_ISOLATED:-0}" = 0 ] || { _briefing_core_worktree_isolated; return 0; }
cat <<'BRIEFING_EOF'
- Do new work in a git worktree for the feature branch, not by switching
branches in place in the mounted checkout - that's the user's actual
working copy, and changing its branch out from under them is disruptive
even if you switch it back.
- The worktree MUST live under the mounted project directory itself, e.g.
`git worktree add .worktrees/<branch> -b <branch>` run from the project
root - never `../<branch>` or any other path outside it, including
@@ -1980,6 +2056,10 @@ _briefing_resolve_key() {
printf '%s' "GitHub read-only$hosts"
fi
;;
# Both of these differ by whether this is an isolated launch, and the
# prose for each case lives with the preamble it is substituted into.
workspace_posture) _briefing_workspace_posture ;;
project_posture) _briefing_project_posture ;;
blocked_branches) manifest_get workflow "$workflow" blocked_branches ;;
project_dir) printf '%s' "${WORKSPACE_DIR:-}" ;;
workspace_dir) printf '%s' "${WORKSPACE_DIR:-}" ;;
@@ -2155,6 +2235,18 @@ _briefing_core_tools() {
printf '%s' "$out"
}
# What closes an isolated briefing, in place of the harness's own sentence
# about resuming on the host.
_briefing_core_isolated_close() {
cat <<'BRIEFING_EOF'
This session cannot be resumed. Its transcript is inside the container and
goes when the container does, so anything worth keeping has to leave before
you finish: into ~/shared, and into a pushed branch where this session has a
forge to push to at all. Say what you left there, rather than assuming the
user can read it back out of the session afterwards.
BRIEFING_EOF
}
briefing_compose() {
local forge="$1" workflow="$2" harness="$3"
local caps autonomy workflow_frag harness_frag text
@@ -2215,7 +2307,19 @@ briefing_compose() {
# business resolving.
text+="$(_briefing_core_closing_a)"
text+=$'\n\n'
text+="$harness_frag"
# The harness fragment is about resuming this session on the host, which
# for an isolated launch is the one thing it cannot do: the state that
# resume reads is the state this mode does not mount. Replaced rather
# than dropped, because a session told nothing would reasonably assume
# the usual arrangement and leave work for a resume that never comes.
#
# The fragment is still composed and validated above, so a harness whose
# own briefing is broken is refused in this mode too.
if [ "${DEJIMA_ISOLATED:-0}" = 1 ]; then
text+="$(_briefing_core_isolated_close)"
else
text+="$harness_frag"
fi
_briefing_substitute "$text" "$forge" "$workflow" "$harness"
}
+21
View File
@@ -40,6 +40,27 @@ share_projects_root() { projects_root "$1"; }
# The host directory mounted at ~/shared for a launch against $2.
share_dir_for() { project_dir_for "$1" "$2"; }
# Where an isolated launch's ~/shared lives: a sibling of the per-project
# root, holding one directory per SESSION rather than one per project.
#
# A sibling rather than a slug inside it because an isolated session has no
# project to be keyed by, and the one thing this layout must never do is hand
# a second session the directory a first one used. A name is not enough to
# promise that, which is why the directory below is made rather than
# computed.
isolated_share_root() { printf '%s/isolated' "${1%/}"; }
# Makes this session's share directory under that root and prints its path.
# Creating is the point: `mktemp -d` is what makes "no other session has this
# directory" true for two launches in the same second, where a name built from
# a timestamp would silently hand both of them one directory.
isolated_share_make() {
local root
root="$(isolated_share_root "$1")"
mkdir -p "$root" || return 1
mktemp -d "$root/$(date -u +%Y%m%d-%H%M%S)-XXXXXX"
}
# The slugs belonging to $1: its own, plus one per live git worktree nested
# underneath it. One per line, sorted, no duplicates.
#
+17 -2
View File
@@ -84,9 +84,14 @@ tui_banner() {
#
# Numbered rather than arrow-driven: phone soft keyboards handle digits far
# better than arrow keys, and dejima-attach is the phone's entry point.
# A third argument makes the prompt time out after that many seconds and
# return 1 with no choice, for a caller that redraws and asks again. Kept here
# rather than in the caller's own read so that there is one prompt, one range
# check and one set of exits: a live list whose prompt behaved differently from
# the static one is two front doors wearing the same colours.
TUI_CHOICE=""
tui_pick() {
local count="$1" verb="$2" choice
local count="$1" verb="$2" timeout="${3:-}" choice rc=0
TUI_CHOICE=""
printf '\n %s%s%s %s[1-%d, q to quit]%s: ' \
"$C_MUTED" "$verb" "$C_OFF" "$C_TEXT" "$count" "$C_OFF"
@@ -94,7 +99,17 @@ tui_pick() {
# 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
#
# A timed read separates the two: bash reports a timeout as a status above
# 128 and an unreadable stdin as 1, so a front door with no terminal still
# exits rather than spinning on a prompt nobody can answer.
if [ -n "$timeout" ]; then
read -r -t "$timeout" choice || rc=$?
[ "$rc" -le 128 ] || return 1
[ "$rc" -eq 0 ] || exit 0
else
read -r choice || exit 0
fi
case "$choice" in
q|Q|'') exit 0 ;;
*[!0-9]*) echo "Not a number." >&2; exit 1 ;;
+49 -2
View File
@@ -58,6 +58,8 @@ esac
source "$(dirname "$(readlink -f "$0")")/dejima-provider-lib.sh"
# shellcheck source=dejima-color-lib.sh
source "$(dirname "$(readlink -f "$0")")/dejima-color-lib.sh"
# shellcheck source=dejima-version-lib.sh
source "$(dirname "$(readlink -f "$0")")/dejima-version-lib.sh"
REPO_ROOT="$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)"
@@ -110,6 +112,25 @@ _remote_tip() {
printf '%s' "${out%%$'\t'*}"
}
# The remote's newest release tag, or nothing. Same hardened invocation as
# _remote_tip and for the same reasons; `--tags` with a pattern so a tag that
# is not a release cannot become the answer.
#
# Newest by sort order, which for a date is newest in time - the whole reason
# a release is a date rather than a number somebody increments.
_remote_release() {
local url="$1" out timeout=()
command -v timeout >/dev/null 2>&1 && timeout=(timeout 5)
out="$(env -u GIT_DIR -u GIT_WORK_TREE "${timeout[@]}" \
git -C / \
-c protocol.allow=never \
-c protocol.https.allow=always \
-c protocol.ssh.allow=always \
-c protocol.file.allow=always \
ls-remote --tags --refs "$url" '[0-9]*' 2>/dev/null)" || return 1
printf '%s' "$(printf '%s\n' "$out" | sed -n 's#.*refs/tags/##p' | sort -V | tail -1)"
}
# stderr dropped rather than shown: a config this cannot read is one bin/dejima
# has already refused to start on, and a version check is not the place anyone
# should first hear about it.
@@ -138,11 +159,15 @@ _usable_url "$url" || exit 0
cache="$(_cache_file)"
tip=""
remote_release=""
if [ -f "$cache" ]; then
read -r c_branch c_tip < "$cache" 2>/dev/null || true
# Three fields now; a cache written before the third existed reads as an
# unknown release, which is the same answer a failed lookup gives.
read -r c_branch c_tip c_release < "$cache" 2>/dev/null || true
c_age=$(( $(date +%s) - $(stat -c %Y "$cache" 2>/dev/null || echo 0) ))
if [ "${c_branch:-}" = "$upstream" ] && [ "$c_age" -lt "$CHECK_INTERVAL" ]; then
tip="${c_tip:-}"
case "${c_release:-}" in ""|"-") remote_release="" ;; *) remote_release="$c_release" ;; esac
cached=1
fi
fi
@@ -151,8 +176,14 @@ if [ -z "${cached:-}" ]; then
# A failed lookup is recorded, not just skipped. Without that, a checkout
# with no route to its remote pays the timeout on every single launch.
tip="$(_remote_tip "$url" "$upstream")" || tip=""
# Only when there is something to report: a checkout that is up to date
# exits below without printing, and asking for the tags as well would be
# a second request per launch for an answer nobody reads.
if [ -n "$tip" ] && [ "$tip" != "$head_sha" ]; then
remote_release="$(_remote_release "$url")" || remote_release=""
fi
mkdir -p "$(dirname "$cache")" 2>/dev/null \
&& printf '%s %s\n' "$upstream" "${tip:--}" > "$cache" 2>/dev/null
&& printf '%s %s %s\n' "$upstream" "${tip:--}" "${remote_release:--}" > "$cache" 2>/dev/null
fi
# "-" is a recorded failure; empty is a branch the remote does not have.
@@ -192,6 +223,22 @@ else
printf ' %sdejima: %s/%s has commits this checkout does not.%s\n' \
"$C_WARN" "$_remote_shown" "$_branch_shown" "$C_RESET" >&2
fi
# Which release, when the remote has tags to say so. A commit count answers
# "is there something newer" and nothing else; a release answers "how far
# behind am I, and what would I be getting" - which is the question, and the
# reason the changelog is named right here rather than left to be found.
#
# Both sides printed even when they name the same release: a checkout on
# 2026.10.12 whose remote is also on 2026.10.12 is behind by commits not yet
# released, and saying that is more honest than naming one version.
if [ -n "$remote_release" ]; then
_local_release="$(dejima_version "$REPO_ROOT")"
printf ' %s is on %s%s%s; this checkout is on %s%s%s.\n' \
"$_remote_shown" "$C_KEY" "$(sanitize "$remote_release")" "$C_RESET" \
"$C_KEY" "$_local_release" "$C_RESET" >&2
printf ' What each release changed: %s%s/CHANGELOG.md%s\n' \
"$C_KEY" "$REPO_ROOT" "$C_RESET" >&2
fi
# install.sh is safe to re-run, and a release can change what it links on PATH,
# so pulling is only half the update. Both paths absolute because the operator
# reading this is not necessarily standing in this checkout.
+49
View File
@@ -0,0 +1,49 @@
# Shared library: which release a checkout is on. Sourced by bin/dejima (for
# --version and the startup banner) and bin/dejima-update-check (to say what
# the remote has that this does not). Not on PATH and not executable.
#
# A release is a date - 2026.10.12 - and the git tag on `main` is where it
# lives. Nothing in the tree records it, which is the point: a VERSION file
# says what was released and goes on saying it while a checkout is pulled
# past that release, and the one moment the answer matters is the moment it
# would be wrong. `git describe` cannot drift from history.
#
# A date rather than a version number because the question an operator has is
# how old theirs is, and because MAJOR.MINOR would be a promise about
# compatibility and patch lines that SECURITY.md explicitly does not make:
# the tip of `main` is the only supported version, and a tag is a record of
# what shipped rather than something to stay on.
# The release this checkout is on, as one word.
#
# 2026.10.12 exactly that release
# 2026.10.12+37 thirty-seven commits past it, so on no release
# unreleased a git checkout with no release tag yet
# unknown no git at all, e.g. an unpacked tarball
#
# A second release on one day is 2026.10.12b - see docs/release.md - which
# needs nothing here: the match below accepts it and sort -V orders it after
# the bare date.
#
# Local-only git, so none of the transport configuration dejima-update-check
# has to work around is in the path here.
dejima_version() { # <repo root>
local root="$1" tag n
git -C "$root" rev-parse --git-dir >/dev/null 2>&1 || { printf 'unknown'; return 0; }
# Only tags that look like a release. A tag someone pushed for anything
# else must not become the answer to "which release is this".
tag="$(git -C "$root" describe --tags --abbrev=0 --match '[0-9]*' 2>/dev/null)" || tag=""
[ -n "$tag" ] || { printf 'unreleased'; return 0; }
n="$(git -C "$root" rev-list --count "$tag..HEAD" 2>/dev/null)" || n=0
case "${n:-0}" in
0|"") printf '%s' "$tag" ;;
*) printf '%s+%s' "$tag" "$n" ;;
esac
}
# The release part alone, for comparing two of them. `2026.10.12+37` and
# `2026.10.12` are the same release; what differs is whether the checkout
# stopped there.
dejima_version_release() { # <version>
printf '%s' "${1%%+*}"
}
+15 -9
View File
@@ -13,14 +13,14 @@
# 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.
# No Notification handler, 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
@@ -40,7 +40,13 @@ DEJIMA_HOOK_DIR=/usr/local/lib/dejima/hooks
harness_register_notify() {
local settings="$1" tmp
tmp="$(mktemp "${TMPDIR:-/tmp}/dejima-settings-remote.XXXXXX.json")"
if jq '.hooks.Stop = ((.hooks.Stop // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "'"$DEJIMA_HOOK_DIR"'/dejima-notify.sh done"}]}])' \
# UserPromptSubmit is the complement of Stop, and it is registered for the
# level alone: the hook writes no notification edge for `running`, so this
# adds nothing to the phone. What it adds is the difference between "this
# session has stopped" and "nothing has been heard from this session",
# which a picker cannot tell apart from the Stop event by itself.
if jq '.hooks.Stop = ((.hooks.Stop // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "'"$DEJIMA_HOOK_DIR"'/dejima-notify.sh done"}]}])
| .hooks.UserPromptSubmit = ((.hooks.UserPromptSubmit // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "'"$DEJIMA_HOOK_DIR"'/dejima-notify.sh running"}]}])' \
"$settings" > "$tmp"; then
mv "$tmp" "$settings"
else
+55 -13
View File
@@ -133,18 +133,23 @@ _cline_host_allowlisted() {
#
# $1 is the override file core owns for harnesses that take one on the command
# line. Cline does not - it reads a JSON file at a fixed place inside its data
# directory - so that argument is unused here and the file is written into the
# state directory instead, before it is mounted. Returning nonzero stops the
# directory - so that argument is unused here. $2 is the per-run staging
# directory, which is where this writes instead. Returning nonzero stops the
# launch.
#
# Rewritten every launch on purpose. A session can edit this file (it is inside
# a read-write mount, and Cline rewrites it itself on its own schedule anyway),
# so it is not a control while a session is running - the firewall is what
# bounds where a repointed endpoint could reach. What rewriting buys is that a
# repointing cannot persist into the next session.
# Written per run and seeded in, not written where it will live. A session can
# edit the installed copy - Cline rewrites it on its own schedule anyway - so
# it is not a control while a session is running; the firewall is what bounds
# where a repointed endpoint could reach. What this buys is that a repointing
# cannot persist into the next session, which writing into the mounted state
# directory only approximated by rewriting the file each launch.
harness_prepare_config() {
local state model settings
state="$(_cline_state_dir "$WORKSPACE_DIR")" || {
local model settings
# Checked, not used: this function no longer writes into the state
# directory, but a launch with no state_dir has nowhere to keep Cline's
# state at all and the resume command in the briefing has no path to
# name. Refusing here is where an operator can still act on it.
_cline_state_dir "$WORKSPACE_DIR" >/dev/null || {
echo "harness.cline: state_dir is not set, so there is nowhere to keep" >&2
echo "this project's Cline state. Set it in [harness.cline]." >&2
return 1
@@ -260,11 +265,34 @@ harness_prepare_config() {
return 1
fi
settings="$state/settings"
# Into the per-run staging directory core hands this hook, not into the
# state directory on the host. Two things follow, and the second is why
# --isolated works for this harness at all:
#
# The file is seeded rather than mounted, so entrypoint.sh installs a copy
# the session owns and nothing Cline writes to it reaches the host. That
# is what the comment above this function wanted - a repointing cannot
# persist into the next session - and writing into the mounted state
# directory only approximated it by rewriting the file each launch.
#
# And it no longer needs the state mount to arrive. An isolated launch
# mounts no host state, which used to mean this file was written to a host
# directory keyed on the container-local working directory and then not
# mounted: Cline started with no model and fell back to its own hosted
# endpoint, which the firewall was never asked to allow (#393).
_CLINE_PROVIDERS_FILE=""
settings="${2:-}"
[ -n "$settings" ] || {
echo "harness.cline: core passed no staging directory, so there is" >&2
echo "nowhere to put this launch's provider configuration." >&2
return 1
}
settings="$settings/cline-providers"
mkdir -p "$settings" || return 1
# 0700: the placeholder is not a secret, but this directory is where a real
# one would land if anything ever wrote one here by mistake.
chmod 700 "$state" 2>/dev/null || true
# 0700 on the staging directory, for the reason the state directory had
# it: the placeholder is not a secret, but this is where a real one would
# land if anything ever wrote one here by mistake.
chmod 700 "$settings" 2>/dev/null || true
# Cline DISCARDS a provider entry with no updatedAt and falls back to its
# own hosted endpoint, silently - the run reports provider "cline" and a
@@ -324,6 +352,10 @@ harness_prepare_config() {
fi || { rm -f "$tmp"; return 1; }
chmod 600 "$tmp"
mv "$tmp" "$settings/providers.json"
# Read by harness_seed below, which runs again after this hook for exactly
# this: a hook whose job is to produce a per-run file has to run before
# the list that mounts it.
_CLINE_PROVIDERS_FILE="$settings/providers.json"
}
# The per-project state this harness keeps on the host.
@@ -413,6 +445,16 @@ harness_staged_config() {
# servers, which are commands cline executes: a session that could write the
# host's copy would be choosing what runs there next time.
harness_seed() {
# This launch's provider configuration, which harness_prepare_config
# wrote into the staging directory. Seeded rather than mounted so the
# session owns the copy it edits and nothing it writes reaches the host -
# and so it arrives without the state mount, which an isolated launch
# does not have (#393).
#
# Empty until that hook has run. Core loads this list twice for exactly
# that reason, and the first pass simply has nothing to emit here.
[ -z "${_CLINE_PROVIDERS_FILE:-}" ] \
|| emit_seed_from "$_CLINE_PROVIDERS_FILE" ".cline/data/settings/providers.json"
emit_seed_from "$HOME/.cline/settings.json" ".cline/settings.json"
emit_seed_from "$HOME/.cline/cline_mcp_settings.json" ".cline/cline_mcp_settings.json"
}
+11
View File
@@ -230,6 +230,14 @@ RUN mkdir -p $SANDBOX_HOME/.ssh && \
chown $SANDBOX_USER:$SANDBOX_USER $SANDBOX_HOME/.ssh && \
chmod 700 $SANDBOX_HOME/.ssh
# The working directory an isolated session gets instead of a project mount.
# Outside $SANDBOX_HOME on purpose: bin/dejima still reads this path on the
# HOST for a few things (an origin URL, a self-hosting check), so a path the
# operator might plausibly have - ~/work - would have those read a real
# directory of theirs. Nothing on a host is at /dejima.
RUN mkdir -p /dejima/work && \
chown -R $SANDBOX_USER:$SANDBOX_USER /dejima
# Pre-create the nvim data/state/cache dirs owned by the sandbox user,
# since they're mounted as named volumes in docker-compose.yml: Docker
# seeds a fresh named volume from whatever already exists at the mount
@@ -324,6 +332,9 @@ COPY docker/harness-wrapper.sh /usr/local/bin/dejima-harness-wrapper
# nothing until something puts them on PATH.
COPY docker/git-guard.sh /usr/local/lib/dejima/git-guard.sh
COPY docker/forge-guard.sh /usr/local/lib/dejima/forge-guard.sh
# The policy both of those apply, and that the two hooks beside them apply to
# the same command from the other side. One copy, so a bypass is fixed once.
COPY docker/guard-lib.sh /usr/local/lib/dejima/guard-lib.sh
# The briefing renderer, which every harness needs and no harness owns. Baked
# in rather than reached through the ~/.claude/hooks mount, because that mount
# is Claude Code's configuration: sandbox-context.sh calls this, and so does
+37 -3
View File
@@ -157,7 +157,13 @@ services:
# no write scopes). Used by `gh` and, via `gh auth setup-git`, by git
# itself for github.com over HTTPS - see the firewall/Dockerfile for
# why that's the only path GitHub is reachable through at all.
GH_TOKEN: ${GH_TOKEN:-}
#
# bin/dejima's SANDBOX_ prefixed copy rather than GH_TOKEN itself,
# because the filter container needs that one for the authenticated
# ranges fetch whatever the session is trusted with, and an isolated
# launch is trusted with less. Empty is an ordinary state here: it is
# what a host that configured no such token has always had.
GH_TOKEN: ${SANDBOX_GH_TOKEN-}
# Points git at the gitconfig copy mounted below instead of the
# default ~/.gitconfig - see that mount's comment for why it can't
# just live at ~/.gitconfig directly.
@@ -225,7 +231,16 @@ services:
# fixed /workspace) so Claude Code's project/session-history keying
# (based on cwd) lines up with the host, letting a session started
# here be resumed on the host with `claude --resume`.
- ${WORKSPACE_DIR:?set WORKSPACE_DIR to the project directory to mount}:${WORKSPACE_DIR}
#
# One variable holding the whole spec rather than a source and a target,
# because `--isolated` has to mount no host path here at all and an
# entry in this list cannot be left empty. There it names the
# isolated-work volume below, which bin/dejima gives this run's own name
# and removes afterwards - the same rule as the other four, rather than
# an anonymous volume whose reaping depends on what `compose run --rm`
# does with one. bin/dejima always sets it: ordinarily to the project
# directory at its own path, for the keying reason above.
- ${SANDBOX_WORKSPACE_MOUNT:?set by bin/dejima - the project directory, or a container-local path for --isolated}
# ~/.claude is enumerated, never mounted whole. A single-file bind
# mount is pinned to the dentry at its path, so a host-side rename -
# a sync daemon, or Claude Code's own atomic writes - unlinks it, the
@@ -289,7 +304,10 @@ services:
- nvim-state:${HOME}/.local/state/nvim
- nvim-cache:${HOME}/.cache/nvim
# Required only if SANDBOX_SSH_KEY isn't set - bin/dejima
# itself enforces that at least one of the two is present.
# itself enforces that at least one of the two is present, and blanks
# SSH_AUTH_SOCK when the key is the one chosen, so this mounts nothing
# then. A forwarded agent left here signs with the operator's personal
# identities for anything in the container that names this path.
- ${SSH_AUTH_SOCK:-/dev/null}:/ssh-agent
- ${SSH_KNOWN_HOSTS:-/dev/null}:${HOME}/.ssh/known_hosts:ro
# Dedicated, purpose-built key for headless git push - see README
@@ -316,7 +334,18 @@ services:
volumes:
# Shell history, shared by every harness - it is the container's shell that
# writes it, not the agent.
#
# Named like the three below so bin/dejima can give an `--isolated` launch
# one of its own: this volume is shared by every project, so the default is
# a session reading what the last one typed anywhere on the host.
#
# The default repeats the project name, which is not a typo. An entry with
# no `name:` gets `<compose project>_<key>`, and the project here is
# `dejima` while the key is `dejima-bashhistory` - so anything shorter is a
# DIFFERENT volume, and every ordinary launch would start from an empty
# history with the real one orphaned.
dejima-bashhistory:
name: ${SANDBOX_BASH_HISTORY:-dejima_dejima-bashhistory}
# Named explicitly so bin/dejima can give each project its own set. These
# persist across containers by design (a plugin install should not have to
# happen every launch), which without the split meant one project's session
@@ -326,6 +355,11 @@ volumes:
#
# The defaults reproduce the previous names exactly, so a `docker compose`
# invocation that bypasses bin/dejima behaves as it did.
# The working directory of an `--isolated` launch, which mounts no host
# path there. Referenced only when the workspace mount above names it, so
# an ordinary launch neither uses nor creates it.
isolated-work:
name: ${SANDBOX_ISOLATED_WORK:-dejima_isolated-work}
nvim-data:
name: ${SANDBOX_NVIM_DATA:-dejima_nvim-data}
nvim-state:
+302 -32
View File
@@ -126,6 +126,12 @@ INTERNAL_OK=${EGRESS_INTERNAL_OK:-}
INTERNAL_OK_HINT=${EGRESS_INTERNAL_OK_HINT:-EGRESS_INTERNAL_OK}
FORGE_SSH=${EGRESS_FORGE_SSH:-0}
HOST_ADDR=${EGRESS_HOST_ADDR:-}
# Which TCP ports the host entry opens. Empty - the default - opens none, so
# the address is a known destination that is refused rather than an implicit
# grant over everything the host publishes. `any` restores the unclamped rule,
# explicitly. See the clamp below for why none is the right default.
HOST_PORTS=${EGRESS_HOST_PORTS:-}
HOST_CANDIDATES=${EGRESS_HOST_CANDIDATES:-}
# Self-check probes. example.com is reserved by RFC 2606 for documentation and
# resolves globally, which is what makes it a usable negative: if it does not
# resolve here, the resolver is restricting names rather than simply broken.
@@ -138,6 +144,9 @@ PROBE_AAAA_NAME=${EGRESS_PROBE_AAAA_NAME:-registry.npmjs.org}
PROBE_ADDR4=${EGRESS_PROBE_ADDR4:-192.0.2.1}
FETCH_ATTEMPTS=${EGRESS_FETCH_ATTEMPTS:-5}
FETCH_TIMEOUT=${EGRESS_FETCH_TIMEOUT:-20}
# The ceiling on one wait between attempts, so a Retry-After nobody expected
# cannot turn a boot into an indefinite hang.
FETCH_MAX_BACKOFF=${EGRESS_FETCH_MAX_BACKOFF:-60}
say() { printf '[egress-policy] %s\n' "$*" >&2; }
die() { printf '[egress-policy] FAILED: %s\n' "$*" >&2; exit 1; }
@@ -224,15 +233,23 @@ classify_entry() {
}
record_entries() {
local class=$1 raw=$2 value kind exemption
local class=$1 raw=$2 value kind exemption ok_name
for value in ${raw//,/ }; do
value=${value%.}
[ -n "$value" ] || continue
kind=$(classify_entry "$value")
exemption=strict
case " ${INTERNAL_OK//,/ } " in
*" $value "*) exemption=internal-ok ;;
esac
# Case-folded and dot-stripped on both sides, like build_entries below and
# dejima_class_for/dejima_mark_internal on the launcher's side. Compared
# verbatim, a pair the launcher had accepted was recorded strict here, and
# the boot then refused the name's internal answer while telling the
# operator to configure the exemption they had already configured.
for ok_name in ${INTERNAL_OK//,/ }; do
ok_name=${ok_name%.}
if [ "${ok_name,,}" = "${value,,}" ]; then
exemption=internal-ok
fi
done
if [ "$exemption" = internal-ok ] && [ "$kind" = addr ]; then
# Harmless, but say so: the check it exempts is a check on answers, and
# an address the operator typed is not an answer.
@@ -272,7 +289,13 @@ build_entries() {
local value
for value in ${INTERNAL_OK//,/ }; do
[ -n "$value" ] || continue
listed=$(awk -v v="${value%.}" '$4 == v' "$ENTRIES_FILE")
# Case-folded on both sides, like dejima_class_for and dejima_mark_internal
# on the launcher's side of this. DNS names are case-insensitive and the
# resolver matches them so, and comparing verbatim here refused a launch
# whose configuration was correct - the launcher's own check had already
# passed on the same pair, so the refusal arrived from the wrong layer with
# a message saying the name was permitted by nothing.
listed=$(awk -v v="${value%.}" 'BEGIN { v = tolower(v) } tolower($4) == v' "$ENTRIES_FILE")
[ -n "$listed" ] || missing+=("$value")
done
[ ${#missing[@]} -eq 0 ] \
@@ -343,8 +366,20 @@ host_of_url() {
# through the policy. The session container has not been started at this point -
# the launcher waits for the readiness file - so the window is not one the agent
# can be in.
# The seconds the endpoint asked us to wait, if it asked. Header names are
# case-insensitive, and the value is either a count of seconds or an HTTP date
# - only the numeric form is honoured, because a date needs parsing to compare
# against a clock and a wrong answer there is a sleep nobody chose.
retry_after_from() {
local v
v=$(tr -d '\r' < "$1" 2>/dev/null \
| awk -F': *' 'tolower($1) == "retry-after" { print $2; exit }') || return 1
case "$v" in ''|*[!0-9]*) return 1 ;; esac
printf '%s' "$v"
}
fetch_forge_ranges() {
local out=$1 attempt rc http err
local out=$1 attempt rc http err retry wait limited
# Authenticated when the launcher supplied a token. The endpoint this
# defaults to is rate-limited per source address, unauthenticated, across
# everything on the host - so a host that starts sandboxes in a loop exhausts
@@ -353,15 +388,38 @@ fetch_forge_ranges() {
# the launcher's to choose.
local -a auth=()
[ -n "$FORGE_META_AUTH" ] && auth=(-H "$FORGE_META_AUTH")
# A file:// source is a test fixture and nothing else. test/runtime.bats
# starts twenty-seven container pairs in one run, each of which fetched the
# ranges for itself - which is what trips the endpoint's secondary limit
# (#357) - so the suite fetches once and points every pair at the result.
#
# Refused outside a test container, because the ranges decide which addresses
# the forge class reaches: a launch that could name its own source could name
# its own ranges. SANDBOX_TEST_MODE is set by that suite and by nothing in
# bin/dejima, so there is no launch this is reachable from.
case "$FORGE_META_URL" in
file://*)
[ "${SANDBOX_TEST_MODE:-0}" = 1 ] \
|| die "EGRESS_FORGE_META_URL names a file:// source, which is a test fixture only. The forge's address ranges decide what the forge class reaches, so they are not read from the filesystem in a real launch."
;;
esac
for ((attempt = 1; attempt <= FETCH_ATTEMPTS; attempt++)); do
rc=0
: > "$out.hdr"
http=$(curl --silent --show-error --location \
--max-time "$FETCH_TIMEOUT" \
--write-out '%{http_code}' \
--dump-header "$out.hdr" \
--output "$out.part" \
${auth[@]+"${auth[@]}"} \
"$FORGE_META_URL" 2>"$out.err") || rc=$?
if [ "$rc" -eq 0 ] && [ "$http" = 200 ]; then
# curl reports no status for a scheme that has none, so what counts as a
# good transfer is the scheme's own answer rather than 200 always.
local want_http=200
case "$FORGE_META_URL" in file://*) want_http=000 ;; esac
if [ "$rc" -eq 0 ] && [ "$http" = "$want_http" ]; then
# Whether the body carries ranges is part of whether the fetch worked,
# so it is decided here rather than after the loop. A rate limit is the
# reason: it answers with a message and no ranges, sometimes over a 200,
@@ -393,12 +451,39 @@ fetch_forge_ranges() {
# reason at all - which is the diagnostic this whole path exists to give.
err=$(jq -re '.message' < "$out.part" 2>/dev/null || true)
[ -n "$err" ] || err="response carried no ranges"
say "attempt $attempt/$FETCH_ATTEMPTS to fetch $FORGE_META_URL failed: $err"
say "attempt $attempt/$FETCH_ATTEMPTS to fetch $FORGE_META_URL failed: http $http, $err"
else
err=$(tr '\n' ' ' < "$out.err")
# The body first, because an HTTP failure from this endpoint carries one
# and curl's stderr does not: "API rate limit exceeded" names the cause,
# where "curl exit 0, http 403" names only the symptom. curl's own
# message is what is left when there was no response to carry a body.
err=$(jq -re '.message' < "$out.part" 2>/dev/null || true)
[ -n "$err" ] || err=$(tr '\n' ' ' < "$out.err")
say "attempt $attempt/$FETCH_ATTEMPTS to fetch $FORGE_META_URL failed: curl exit $rc, http ${http:-none}${err:+, }$err"
fi
[ "$attempt" -lt "$FETCH_ATTEMPTS" ] && sleep $((attempt * 2))
# A rate limit is the one failure here that clears by waiting, and the only
# one worth waiting long for. GitHub reports a secondary limit as 403 with
# a Retry-After, and a primary one as 403 or 429 - but it will also answer
# 200 with a message and no ranges, so the body counts as evidence too.
limited=0
case "$http" in 403|429) limited=1 ;; esac
case "${err,,}" in *"rate limit"*|*"abuse"*|*"too many requests"*) limited=1 ;; esac
# attempt*2 spends twenty seconds over five tries, and a secondary limit is
# measured in tens of seconds - so the whole sequence could sit inside one
# window and exhaust itself without ever reaching the other side (#357).
wait=$((attempt * 2))
[ "$limited" -eq 1 ] && wait=$((attempt * 15))
# What the endpoint asked for beats what this guessed, which is the only
# number here that is not an estimate.
retry=$(retry_after_from "$out.hdr") && wait=$retry
[ "$wait" -le "$FETCH_MAX_BACKOFF" ] || wait=$FETCH_MAX_BACKOFF
if [ "$attempt" -lt "$FETCH_ATTEMPTS" ]; then
say "waiting ${wait}s before attempt $((attempt + 1))${retry:+ (Retry-After: ${retry}s)}"
sleep "$wait"
fi
retry=""
done
die "could not fetch the forge address ranges after $FETCH_ATTEMPTS attempts; the reason for each is above. The sandbox cannot start without them - the policy would refuse every forge range, so git, gh and the API would all fail once inside."
}
@@ -435,6 +520,90 @@ emit_forge_clamp() {
printf ' %s daddr @%s goto refuse\n' "$family" "$set"
}
# The host entry is the one destination nobody declared: it is the gateway of
# the default route, derived rather than configured. Docker publishes container
# ports on that address, so permitting it on every port reached the host's sshd,
# anything bound to 0.0.0.0, and every published port of every other container
# on the machine - including the remote-access front door dejima itself ships,
# which is a host shell behind a tmux prefix key (#363).
#
# Nothing in a launch needs it. A host-resident forge, Loki or self-served model
# is reached through its own entry, by name or as a literal address, and either
# way lands in a set of its own. So the default here is no ports at all, and the
# address stays a named destination that is refused rather than one that falls
# through to the end of the chain - the counter says the host was reached for,
# which an operator debugging a clamp needs to see.
#
# TCP only: nothing here has ever permitted UDP to the host, and DNS to it is
# refused above with everyone else's.
emit_host_clamp() {
local set=$1 p list=""
if [ "$HOST_PORTS" = any ]; then
printf ' ip daddr @%s goto permit\n' "$set"
return
fi
# Rebuilt element by element rather than by substituting the separators: the
# value may be comma-separated, space-separated or both, and a substitution
# turns ", " into an empty element that nft rejects.
for p in ${HOST_PORTS//,/ }; do
list="${list:+$list, }$p"
done
if [ -n "$list" ]; then
printf ' ip daddr @%s tcp dport { %s } goto permit\n' "$set" "$list"
fi
printf ' ip daddr @%s goto refuse\n' "$set"
}
# A typo stops the container rather than becoming a rule that permits the wrong
# port or a ruleset nft refuses after the fact, which is the same reasoning as
# classify_entry's: a destination that looks configured and is silently refused
# is the worst of the three.
# The ports of destinations the launcher configured AT the host's own address.
#
# The launcher knows which destinations this launch configured and cannot know
# the host's address; this process works that out from the default route a few
# lines below. So it passes candidates - a literal address with an explicit
# port, each one it is sure of - and the decision is made here, where both
# halves are known. Nothing resolves a name on either side: a candidate whose
# address is not the host's is simply not the host's.
#
# Without this the clamp, which is matched before every wider rule, answers
# `ports none` for a host-resident Loki or self-served model at the gateway
# address - working before only because the open-class permit answered first.
fold_host_candidates() { # <host address>...
local cand addr port a
[ -n "$HOST_CANDIDATES" ] || return 0
# `any` is already wider than anything a candidate could add.
[ "$HOST_PORTS" = any ] && return 0
for cand in $HOST_CANDIDATES; do
addr=${cand%:*}
port=${cand##*:}
case "$port" in
""|*[!0-9]*) die "EGRESS_HOST_CANDIDATES entry '$cand' has no TCP port" ;;
esac
for a in "$@"; do
[ "$addr" = "$a" ] || continue
case ",${HOST_PORTS}," in
*",$port,"*) ;;
*) HOST_PORTS="${HOST_PORTS:+$HOST_PORTS,}$port" ;;
esac
break
done
done
}
validate_host_ports() {
local p
[ "$HOST_PORTS" = any ] && return 0
for p in ${HOST_PORTS//,/ }; do
[[ $p =~ ^[0-9]+(-[0-9]+)?$ ]] \
|| die "EGRESS_HOST_PORTS entry '$p' is not a TCP port or a low-high range (or the whole value 'any')"
local lo=${p%%-*} hi=${p##*-}
{ [ "$lo" -ge 1 ] && [ "$hi" -le 65535 ] && [ "$lo" -le "$hi" ]; } \
|| die "EGRESS_HOST_PORTS entry '$p' is not within 1-65535, low first"
done
}
# Rules are generated into one file and loaded with `nft -f`, which is a single
# transaction: the namespace is never left half-filtered. The table is replaced
# rather than the ruleset flushed - the runtime has its own rules in this
@@ -637,13 +806,28 @@ EOF
cat <<EOF
# The container's own host, as a single address, on the ports it was told
# to open and no others.
#
# Before the open class, not after it, because this is the narrower rule
# and the first match wins. The host's address reaches literal_v4 by
# several ordinary routes - a host-resident LOKI_URL, a self-served
# model's base_url, \`dejima-allow <gateway>\` - and from there the open
# permit below answered for it first, on every port, including sshd and
# the remote-access front door. Configuring one host service opens that
# service, which is what docs/security.md says it does.
EOF
emit_host_clamp host_v4
cat <<EOF
# The open class: names the launcher permitted and addresses it supplied,
# on any port. DNS to them is refused above along with everyone else's.
ip daddr @oname_v4 goto permit
ip daddr @literal_v4 goto permit
EOF
# The container's own host, as a single address.
ip daddr @host_v4 goto permit
cat <<EOF
# Anything else: logged, then refused. The chain policy is drop as a
# backstop, but nothing should ever reach it.
@@ -757,9 +941,21 @@ EOF
fi
}
# The forge credential, removed from a daemon's environment at the point it
# starts rather than somewhere upstream of it. All three of these parse content
# the session's own traffic decides, and all three are reachable by more than
# one call path: `dejima-allow` runs `egress-policy permit` through `docker
# exec`, which gets the filter container's configured environment, and both
# permit_name and rollback_entries restart the resolver. An unset earlier in
# one path says nothing about the others; this says it where it has to hold.
#
# `env -u` rather than an unset, so the scrub is visible on the line that
# starts the daemon and cannot be separated from it by an edit elsewhere.
readonly NO_CREDENTIAL=(env -u GH_TOKEN -u EGRESS_FORGE_META_AUTH)
start_resolver() {
stop_daemon "$RESOLVER_PID" dnsmasq
dnsmasq -C "$RESOLVER_CONF" || die "the resolver did not start"
"${NO_CREDENTIAL[@]}" dnsmasq -C "$RESOLVER_CONF" || die "the resolver did not start"
wait_for_daemon dnsmasq
# Waiting for the process is not waiting for the resolver. dnsmasq
@@ -848,7 +1044,7 @@ start_connection_log() {
stop_daemon "$ULOGD_PID" ulogd
id -u "$LOGGER_USER" >/dev/null 2>&1 \
|| die "the image did not create the $LOGGER_USER account, which the connection logger drops to"
ulogd -d -u "$LOGGER_USER" -c "$ULOGD_CONF" -p "$ULOGD_PID" \
"${NO_CREDENTIAL[@]}" ulogd -d -u "$LOGGER_USER" -c "$ULOGD_CONF" -p "$ULOGD_PID" \
|| die "the connection logger did not start"
wait_for_daemon ulogd
}
@@ -871,7 +1067,7 @@ start_lookup_log() {
# resolver's log - and that is the last thing in this container that should
# be running with privilege. It opens two files and then only reads and
# writes them, so the drop costs it nothing.
nohup gosu "$LOGGER_USER" "$LOOKUP_LOGGER" --source "$RESOLVER_LOG" --output "$LOOKUP_LOG" \
nohup "${NO_CREDENTIAL[@]}" gosu "$LOGGER_USER" "$LOOKUP_LOGGER" --source "$RESOLVER_LOG" --output "$LOOKUP_LOG" \
>/dev/null 2>>"$LOGDIR/lookup-log.err" &
echo $! > "$LOOKUP_PID"
sleep 0.3
@@ -939,8 +1135,31 @@ retry_positive() {
# Ordered so that a failure names its own cause: the positive checks come
# first, because "a blocked destination was refused" means nothing until
# something that should work has been shown to work.
# Which permitted name the resolver checks are about.
#
# Normally the meta URL's host, which is a permitted forge-class name by
# construction. A file:// source has no host at all - that is the suite's
# cached-ranges fixture (#357) - and handing an empty name to check 3 asked the
# resolver to look up "" and failed the boot on it.
#
# The fallback is a permitted forge-class name, because the property those
# checks state is about the POLICY: that the resolver resolves a permitted name
# and files its address in that name's own set. Where the ranges were read from
# is a different question, and check 5's.
#
# sed rather than head: a producer whose pipe closes early takes SIGPIPE, and
# pipefail makes that the pipeline's status - see the note on grep -q above.
verify_subject() {
local host=$1
if [ -n "$host" ]; then
printf '%s' "$host"
return 0
fi
entries_of forge name | sed -n '1p'
}
verify() {
local forge_host=$1 rc answer set_name
local forge_host=$1 rc answer set_name subject
pgrep -x dnsmasq >/dev/null || die "check 1: the resolver is not running"
pgrep -x ulogd >/dev/null || die "check 1: the connection logger is not running"
@@ -954,9 +1173,13 @@ verify() {
[ "$running_as" = "$RESOLVER_USER" ] \
|| die "check 2: the resolver is running as '${running_as:-unknown}', not $RESOLVER_USER, so the rule permitting it to reach an upstream resolver is keyed to the wrong account"
answer=$(retry_positive 3 resolve_one "$forge_host") || true
subject=$(verify_subject "$forge_host")
[ -n "$subject" ] \
|| die "check 3: there is no permitted forge-class name to check the resolver against, and no host in $FORGE_META_URL to fall back on"
answer=$(retry_positive 3 resolve_one "$subject") || true
[ -n "$answer" ] \
|| die "check 3: the permitted name $forge_host did not resolve through the local resolver - name resolution is broken, not restricted.$(why_no_answer "$forge_host")"
|| die "check 3: the permitted name $subject did not resolve through the local resolver - name resolution is broken, not restricted.$(why_no_answer "$subject")"
# The resolver is also what puts a permitted name's addresses into the
# allow-list, and which set it uses is the name's class. Checking membership
@@ -970,13 +1193,25 @@ verify() {
# two cannot look in different sets.
set_name=$(set_for_class forge)
nft get element inet "$TABLE" "$set_name" "{ $answer }" >/dev/null 2>&1 \
|| die "check 4: $forge_host resolved to $answer but the resolver did not add it to $set_name - permitted names would resolve and then be refused"
|| die "check 4: $subject resolved to $answer but the resolver did not add it to $set_name - permitted names would resolve and then be refused"
rc=0
retry_positive 3 curl --silent --show-error --output /dev/null \
--connect-timeout 5 --max-time 20 "$FORGE_META_URL" || rc=$?
[ "$rc" -eq 0 ] \
|| die "check 5: the permitted destination $FORGE_META_URL is not reachable through the policy (curl exit $rc)"
# Skipped for a file:// source, and said out loud. This check asserts that a
# permitted destination is reachable THROUGH the policy, and a local file is
# not one - reaching it would pass while proving nothing. That is a real
# reduction on the runs using the cached ranges, which is why it is a line in
# the log rather than a silent pass.
case "$FORGE_META_URL" in
file://*)
say "check 5 skipped: the ranges came from a file, so reading it again says nothing about what the policy permits"
;;
*)
rc=0
retry_positive 3 curl --silent --show-error --output /dev/null \
--connect-timeout 5 --max-time 20 "$FORGE_META_URL" || rc=$?
[ "$rc" -eq 0 ] \
|| die "check 5: the permitted destination $FORGE_META_URL is not reachable through the policy (curl exit $rc)"
;;
esac
# The same address, on the port the opt-in gates. With the opt-in off it must
# be refused even though the address was reached by name, which is the whole
@@ -1229,7 +1464,12 @@ resolve_one() {
local answers line
# A only: the resolver is configured to answer no AAAA at all, so asking for
# one spends a query to be told nothing.
answers=$(dig +short +time=3 +tries=2 @127.0.0.1 "$1" A 2>/dev/null) || return 0
# Non-zero when dig itself failed, not zero: that is a lookup which did not
# come back - a resolver a moment behind the container is exactly it - and so
# the one case the positive checks' retry exists to absorb. Returning 0 there
# reported success with no address, which retry_positive takes as an answer
# and the caller then dies on (#345).
answers=$(dig +short +time=3 +tries=2 @127.0.0.1 "$1" A 2>/dev/null) || return 1
while IFS= read -r line; do
[[ "$line" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || continue
printf '%s\n' "$line"
@@ -1292,7 +1532,14 @@ cmd_apply() {
discover_upstreams
local host_addrs forge_host
# Discover, then fold the launcher's candidates in, then validate the whole
# list: the candidates' ports become part of what the clamp permits, so they
# are held to the same check rather than trusted for having crossed a
# process boundary.
host_addrs=$(discover_host_addr)
# shellcheck disable=SC2086
fold_host_candidates $host_addrs
validate_host_ports
forge_host=$(host_of_url "$FORGE_META_URL")
build_entries "$forge_host"
@@ -1306,6 +1553,11 @@ cmd_apply() {
local meta="$RUNDIR/forge-meta.json"
fetch_forge_ranges "$meta"
# The one request that needed the credential has been made, so it stops here
# for the rest of this process too. Each daemon start scrubs its own
# environment as well (NO_CREDENTIAL), because `permit` reaches them without
# ever coming through here.
unset FORGE_META_AUTH EGRESS_FORGE_META_AUTH
local ranges forge4
ranges=$(jq -r "$FORGE_META_FILTER" < "$meta" | grep -v '^null$' | grep -v '^$') \
@@ -1325,7 +1577,7 @@ cmd_apply() {
resolver_uid=$(id -u "$RESOLVER_USER")
apply_ruleset "$forge4" "$host_addrs" "$resolver_uid"
say "policy applied: $(printf '%s\n' "$ranges" | wc -l) forge ranges, host $(printf '%s ' $host_addrs), forge ssh=$FORGE_SSH"
say "policy applied: $(printf '%s\n' "$ranges" | wc -l) forge ranges, host $(printf '%s ' $host_addrs) on ports ${HOST_PORTS:-none}, forge ssh=$FORGE_SSH"
write_log_config
start_connection_log
@@ -1412,13 +1664,26 @@ rollback_entries() {
# An address needs no resolution and no restart: it goes straight into the sets
# its class uses. Both sets are interval sets, so a prefix works as well as a
# single address.
# A forge-class address belongs in both sets, and the adds are not one
# transaction: if the second fails, the first has to come back out. Otherwise
# the address the forge class says is HTTPS-only is left in literal_v4, which
# permits every port, while the entries file rolls back - so nothing reports a
# grant that is live for the rest of the session.
#
# Deleting it is unconditional, which can take back an element that was in that
# set from somewhere else. That direction is safe: the refusal already rolled
# the record back, so a narrower live set agrees with what `entries` says.
permit_address() {
local class=$1 value=$2
nft add element inet "$TABLE" literal_v4 "{ $value }" \
|| { mv "$ENTRIES_FILE.prev" "$ENTRIES_FILE"; die "could not add $value to the allow-list"; }
if [ "$class" = forge ]; then
nft add element inet "$TABLE" forge_v4 "{ $value }" \
|| { mv "$ENTRIES_FILE.prev" "$ENTRIES_FILE"; die "could not add $value to the forge class"; }
nft add element inet "$TABLE" forge_v4 "{ $value }" || {
nft delete element inet "$TABLE" literal_v4 "{ $value }" 2>/dev/null \
|| say "WARNING: $value is still in the any-port allow-list and could not be removed; restart the session"
mv "$ENTRIES_FILE.prev" "$ENTRIES_FILE"
die "could not add $value to the forge class"
}
fi
say "$value permitted ($class class, direct)"
}
@@ -1429,7 +1694,7 @@ permit_address() {
# up in the set its class uses. A name that resolved but never reached the
# allow-list would fail later as a mystery, so it is undone here instead.
permit_name() {
local class=$1 name=$2 set_prefix addr addrs ok='' mark
local class=$1 name=$2 set_name addr addrs ok='' mark
write_resolver_config
mark=$(wc -c < "$RESOLVER_LOG")
@@ -1449,13 +1714,18 @@ permit_name() {
die "$name returned no address - the name may not exist, or its upstream may be unreachable. Nothing was changed."
fi
[ "$class" = forge ] && set_prefix=fname || set_prefix=oname
# Through the same helper as the config that fills the set and the check that
# reads it back at boot. This was the fourth place naming a set for a class,
# and the one the helper's own comment claimed did not exist - a live add
# looking in a different set from the one being filled would report a name as
# unreachable while it was permitted, or the reverse.
set_name=$(set_for_class "$class")
for addr in $addrs; do
nft get element inet "$TABLE" "${set_prefix}_v4" "{ $addr }" >/dev/null 2>&1 && ok=yes
nft get element inet "$TABLE" "$set_name" "{ $addr }" >/dev/null 2>&1 && ok=yes
done
if [ -z "$ok" ]; then
rollback_entries
die "$name resolved to [$(printf '%s ' $addrs)] but none of those addresses reached the ${set_prefix} sets; nothing was changed"
die "$name resolved to [$(printf '%s ' $addrs)] but none of those addresses reached $set_name; nothing was changed"
fi
say "$name permitted ($class class, $exemption): $(printf '%s ' $addrs)"
}
+29 -25
View File
@@ -44,7 +44,10 @@ fi
# involved), leaving the tty root-owned. nvim's clipboard fallback needs to
# write to it directly as the sandbox user, so fix the ownership up here,
# before dropping privileges.
if [ -t 1 ]; then
# Both halves read stdin, because that is what `tty` reports on. Testing stdout
# instead meant a container with one attached and the other not produced an
# empty path and a chown that silently did nothing.
if [ -t 0 ]; then
chown "$SANDBOX_USER" "$(tty)" 2>/dev/null || true
fi
@@ -70,6 +73,9 @@ fi
# Mounted at /root (not under $SANDBOX_HOME) so only this root-run block
# can ever read it - copied out, loaded into the agent, and the copy
# deleted, all before dropping to the sandbox user below.
# The forwarded agent is not mounted when this key is configured (bin/dejima
# blanks SSH_AUTH_SOCK), so this is the only agent in the container rather than
# the preferred one of two.
SANDBOX_SSH_KEY_SRC="/root/.ssh-sandbox-key-src"
if [ -s "$SANDBOX_SSH_KEY_SRC" ]; then
install -o "$SANDBOX_USER" -g "$SANDBOX_USER" -m 600 "$SANDBOX_SSH_KEY_SRC" "$SANDBOX_HOME/.ssh/sandbox-key"
@@ -90,31 +96,25 @@ fi
# Linked here rather than COPYed into place at build time because both depend
# on values that are only known per launch: which hosts this forge may be
# pushed to, and which subcommand this workflow blocks.
if [ -n "${DEJIMA_PUSH_HOSTS:-}${DEJIMA_BLOCKED_BRANCHES:-}" ]; then
# /usr/local/bin precedes /usr/bin, so this shadows git without moving it
# and the guard execs /usr/bin/git directly.
ln -sf /usr/local/lib/dejima/git-guard.sh /usr/local/bin/git
fi
# Every CLI the shadow has something to do about: the one whose merge a
# workflow blocks, and every one that writes a body the session authored. More
# than one, because a forge can have a CLI per artifact rather than one with
# subcommands, and because a forge with nothing blocked can still have
# something to disclose.
# The git shadow is installed unconditionally, because DEJIMA_PUSH_HOSTS fails
# CLOSED: empty means no host may be pushed to, which is how a no-forge or
# non-pushing workflow becomes enforced rather than described. Gating the
# shadow on a non-empty value would leave it absent in exactly that case - and
# local-only, the shipped default, is exactly that case.
#
# The first word of each record names the CLI. The real one is already behind
# the symlink this replaces (see install-forge-bins.sh), so the guard has a
# fixed path to exec.
_shadow_clis() {
[ -z "${DEJIMA_MERGE_BLOCK:-}" ] || printf '%s\n' "${DEJIMA_MERGE_BLOCK%% *}"
# "<kind> <cli> <subcommand>...", so the CLI is the second word.
[ -z "${DEJIMA_DISCLOSE:-}" ] || printf '%s\n' "$DEJIMA_DISCLOSE" |
while read -r _ _cli _; do
[ -z "$_cli" ] || printf '%s\n' "$_cli"
done
}
# /usr/local/bin precedes /usr/bin, so this shadows git without moving it and
# the guard execs /usr/bin/git directly.
ln -sf /usr/local/lib/dejima/git-guard.sh /usr/local/bin/git
_shadow_cli_list="$(_shadow_clis | sort -u)"
# Every CLI the shadow has something to do about. Derived by the same function
# hooks/merge-guard.sh uses to decide whether a command is one of these, so the
# set the shadow is linked over and the set the hook checks against cannot
# disagree. The real CLI is already behind the symlink this replaces (see
# install-forge-bins.sh), so the guard has a fixed path to exec.
# shellcheck source=guard-lib.sh
source "${DEJIMA_GUARD_LIB:-/usr/local/lib/dejima/guard-lib.sh}"
_shadow_cli_list="$(dejima_forge_commands | sort -u)"
if [ -n "$_shadow_cli_list" ]; then
while read -r _cli; do
[ -n "$_cli" ] || continue
@@ -315,7 +315,11 @@ if [ -n "${SANDBOX_HARNESS_BRIEFING_ARGS:-}" ] || [ -n "${SANDBOX_HARNESS_BRIEFI
mkdir -p /etc/dejima
# Written and renamed, so a harness starting while this is written reads
# either the whole briefing or no file at all.
if ! /usr/local/lib/dejima/dejima-briefing-render.sh "" "$PWD" \
# As the sandbox user, not as root. It reads the project's own .dejima.md
# and asks the package managers what is installed, with its cwd inside the
# workspace the session can write - and the file it produces is written by
# the redirect out here, which root still owns.
if ! gosu "$SANDBOX_USER" /usr/local/lib/dejima/dejima-briefing-render.sh "" "$PWD" \
> "$DEJIMA_BRIEFING_RENDERED.part"; then
rm -f "$DEJIMA_BRIEFING_RENDERED.part"
echo "entrypoint: could not render the session briefing. Refusing to" >&2
+35 -2
View File
@@ -101,8 +101,28 @@ fi
_join() { local IFS=' '; echo "${*:-}"; }
EGRESS_FORGE_NAMES="$(_join ${DEJIMA_FORGE_CLASS[@]+"${DEJIMA_FORGE_CLASS[@]}"})"
EGRESS_ALLOWED_NAMES="$(_join ${DEJIMA_OPEN_CLASS[@]+"${DEJIMA_OPEN_CLASS[@]}"})"
# The forge class wins wherever the two overlap, applied here rather than
# asserted in a comment. Every route into the open class is normalised, not just
# the approved-domains loop: the built-in list carries
# release-assets.githubusercontent.com, which is under a forge suffix, and a
# harness or backend domain under one would arrive the same way. Left alone, such
# a name gets a resolver line more specific than the suffix entry, feeding the
# open-class set - and the port restriction then rests on the forge's published
# ranges alone, which is one layer where the design has two.
_forge_extra=""
_open_kept=""
for _domain in ${DEJIMA_OPEN_CLASS[@]+"${DEJIMA_OPEN_CLASS[@]}"}; do
if [ "$(dejima_class_for "$_domain")" = forge ]; then
_forge_extra="$_forge_extra $_domain"
else
_open_kept="$_open_kept $_domain"
fi
done
# shellcheck disable=SC2086
EGRESS_FORGE_NAMES="$(_join ${DEJIMA_FORGE_CLASS[@]+"${DEJIMA_FORGE_CLASS[@]}"} $_forge_extra)"
# shellcheck disable=SC2086
EGRESS_ALLOWED_NAMES="$(_join $_open_kept)"
EGRESS_INTERNAL_OK="$(_join ${DEJIMA_INTERNAL_CLASS[@]+"${DEJIMA_INTERNAL_CLASS[@]}"})"
# What the policy should call that list when it tells an operator to add
# something to it. It knows the variable it reads; only dejima knows the
@@ -116,8 +136,17 @@ export EGRESS_FORGE_SSH="${ALLOW_GITHUB_PUSH:-0}"
# was available. Same read-only token `gh` already gets (docker-compose.yml),
# sent to the API it belongs to and nowhere else.
[ -n "${GH_TOKEN:-}" ] && export EGRESS_FORGE_META_AUTH="Authorization: Bearer $GH_TOKEN"
# The raw token has no other use here, and the header is the only thing the
# policy needs it for. Both are a credential anything started below has no
# reason to hold: they parse content the session's own traffic decides.
unset GH_TOKEN
/usr/local/sbin/egress-policy apply
# The header carries the token in another wrapper, so it goes the moment the
# request that needed it is made. egress-policy scrubs its own daemons at the
# line that starts each one; nothing started below this point can hold either
# half, which is how the shipper is covered.
unset EGRESS_FORGE_META_AUTH
# Best-effort visibility on top of the enforcement above, not part of it -
# log-shipper.sh no-ops if LOKI_URL isn't set. It stays root, deliberately:
@@ -134,4 +163,8 @@ export EGRESS_FORGE_SSH="${ALLOW_GITHUB_PUSH:-0}"
# It outlives this script, and reparents to the container's init when this one
# exits - which is where a daemon watching a session belongs. Started from the
# entrypoint, it was a child of the very session process it exists to watch.
# No `env -u` prefix here, unlike the daemons egress-policy starts: drop_caps
# is a shell function, and env can only exec a binary - so a prefix turns this
# line into exit 127, which the `&` hides from set -e. The two unsets above
# already ran, which is what this one relies on instead.
drop_caps -- -c 'exec "$@"' dejima /usr/local/bin/log-shipper.sh &
+7 -5
View File
@@ -58,11 +58,13 @@ DEJIMA_OPEN_CLASS=(
DEJIMA_INTERNAL_CLASS=()
# release-assets.githubusercontent.com is under a forge-class suffix, and the
# forge class wins wherever the two overlap - by design, not by accident. It is
# fetched over HTTPS like everything else under that suffix, so the narrower
# class costs it nothing and the alternative would be a name that quietly
# widened the forge.
# release-assets.githubusercontent.com is under a forge-class suffix, so it ends
# up in the forge class: firewall-up.sh routes every open-class entry through
# dejima_class_for, this list included, rather than only the ones a project
# declared. It is fetched over HTTPS like everything else under that suffix, so
# the narrower class costs it nothing, and the alternative is a name that
# quietly widens the forge - which is what it did until the normalisation
# covered this list too.
# Split on spaces explicitly, not on whatever IFS the caller happens to have,
# so every consumer gets the same list from the same value. A harness declaring
+25 -187
View File
@@ -3,21 +3,22 @@
# one subcommand a workflow blocks is refused, a command aimed at another
# repository is refused, and a body the session authored says so.
#
# The first rule is the one hooks/merge-guard.sh applies - a merge is the
# operator's decision, so the sandbox opens a PR and stops - moved off the
# harness's hook protocol and onto the command. It works for a harness that
# cannot intercept a tool call, and it compares argv rather than
# regex-matching a command string, so quoting, spacing and pipelines stop
# mattering.
# The first two are docker/guard-lib.sh, which hooks/merge-guard.sh applies to
# the same command from the other side. Here they sit on the command itself: it
# works for a harness that cannot intercept a tool call, and it compares argv
# rather than regex-matching a command string, so quoting, spacing and
# pipelines stop mattering.
#
# The second is here for the same reason it is not in the forge CLIs: dejima
# writes two of them and not the third, so a disclosure living in the tools is
# one that depends on which forge an operator picked. This is the one place
# every forge passes through.
# The third is this layer's alone, because a hook is handed a command string
# and cannot rewrite the body an argument points at. It is here rather than in
# the forge CLIs for the same reason the repository rule is: dejima writes two
# of them and not the third, so a disclosure living in the tools is one that
# depends on which forge an operator picked. This is the one place every forge
# passes through.
#
# Fast-fail, not a boundary, exactly as the hook was: what actually decides
# whether a merge is possible is the scope of the token the forge issued. This
# stops an eager agent doing it by accident.
# Fast-fail, not a boundary: what actually decides whether a merge is possible
# is the scope of the token the forge issued. This stops an eager agent doing
# it by accident.
#
# DEJIMA_MERGE_BLOCK the full command prefix to refuse, e.g. "gitea-pr
# merge". Its first word is the command this shadows;
@@ -51,180 +52,12 @@ if [ -z "${DEJIMA:-}" ] ||
exec "$real" "$@"
fi
# The words of a command prefix, minus the CLI name, appear as a run of
# consecutive non-flag arguments. Flags are dropped first, so `gitea-pr
# --verbose merge 7` is still a merge.
#
# Anywhere in that run rather than at the start of it, because a flag's value
# is indistinguishable from a subcommand here: in `gh --repo o/r pr merge 7`
# the first non-flag argument is `o/r`, and requiring the match to begin there
# let the most ordinary way to name a PR from outside its checkout walk
# straight past the block. Knowing which flags take a value would mean knowing
# each CLI, which is the coupling this guard exists to avoid.
#
# The cost is a command that happens to pass the words as consecutive values -
# `--title pr --body merge` - matching. For the merge block that is a loud
# refusal against a merge going through unnoticed; for a disclosure it is a
# line of text on something an agent did write anyway. Both are the direction
# to be wrong in.
plain=()
for arg in "$@"; do
case "$arg" in -*) continue ;; esac
plain+=("$arg")
done
# shellcheck source=guard-lib.sh
source "${DEJIMA_GUARD_LIB:-/usr/local/lib/dejima/guard-lib.sh}"
# argv_has <word>... - true when those words appear consecutively in `plain`.
argv_has() {
local want=("$@") n="${#plain[@]}" m="$#" i=0 k
[ "$m" -gt 0 ] || return 1
while [ "$((i + m))" -le "$n" ]; do
k=0
while [ "$k" -lt "$m" ] && [ "${plain[$((i + k))]}" = "${want[$k]}" ]; do
k=$((k + 1))
done
[ "$k" -lt "$m" ] || return 0
i=$((i + 1))
done
return 1
}
# --- the merge block --------------------------------------------------------
if [ -n "${DEJIMA_MERGE_BLOCK:-}" ]; then
read -ra blocked <<< "$DEJIMA_MERGE_BLOCK"
# The first word names the command; only the rest is a subcommand sequence
# to compare against argv. A block naming a different command than the one
# being run is not this invocation's business.
if [ "${#blocked[@]}" -ge 1 ] && [ "${blocked[0]}" = "$self" ]; then
if [ "${#blocked[@]}" -eq 1 ]; then
# The whole command is blocked, not one of its subcommands.
echo "Blocked by dejima's forge guard: '$self' is not available in the sandbox." >&2
exit 1
fi
if argv_has "${blocked[@]:1}"; then
echo "Blocked by dejima's forge guard: merging is a human decision in the sandbox - open a PR/MR, then the operator merges. Report its URL and stop." >&2
exit 1
fi
fi
fi
# --- the repository the session was launched against ------------------------
#
# A forge whose CLI can be aimed elsewhere declares how (repo_flags,
# repo_env). The gitea CLIs declare neither, because they resolve the repo
# from origin and refuse another host; `gh` declares both, so on that path the
# most ordinary way to name a PR from outside its checkout is also the way to
# file an issue on a repository the session has nothing to do with (#187).
#
# Fast-fail, not a boundary, for the same reason the merge block is not one:
# the session holds the token and could call the API directly. What bounds
# this is the scope of push_token_file.
#
# 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:-}" ] ||
[ -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
}
# Case-insensitively: forges treat an owner and a repository name as
# case-preserving but not case-sensitive, and a refusal over capitalisation
# would be a false one.
same_repo() {
[ -n "${DEJIMA_FORGE_REPO:-}" ] && [ "${1,,}" = "${DEJIMA_FORGE_REPO,,}" ]
}
if [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ]; then
env_value="${!DEJIMA_FORGE_REPO_ENV:-}"
if [ -n "$env_value" ] && ! same_repo "$env_value"; then
refuse_repo "$DEJIMA_FORGE_REPO_ENV" "$env_value"
fi
fi
if [ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ]; then
read -ra repo_flags <<< "$DEJIMA_FORGE_REPO_FLAGS"
args_left=("$@")
while [ "${#args_left[@]}" -gt 0 ]; do
arg="${args_left[0]}"
args_left=("${args_left[@]:1}")
for flag in "${repo_flags[@]}"; do
value=""
case "$arg" in
"$flag")
# The value is the next argument, and its absence is
# the CLI's error to report rather than this one's.
[ "${#args_left[@]}" -gt 0 ] || continue
value="${args_left[0]}"
;;
"$flag"=*) value="${arg#*=}" ;;
*)
# One-dash flags only; a two-dash flag carries its
# value with `=` or as the next argument, both above.
case "$flag" in
-?) ;;
*) 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
same_repo "$value" || refuse_repo "$flag" "$value"
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
if ! dejima_forge_verdict "$self" "$@"; then
echo "Blocked by dejima's forge guard: $DEJIMA_GUARD_REASON" >&2
exit 1
fi
# --- the authorship disclosure ---------------------------------------------
@@ -242,13 +75,18 @@ marker_for() {
esac
}
# The non-flag arguments the records below are compared against. Set here
# rather than relied on from the verdict above, so this section says what it
# depends on.
dejima_forge_plain "$@"
kind=""
while read -r record_kind record_rest; do
[ -n "$record_kind" ] || continue
read -ra words <<< "$record_rest"
[ "${#words[@]}" -ge 1 ] || continue
[ "${words[0]}" = "$self" ] || continue
if argv_has "${words[@]:1}"; then
if dejima_argv_has "${words[@]:1}"; then
kind="$record_kind"
break
fi
+17 -129
View File
@@ -1,20 +1,20 @@
#!/bin/bash
# Shadows git on PATH so `git push` is checked whatever ran it.
#
# The rules are the ones hooks/git-push-guard.sh applies - push only to the
# forge's own hosts, never straight to a blocked branch - moved off the
# harness's hook protocol and onto the command itself. Two things follow from
# that. It works for a harness with no way to intercept a tool call, which is
# most of them. And it reads the decision out of argv rather than out of a
# regex over a reconstructed command string, so `git push`, `git -c k=v
# push` and a push buried in a pipeline are all just argv here.
# The rules are in docker/guard-lib.sh, which hooks/git-push-guard.sh applies
# to the same push from the other side - push only to the forge's own hosts,
# never straight to a blocked branch. Here they sit on the command itself,
# which means two things. It works for a harness with no way to intercept a
# tool call, which is most of them. And it reads the decision out of argv
# rather than out of a regex over a reconstructed command string, so
# `git push`, `git -c k=v push` and a push buried in a pipeline are all just
# argv here.
#
# It is a fast-fail guard, not a boundary, and the distinction is the same one
# docs/security.md draws about the hook it replaces: a session that means to
# get round it can call /usr/bin/git directly, exactly as it could have used a
# tool the hook did not cover. What stops a push reaching somewhere it should
# not is the firewall, and what stops it being accepted is the forge. This
# stops an eager agent doing the obvious thing by accident.
# docs/security.md draws about the hook beside it: a session that means to get
# round it can call /usr/bin/git directly, exactly as it could have used a tool
# the hook did not cover. What stops a push reaching somewhere it should not is
# the firewall, and what stops it being accepted is the forge.
#
# Everything it needs is already in the container's environment, resolved on
# the host from the forge and workflow manifests (see bin/dejima):
@@ -32,125 +32,13 @@ if [ -z "${DEJIMA:-}" ]; then
exec "$REAL_GIT" "$@"
fi
# Find the subcommand, and -C if it is there. git's own global options come
# before the subcommand and several take a value, so the ones that do are
# skipped in pairs rather than assumed to be attached.
repo_dir="$PWD"
subcommand=""
argv=("$@")
# Every `-c key=value` this push carries, kept rather than only skipped. The
# question below is where the push will go, and git answers that from the
# config the push runs under - so resolving it without these would answer for
# a different configuration than the one about to be used. A one-command
# `-c remote.origin.pushurl=...` is exactly that case.
config_overrides=()
i=0
while [ "$i" -lt "${#argv[@]}" ]; do
case "${argv[$i]}" in
-C)
i=$((i + 1))
[ "$i" -lt "${#argv[@]}" ] && repo_dir="${argv[$i]}"
;;
-c)
i=$((i + 1))
[ "$i" -lt "${#argv[@]}" ] \
&& config_overrides+=(-c "${argv[$i]}")
;;
--exec-path|--git-dir|--work-tree|--namespace|--super-prefix)
i=$((i + 1))
;;
-*)
;;
*)
subcommand="${argv[$i]}"
break
;;
esac
i=$((i + 1))
done
DEJIMA_GUARD_GIT="$REAL_GIT"
# shellcheck source=guard-lib.sh
source "${DEJIMA_GUARD_LIB:-/usr/local/lib/dejima/guard-lib.sh}"
[ "$subcommand" = "push" ] || exec "$REAL_GIT" "$@"
# What comes after `push`: the first two non-flag words are the remote and the
# refspec. Flags that take a value are skipped in pairs for the same reason as
# above - `git push --repo origin main` must not read "origin" as the remote.
# --repo names the remote in git's own legacy form and wins over a positional,
# so it is read rather than skipped: reading it as an opaque flag would leave
# `git push --repo=origin main` looking like a push to a remote called "main",
# which resolves to no URL - and passing that through unchecked is a push to
# origin/main that nothing looked at.
remote=""
refspec=""
i=$((i + 1))
while [ "$i" -lt "${#argv[@]}" ]; do
case "${argv[$i]}" in
--repo=*)
remote="${argv[$i]#--repo=}"
;;
--repo)
i=$((i + 1))
[ "$i" -lt "${#argv[@]}" ] && remote="${argv[$i]}"
;;
-o|--push-option|--receive-pack|--exec)
i=$((i + 1))
;;
-*)
;;
*)
if [ -z "$remote" ]; then
remote="${argv[$i]}"
elif [ -z "$refspec" ]; then
refspec="${argv[$i]}"
fi
;;
esac
i=$((i + 1))
done
[ -n "$remote" ] || remote=origin
# --push, because that is the URL the push will use. Without it this reads
# remote.<name>.url while git pushes to remote.<name>.pushurl when one is set,
# so a pushurl naming any other host is checked against the fetch URL and
# allowed. It needs no evasion to reach: `git -c remote.origin.pushurl=...
# push origin branch` sets one for a single command, and .git/config is in the
# read-write project mount, so a session can also just write one down.
remote_url="$("$REAL_GIT" ${config_overrides[@]+"${config_overrides[@]}"} \
-C "$repo_dir" remote get-url --push "$remote" 2>/dev/null)"
# A remote this cannot resolve is refused rather than passed through. It is
# usually a typo, which git would reject in a moment anyway - but it is also
# what a disagreement between this parser and git's looks like, and the
# failure of that disagreement has to be a refusal rather than a push nobody
# checked.
if [ -z "$remote_url" ]; then
echo "Blocked by dejima's git guard: cannot resolve the remote '$remote' to a URL," >&2
echo "so there is nothing to check this push against. Name a remote that exists." >&2
if ! dejima_push_verdict "$PWD" "$@"; then
echo "Blocked by dejima's git guard: $DEJIMA_GUARD_REASON" >&2
exit 1
fi
host="$(printf '%s' "$remote_url" \
| sed -E 's#^[a-zA-Z]+://([^/@]+@)?([^/:]+).*#\2#; s#^[^@/]+@([^:/]+)[:/].*#\1#')"
host_allowed=0
for allowed_host in ${DEJIMA_PUSH_HOSTS:-}; do
[ "$host" = "$allowed_host" ] && { host_allowed=1; break; }
done
if [ "$host_allowed" != 1 ]; then
echo "Blocked by dejima's git guard: this sandbox may only push to ${DEJIMA_FORGE_NAME:-the configured forge}'s allowed host(s) (${DEJIMA_PUSH_HOSTS:-none allowed}), not '$host'." >&2
exit 1
fi
if [ -n "$refspec" ]; then
branch="${refspec##*:}"
else
branch="$("$REAL_GIT" -C "$repo_dir" branch --show-current 2>/dev/null)"
fi
branch="${branch#refs/heads/}"
for blocked_branch in ${DEJIMA_BLOCKED_BRANCHES:-}; do
if [ "$branch" = "$blocked_branch" ]; then
echo "Blocked by dejima's git guard: direct pushes to '$branch' aren't allowed in the sandbox. Push a feature branch and open a PR/MR instead." >&2
exit 1
fi
done
exec "$REAL_GIT" "$@"
+583
View File
@@ -0,0 +1,583 @@
#!/bin/bash
# The push and merge policy, once, for the two layers that apply it.
#
# Each rule used to exist twice: as a PATH shadow over the command
# (docker/git-guard.sh, docker/forge-guard.sh) and as a PreToolUse hook over
# the command string a harness reports (hooks/git-push-guard.sh,
# hooks/merge-guard.sh). The two overlap deliberately - a hook sees
# `/usr/bin/git push`, which walks past the shadow, and a shadow sees a push
# from an editor integration or a script, which the harness never reports - so
# neither can be dropped. What they cannot do is disagree, and they had already
# drifted: `git push -o x origin main` was fine to one and a hard refusal from
# the other, for the same invocation in the same session.
#
# So the decision lives here and both call it. A bypass is then fixed once.
#
# Every function reads argv, never a command string. A hook holds a string, so
# it splits it into segments and words first (dejima_guard_segments) and hands
# the words over - which is also why the hooks inherit the shadows' indifference
# to spacing, quoting and pipelines.
#
# None of this is a boundary, and the distinction is the one docs/security.md
# draws: a session that means to get round these can call /usr/bin/git or the
# forge API directly. What stops a push reaching somewhere it should not is the
# firewall, and what stops it being accepted is the forge's token scope. These
# stop an eager agent doing the obvious thing by accident.
#
# Sourced, so nothing here exits: a refusal is a return of 1 with the reason in
# DEJIMA_GUARD_REASON. The caller supplies its own prefix, because an operator
# reading a refusal needs to know which layer spoke.
# The real git, never the shadow. The shadow passes a non-push through, so
# calling it would work - but it would also mean every question this asks git
# goes through a script that is asking git questions.
DEJIMA_GUARD_GIT="${DEJIMA_GUARD_GIT:-/usr/bin/git}"
DEJIMA_GUARD_REASON=""
# --- splitting a command string into git/forge invocations -------------------
# dejima_guard_segments <command string>
#
# One segment per line. A guarded command can be the second half of a compound
# one, and only a segment whose first word is the command in question is that
# command - `echo push` is not a push.
#
# Word-splitting rather than a full shell parse, so quoting edge cases are not
# handled. That is the same pragmatic tradeoff as the sibling
# deny-dangerous-bash.sh denylist, and the reason every rule below refuses
# rather than guesses when it cannot read what it is looking at.
# The trailing newline is load-bearing: without it `read` returns false on the
# last segment even though it read one, so a command with no separators in it -
# every ordinary push - was never looked at.
dejima_guard_segments() {
printf '%s\n' "$1" | sed 's/&&/\n/g; s/||/\n/g; s/;/\n/g; s/|/\n/g'
}
# --- git push ---------------------------------------------------------------
# Where a push will go and which branches it will write, read out of argv the
# way git reads it. Sets DEJIMA_GUARD_PUSH=1 when the argv is a push at all.
#
# git's own global options come before the subcommand and several take a
# separate value, so those are skipped in pairs rather than assumed attached.
#
# `-c key=value` is kept rather than skipped: git resolves where a push goes
# from the config the push runs under, so asking without these answers for a
# different configuration than the one about to be used. A one-command
# `-c remote.origin.pushurl=...` is exactly that case.
#
# --git-dir and --work-tree are REFUSED rather than skipped or honoured. Both
# used to be consumed and ignored, so `git --git-dir /tmp/x/.git push origin
# main` resolved `origin` in the mounted project, found it allowed, and then
# ran against a repository whose origin points somewhere else. Honouring them
# would mean reimplementing how they combine with -C and with each other;
# refusing is the direction the unresolvable-remote case below already takes,
# and a session with a second repository to push from can cd into it or use -C.
dejima_push_verdict() { # <repo-dir> <git argv...>
local repo_dir="$1"; shift
local argv=("$@") i=0 subcommand="" branch
local -a config_overrides=() refspecs=()
local remote="" repo_flag="" push_all=0 push_mirror=0 repo_override=0
DEJIMA_GUARD_REASON=""
DEJIMA_GUARD_PUSH=0
while [ "$i" -lt "${#argv[@]}" ]; do
case "${argv[$i]}" in
-C)
i=$((i + 1))
[ "$i" -lt "${#argv[@]}" ] && repo_dir="${argv[$i]}"
;;
-c)
i=$((i + 1))
[ "$i" -lt "${#argv[@]}" ] \
&& config_overrides+=(-c "${argv[$i]}")
;;
--git-dir|--work-tree)
repo_override=1
i=$((i + 1))
;;
--git-dir=*|--work-tree=*)
repo_override=1
;;
# `-c` with the value read from the environment instead of argv,
# so it sets a pushurl the same way and has to be carried into the
# resolution below the same way. In the separated spelling it also
# takes the next word, which an unrecognised option would leave to
# be read as the subcommand - and a subcommand that is not `push`
# ends this function having checked nothing.
--config-env)
i=$((i + 1))
[ "$i" -lt "${#argv[@]}" ] \
&& config_overrides+=(--config-env "${argv[$i]}")
;;
--config-env=*)
config_overrides+=("${argv[$i]}")
;;
--exec-path|--namespace|--super-prefix)
i=$((i + 1))
;;
-*)
;;
*)
subcommand="${argv[$i]}"
break
;;
esac
i=$((i + 1))
done
[ "$subcommand" = push ] || return 0
DEJIMA_GUARD_PUSH=1
# Noted while parsing and refused here, so that only a push is refused:
# these say nothing about any other git command, and a guard that refused
# `git --git-dir X status` would be refusing the flag rather than the push.
if [ "$repo_override" = 1 ]; then
DEJIMA_GUARD_REASON="a push carrying --git-dir or --work-tree cannot be checked, because the repository it writes from is not the one this would resolve the remote in. Run it from that repository, or name it with -C."
return 1
fi
# What comes after `push`. --repo names the remote in git's own legacy form
# and is read rather than treated as an opaque flag, because skipping it
# would leave `git push --repo=origin main` looking like a bare push and
# resolve a remote the command never named.
#
# It LOSES to a positional, which is what git documents: "this option is
# equivalent to the <repository> argument. If both are specified, the
# command-line argument takes precedence." So in `git push --repo=origin
# other-remote feature` the remote is other-remote, and checking origin
# instead would approve a host this push never goes to.
#
# Every positional after the remote is a refspec, not just the first.
# `git push origin feature main` checked `feature` alone and let `main`
# through beside it.
i=$((i + 1))
while [ "$i" -lt "${#argv[@]}" ]; do
case "${argv[$i]}" in
--repo=*) repo_flag="${argv[$i]#--repo=}" ;;
--repo)
i=$((i + 1))
[ "$i" -lt "${#argv[@]}" ] && repo_flag="${argv[$i]}"
;;
--all) push_all=1 ;;
--mirror) push_mirror=1 ;;
-o|--push-option|--receive-pack|--exec)
i=$((i + 1))
;;
-*) ;;
*)
if [ -z "$remote" ]; then
remote="${argv[$i]}"
else
refspecs+=("${argv[$i]}")
fi
;;
esac
i=$((i + 1))
done
[ -n "$remote" ] || remote="$repo_flag"
[ -n "$remote" ] || remote=origin
# --push, because that is the URL the push will use. Without it this reads
# remote.<name>.url while git pushes to remote.<name>.pushurl when one is
# set, so a pushurl naming any other host is checked against the fetch URL
# and allowed. It needs no evasion to reach: `git -c
# remote.origin.pushurl=... push origin branch` sets one for a single
# command, and .git/config is in the read-write project mount, so a session
# can also just write one down.
local remote_url host
remote_url="$("$DEJIMA_GUARD_GIT" ${config_overrides[@]+"${config_overrides[@]}"} \
-C "$repo_dir" remote get-url --push "$remote" 2>/dev/null)"
if [ -z "$remote_url" ]; then
# A remote this cannot resolve is refused rather than passed through.
# It is usually a typo, which git would reject in a moment anyway - but
# it is also what a disagreement between this parser and git's looks
# like, and the failure of that disagreement has to be a refusal rather
# than a push nobody checked.
DEJIMA_GUARD_REASON="cannot resolve the remote '$remote' to a URL, so there is nothing to check this push against. Name a remote that exists."
return 1
fi
host="$(printf '%s' "$remote_url" \
| sed -E 's#^[a-zA-Z]+://([^/@]+@)?([^/:]+).*#\2#; s#^[^@/]+@([^:/]+)[:/].*#\1#')"
local allowed_host host_allowed=0
for allowed_host in ${DEJIMA_PUSH_HOSTS:-}; do
[ "$host" = "$allowed_host" ] && { host_allowed=1; break; }
done
if [ "$host_allowed" != 1 ]; then
DEJIMA_GUARD_REASON="this sandbox may only push to ${DEJIMA_FORGE_NAME:-the configured forge}'s allowed host(s) (${DEJIMA_PUSH_HOSTS:-none allowed}), not '$host'."
return 1
fi
# --mirror puts every ref under refs/ on the remote AND deletes the ones
# that are gone locally, so what it does to a protected branch cannot be
# read off the command: a mirror from a checkout that has no `main` deletes
# the remote's. Refused outright rather than resolved, because there is no
# branch list that makes it safe.
if [ "$push_mirror" = 1 ]; then
DEJIMA_GUARD_REASON="--mirror writes every ref and deletes the remote refs this checkout does not have, so it cannot be checked against a branch policy. Push the branch you mean."
return 1
fi
# --all pushes every local branch, so every local branch is what the policy
# has to be applied to. The refspec list is empty here - git refuses --all
# with one - and reading only the current branch is what let `git push
# --all origin` from a feature branch push `main` beside it.
if [ "$push_all" = 1 ]; then
local local_branch
while IFS= read -r local_branch; do
[ -n "$local_branch" ] || continue
if _dejima_branch_blocked "$local_branch"; then
DEJIMA_GUARD_REASON="--all pushes every local branch, and '$local_branch' is one of them. Direct pushes to '$local_branch' aren't allowed in the sandbox. Push a feature branch and open a PR/MR instead."
return 1
fi
done < <("$DEJIMA_GUARD_GIT" -C "$repo_dir" for-each-ref \
--format='%(refname:short)' refs/heads/ 2>/dev/null)
return 0
fi
# Each refspec's destination. `src:dst` names it outright, a bare name is
# both, and `HEAD` or no refspec at all means the current branch - `git
# push origin` and `git push origin HEAD` both push it.
if [ "${#refspecs[@]}" -eq 0 ]; then
refspecs=(HEAD)
fi
local refspec
for refspec in "${refspecs[@]}"; do
# One leading `+` is the force marker and belongs to the refspec rather
# than to the branch, so it comes off before the destination is read -
# `git push origin +main` names main, and the force spelling is the
# destructive one to be wrong about.
refspec="${refspec#+}"
case "$refspec" in
*:*) branch="${refspec##*:}" ;;
HEAD) branch="$("$DEJIMA_GUARD_GIT" -C "$repo_dir" branch --show-current 2>/dev/null)" ;;
*) branch="$refspec" ;;
esac
# A destination that does not start with refs/ is matched against the
# remote's refs by git's rev-parse rules, so `heads/main` and `main`
# both name refs/heads/main. Stripping only the full prefix left
# `feature:heads/main` naming a branch no policy entry matched.
branch="${branch#refs/}"
branch="${branch#heads/}"
if _dejima_branch_blocked "$branch"; then
DEJIMA_GUARD_REASON="direct pushes to '$branch' aren't allowed in the sandbox. Push a feature branch and open a PR/MR instead."
return 1
fi
done
return 0
}
# DEJIMA_BLOCKED_BRANCHES fails OPEN: empty means no branch is protected, which
# is correct for a workflow that genuinely has no branch policy. Unlike
# DEJIMA_PUSH_HOSTS above, which fails CLOSED - empty means no host is allowed,
# so every push is refused, and that is how a no-forge or non-pushing workflow
# becomes enforced rather than merely described.
_dejima_branch_blocked() {
local blocked
[ -n "${1:-}" ] || return 1
for blocked in ${DEJIMA_BLOCKED_BRANCHES:-}; do
[ "$1" = "$blocked" ] && return 0
done
return 1
}
# --- the forge CLI ----------------------------------------------------------
# dejima_argv_has <word>... - true when those words appear consecutively among
# the non-flag arguments of the command being checked. _dejima_plain must be
# set first, by dejima_forge_verdict.
#
# Anywhere in that run rather than at the start of it, because a flag's value
# is indistinguishable from a subcommand here: in `gh --repo o/r pr merge 7`
# the first non-flag argument is `o/r`, and requiring the match to begin there
# let the most ordinary way to name a PR from outside its checkout walk
# straight past the block. Knowing which flags take a value would mean knowing
# each CLI, which is the coupling this guard exists to avoid.
#
# The cost is a command that happens to pass the words as consecutive values -
# `--title pr --body merge` - matching. For the merge block that is a loud
# refusal against a merge going through unnoticed; for a disclosure it is a
# line of text on something an agent did write anyway. Both are the direction
# to be wrong in.
dejima_argv_has() {
local want=("$@") n="${#_dejima_plain[@]}" m="$#" i=0 k
[ "$m" -gt 0 ] || return 1
while [ "$((i + m))" -le "$n" ]; do
k=0
while [ "$k" -lt "$m" ] && [ "${_dejima_plain[$((i + k))]}" = "${want[$k]}" ]; do
k=$((k + 1))
done
[ "$k" -lt "$m" ] || return 0
i=$((i + 1))
done
return 1
}
# dejima_forge_plain <argv...> - fills _dejima_plain with the non-flag
# arguments, which is what every rule below compares against.
dejima_forge_plain() {
local arg
_dejima_plain=()
for arg in "$@"; do
case "$arg" in -*) continue ;; esac
_dejima_plain+=("$arg")
done
}
# dejima_forge_commands - every CLI this policy applies to, one per line: the
# one whose merge a workflow blocks, and every one that writes a body the
# session authored. More than one, because a forge can have a CLI per artifact
# rather than one with subcommands, and because a forge with nothing blocked
# can still have something to disclose.
#
# docker/entrypoint.sh links the shadow over exactly this set, and
# hooks/merge-guard.sh checks a command against it before applying anything -
# without that the hook would apply gh's --repo rule to any command carrying
# that flag, which the shadow cannot do because it only exists for these.
dejima_forge_commands() {
[ -z "${DEJIMA_MERGE_BLOCK:-}" ] || printf '%s\n' "${DEJIMA_MERGE_BLOCK%% *}"
# "<kind> <cli> <subcommand>...", so the CLI is the second word.
[ -z "${DEJIMA_DISCLOSE:-}" ] || printf '%s\n' "$DEJIMA_DISCLOSE" |
while read -r _ _cli _; do
[ -z "$_cli" ] || printf '%s\n' "$_cli"
done
}
# dejima_forge_verdict <command name> <argv...>
#
# The command name is argv[0] as the caller saw it - the shadow's own basename,
# or the first word of a hook's segment. Every policy here is keyed to it,
# because a block naming a different command than the one being run is not this
# invocation's business.
dejima_forge_verdict() {
local self="$1"; shift
DEJIMA_GUARD_REASON=""
dejima_forge_plain "$@"
if [ -n "${DEJIMA_MERGE_BLOCK:-}" ]; then
local -a blocked
read -ra blocked <<< "$DEJIMA_MERGE_BLOCK"
if [ "${#blocked[@]}" -ge 1 ] && [ "${blocked[0]}" = "$self" ]; then
if [ "${#blocked[@]}" -eq 1 ]; then
# The whole command is blocked, not one of its subcommands.
DEJIMA_GUARD_REASON="'$self' is not available in the sandbox."
return 1
fi
if dejima_argv_has "${blocked[@]:1}"; then
DEJIMA_GUARD_REASON="merging is a human decision in the sandbox - open a PR/MR, then the operator merges. Report its URL and stop."
return 1
fi
fi
fi
_dejima_forge_repo_verdict "$@" || return 1
return 0
}
# The repository the session was launched against.
#
# A forge whose CLI can be aimed elsewhere declares how (repo_flags, repo_env,
# repo_paths). The gitea CLIs declare none, because they resolve the repo from
# origin and refuse another host - which leaves nothing here to compare, not
# that they are confined to one repository: origin is session-writable. `gh`
# declares all three, so on that path the
# most ordinary way to name a PR from outside its checkout is also the way to
# file an issue on a repository the session has nothing to do with (#187).
#
# 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.
_dejima_forge_repo_verdict() {
[ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ] || [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ] \
|| [ -n "${DEJIMA_FORGE_REPO_PATHS:-}" ] || return 0
if [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ]; then
local env_value="${!DEJIMA_FORGE_REPO_ENV:-}"
if [ -n "$env_value" ] && ! _dejima_same_repo "$env_value"; then
_dejima_refuse_repo "$DEJIMA_FORGE_REPO_ENV" "$env_value"
return 1
fi
fi
if [ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ]; then
local -a repo_flags args_left
read -ra repo_flags <<< "$DEJIMA_FORGE_REPO_FLAGS"
args_left=("$@")
local arg flag value letter rest
while [ "${#args_left[@]}" -gt 0 ]; do
arg="${args_left[0]}"
args_left=("${args_left[@]:1}")
for flag in "${repo_flags[@]}"; do
value=""
case "$arg" in
"$flag")
# The value is the next argument, and its absence is
# the CLI's error to report rather than this one's.
[ "${#args_left[@]}" -gt 0 ] || continue
value="${args_left[0]}"
;;
"$flag"=*) value="${arg#*=}" ;;
*)
# One-dash flags only; a two-dash flag carries its
# value with `=` or as the next argument, both above.
case "$flag" in
-?) ;;
*) 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
if ! _dejima_same_repo "$value"; then
_dejima_refuse_repo "$flag" "$value"
return 1
fi
done
done
fi
_dejima_forge_path_verdict "$@" || return 1
return 0
}
# 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.
#
# Under that subcommand the rule is inverted: rather than looking for a path
# naming the wrong repository, it requires one naming the right one. `gh api`
# reaches every endpoint the token does, and an endpoint that is not under the
# declared segment - `user/repos`, `/orgs/<org>/repos`, `graphql` - names no
# repository for this to compare, so there was nothing to refuse and nothing
# was (#365). Telling an endpoint apart from a header value or a field value
# would mean knowing which of the CLI's flags take a separate value, which is
# the coupling this guard exists to avoid - so the question asked is whether a
# target it CAN read is present, which needs no such knowledge.
#
# graphql counts as one, because dejima's own github-pr reads review threads
# through it and the API has no other route to them. A mutation does not:
# `gh api graphql -f query='mutation{mergePullRequest(...)}'` is what an agent
# reaches for once `gh pr merge` refuses, and no repository scoping can be read
# out of a GraphQL document. Queries through it can only read, which the
# read-only token could do anyway.
_dejima_forge_path_verdict() {
[ -n "${DEJIMA_FORGE_REPO_PATHS:-}" ] && [ "$#" -gt 0 ] || return 0
local -a repo_paths
read -ra repo_paths <<< "$DEJIMA_FORGE_REPO_PATHS"
local spec segment arg path owner name scopable
for spec in "${repo_paths[@]}"; do
[ "$1" = "${spec%%:*}" ] || continue
segment="${spec#*:}"
scopable=0
for arg in "$@"; do
# A GraphQL document whose operation is a mutation. Matched
# anywhere in the argument, because the document arrives as the
# value of a field flag - `-f query=mutation{...}` - rather than as
# an argument of its own. Matched before the brace test below,
# which a document would otherwise satisfy.
#
# Over EVERY argument, including the dash-prefixed ones, because a
# flag can carry its value attached: `--field=query=mutation{...}`
# and `-Fquery=mutation{...}` put the document inside one word that
# the scoping loop below skips. This check needs no flag table to
# read it, which is what lets it look at all of them - unlike the
# repository scoping, which does.
#
# Both boundaries are anything that cannot continue an
# identifier, rather than whitespace and a brace. GraphQL treats
# commas and comments as ignored tokens, so `mutation,{...}` and
# `mutation#c` followed by the body are valid documents, and a
# document carrying more than one operation puts `}` immediately
# before the second one. Each of those sat just outside a narrower
# boundary. The class excludes `_` on both sides, so a field called
# foo_mutation is not a mutation.
if [[ "$arg" =~ (^|[^A-Za-z0-9_])mutation([^A-Za-z0-9_]|$) ]]; then
DEJIMA_GUARD_REASON="a GraphQL mutation reaches any repository and any write the token allows, and no repository scoping can be read out of it. Use the subcommands, and leave a merge to the operator."
return 1
fi
# A body this cannot read is a body it cannot tell a query from a
# mutation in. Two spellings put one out of reach: a field value
# read from a file, and --input, which takes the whole request body
# from a file or from stdin. Refused under this subcommand only;
# the document is one argument, so passing it inline is the fix.
case "$arg" in
*=@*|--input|--input=*)
DEJIMA_GUARD_REASON="'$1' takes its body from a file or from stdin, so what it sends is not in the command for this to read. Pass the document inline."
return 1
;;
esac
# Everything from here needs to know which argument is the
# endpoint, so the flags go.
case "$arg" in -*) continue ;; esac
[ "$arg" = graphql ] && { scopable=1; continue; }
path="${arg#/}"
# 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. Those two tokens only, and
# only in an endpoint under the segment: any other brace says
# nothing about which repository the call reaches, and a value
# carrying one - `-f name={x}`, `--template '{{.login}}'` - would
# otherwise be enough to assert the whole command is scoped.
case "$path" in
"$segment"/*'{owner}'*|"$segment"/*'{repo}'*) scopable=1; continue ;;
esac
case "$path" in "$segment"/*/*) ;; *) continue ;; esac
path="${path#"$segment"/}"
owner="${path%%/*}"
path="${path#*/}"
name="${path%%/*}"
[ -n "$owner" ] && [ -n "$name" ] || continue
if ! _dejima_same_repo "$owner/$name"; then
_dejima_refuse_repo "the path '$arg'" "$owner/$name"
return 1
fi
scopable=1
done
if [ "$scopable" = 0 ]; then
DEJIMA_GUARD_REASON="'$1' names no endpoint under '$segment/', so there is nothing here saying which repository this reaches. Name the endpoint as '$segment/${DEJIMA_FORGE_REPO:-<owner>/<name>}/...', or use the subcommands."
return 1
fi
done
return 0
}
_dejima_refuse_repo() {
DEJIMA_GUARD_REASON="this session was launched against ${DEJIMA_FORGE_REPO:-no repository}, and $1 names '$2'. Act on the repository the sandbox mounts."
}
# Case-insensitively: forges treat an owner and a repository name as
# case-preserving but not case-sensitive, and a refusal over capitalisation
# would be a false one.
_dejima_same_repo() {
[ -n "${DEJIMA_FORGE_REPO:-}" ] && [ "${1,,}" = "${DEJIMA_FORGE_REPO,,}" ]
}
+18 -4
View File
@@ -464,6 +464,16 @@ prefer credentials that cannot do anything else: a token that raises an
anonymous API rate limit should be a fine-grained token with no permissions
and no repository access, not a general-purpose one.
**A per-run fetch, not a per-container one.** The privileged job starts a
container pair per test, and each filter container asks the forge for its
published address ranges before it brings the policy up. Done once per
container that is a burst, and GitHub answers a burst on `/meta` with a `403`
from its secondary rate limit - which is not the hourly ceiling a token raises,
so a valid credential does not prevent it (#357). The job fetches once into the
directory it already shares with those containers, and the suite points every
filter at the result; a container falls back to fetching for itself when the
file is absent, which is what happens outside CI.
**The cache server.** `act_runner` enables it by default, and it binds on the
runner host specifically so that job containers can reach it. That is an
inbound service exposed to untrusted code. Turn it off where it is not
@@ -523,7 +533,11 @@ can still add a workflow of its own.
daemon, the answer is a host you are willing to lose, not a smaller grant.
- **Before Gitea 1.27, the job token stays permissive.** Branch protection
bounds where it can write; it does not take the token away.
- **None of this is checked by anything.** The rest of dejima's security
properties have tests behind them. A runner's configuration lives on a
machine this repository has no access to, so it is on you to re-check it
after upgrades and rebuilds.
- **Only part of this is checked, and only from inside a job.**
`test/ci-runner.bats` runs on both tiers and asserts what a job can see from
where it stands: which tier it landed on, that the confined one has no docker
socket and the privileged one does, and that the egress rules are in front of
the confined job. Everything outside a job - the runner's own configuration
file, its labels, what else shares the machine - lives somewhere this
repository has no access to, so it is on you to re-check that after upgrades
and rebuilds.
+179 -24
View File
@@ -55,6 +55,19 @@ Every tool takes `--help`, which describes its own arguments and what they do.
That is the fastest answer to "what does this flag mean"; this page is for the
config file behind them.
**A launch flag overrides the file for that launch only, and every `--no-*`
flag only ever narrows.** None of those turns a setting back on: a config that
says `off` is a decision somebody made in a file they can reread, and a flag
that could undo one in passing would make the file's answer the weaker of the
two. `--isolated` is the far end of the same idea, narrowing several at once:
see [Isolated sessions](#isolated-sessions).
The rule is about the `--no-*` family, not about every flag. `--ro`, `--rw`,
`--include`, `--allow-github-push` and a `--forge` naming a forge the config
did not each widen what one launch can reach, which is what they are for - and
each is visible in the command that used it, where a config file's answer is
not.
### `[core]` - what this sandbox runs
The three selections come first because everything below depends on them: the
@@ -137,7 +150,7 @@ required: an identity supplies what it names and no more.
| Key | Default | Purpose |
| --- | --- | --- |
| `ssh_key` | unset | the SSH key this identity pushes with |
| `ssh_key` | unset | the SSH key this identity pushes with. An empty file counts as no key at all rather than a refusal, so pointing this at one is how a launch keeps the setting and forwards your agent instead |
| `user_name` | unset | `user.name` for commits made in the session |
| `user_email` | unset | `user.email` for commits made in the session |
| `token_file` | *(the forge's own default)* | this identity's Gitea/Forgejo API token, overriding `[forge.gitea] token_file`. Read only when `[core] forge = gitea` |
@@ -157,7 +170,7 @@ you selected, the second is push access when GitHub *is* your forge.
| `[forge.gitea] api` | **required** for `forge = gitea` | `https://<gitea host>/api/v1` |
| `[forge.gitea] token_file` | `~/.config/gitea/token` | path to the Gitea/Forgejo API token (the file, not the token itself) |
| `[forge.github] push_token_file` | unset | path to a **write-capable** PAT, used only when `[core] forge = github`: deliberately separate from `[github] token_file`; see "GitHub push" in [docs/security.md](security.md) |
| `[github] token_file` | `~/.config/gh/sandbox-token` | path to a read-only PAT for private GitHub repos / `gh`. Read whichever forge is selected, and it should stay read-only |
| `[github] token_file` | `~/.config/gh/sandbox-token` | path to a read-only PAT for private GitHub repos / `gh`, and it should stay read-only. Read for every session EXCEPT when `[core] forge = github` and `push_token_file` is set: both fill `GH_TOKEN`, the forge's wins, and the launch says so. Which token can push is the half that matters |
### `[mounts]` - extra host paths
@@ -254,8 +267,16 @@ name and its subdomains, with no exact-name form to use instead - so a bare
label would grant its whole suffix, and `org` would ask for every `.org` name.
The same floor applies to every key whose value becomes a permitted
destination: `[core] internal_domains`, a forge's `ssh_host` and `api`, a
backend's `base_url`, and `[logging] loki_url`. A single-label value is refused
when the entry is declared and again at launch, naming the key it came from.
backend's `base_url`, and `[logging] loki_url`. A value that is not a host name of at least two labels is
refused when the entry is declared and again at launch, naming the key it came
from - which includes a value that reached the launcher through a URL and lost
its host on the way, such as a `loki_url` carrying userinfo.
An approval granted before this rule existed is still read rather than called
corrupt, so an upgrade does not present as tampering; the launch stops instead,
naming the entry. There is no `remove` verb, so clearing one means editing the
store by hand - `~/.config/dejima/allowed-domains` for a global approval, or
`~/.config/dejima/projects/<slug>/allowed-domains` for a project's.
**Declaring grants nothing.** The project directory is bind-mounted
read-write, so a session could write these files; they are read only to
@@ -269,6 +290,7 @@ 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-allow --container <id> dl.example.com files.example.org # several
dejima-dep add --project ~/myproject cargo:cargo-nextest
dejima-dep add --global cargo:cargo-nextest
```
@@ -276,8 +298,22 @@ dejima-dep add --global cargo:cargo-nextest
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
`dejima-dep add --project` appends to `.dejima/dependencies`.
`dejima-allow` takes up to twenty domains at once, which is what a toolchain
fetching from several hosts actually needs. One goes through as a single
decision you already made by typing it; more than one is printed back to you
one per line and confirmed before anything is added, defaulting to no. The cap
and the confirmation are the same point: a list is where an unwanted name rides
along unread, the agent that suggested the command is what might have put it
there, and neither mechanism helps if the list is too long to finish reading.
Every name is validated before any of them is shown, so what you are confirming
is what will be added and no entry can carry an escape sequence that rewrites
the lines above it. Confirming needs a terminal, so a script does them one at a
time.
`dejima-allow --project` and `dejima-dep add --project` 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.
@@ -796,8 +832,15 @@ 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 it means
a live forwarded agent is no longer required at all.
[security notes](security.md) for the full mount rationale).
Setting it means a forwarded agent is not merely unnecessary but **not
mounted**: your own `SSH_AUTH_SOCK` is not passed to the container at all, so
nothing in a session can sign with your personal identities by naming the
socket directly. That is the point of choosing a dedicated key, and it is worth
knowing before you choose one - a session that also needed the forwarded agent
for something else (a second remote, a private submodule over ssh) no longer
has it. Leave this unset to keep the agent.
## Using a different editor/application
@@ -947,11 +990,18 @@ 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).
Suspending is not pausing, and the difference is worth knowing before you reach
for it. What stops is the host side: `dejima` itself, the container run, the
credential proxy and the conversation shipper, which share one process group so
the kernel stops and continues them together. The container is not in that
group. It keeps running under the container runtime, with its mounts, its
firewall grants and its forge token, and keeps making requests while the
terminal looks stopped - see
[what dejima does not sandbox](security.md#what-dejima-does-not-sandbox).
Suspending is not detaching either: the job belongs to the terminal you started
it from. For a session you want to pick up from somewhere else, run dejima
inside tmux and use [`dejima-attach`](remote-access.md).
## Shared scratch space
@@ -1019,6 +1069,100 @@ 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.
## Isolated sessions
`dejima --isolated` runs a session with nothing before it and nothing after it.
No project is mounted, no memory or transcript crosses either boundary, and the
one thing that outlives it is a `~/shared` of its own.
```sh
dejima --isolated # from anywhere - the directory you are in is not used
```
Naming a directory is **refused**, not ignored: `dejima --isolated ~/proj`
stops rather than starting a session that looks like it is working on a
project it cannot see. Harness arguments after `--` still pass through.
| | ordinary launch | `--isolated` |
|---|---|---|
| working directory | the project, read-write at its own path | `/dejima/work`, container-local |
| `~/shared` | this project's, shared by every session against it | made fresh, never reused |
| memory and transcripts | `~/.claude/projects/<slug>`, mounted | none |
| shell history, nvim state | volumes that outlive the container | this run's own, removed with it |
| domains, dependencies, tools | the global set plus this project's | the global set only |
| `[mounts]` lists | applied | dropped, and named at launch |
| forge, workflow | as configured | `none` and `local-only`, unless named |
| forge token, `[github] token_file` | both cross | neither, unless a forge is named |
| forwarded ssh agent | mounted when the workflow pushes | not mounted, unless you name a pushing workflow and have no key |
| `[identity.<name>] ssh_key` | mounted | mounted |
| conversation log, epitaph | as configured | off |
Every shipped harness supports it. A harness whose model configuration has to
be a file inside its own state directory gets it from a staging directory
that goes away with the launch, rather than from the state mount this mode
does not have.
Your own `~/.claude` hooks, skills, commands, agents and output styles are
still mounted read-only, as is `[mounts] nvim_config`. Those are yours, no
session can write them, and a session without them is a session without the
tools you built for it.
**The directory you launch from decides nothing.** An approval store is found
by walking up from the launch directory, so a home directory that was once
approved as a project would otherwise hand an anonymous session that project's
allowed domains and its dependency layer - which is the whole reason to be able
to run this from a fresh terminal in `$HOME` without thinking about it.
**There is no config key, in any spelling.** A mode this strong is a decision
to make in the command, where it is visible, rather than a line in a file that
every later launch inherits. For the same reason `--ro` and `--rw` are refused
rather than dropped: a path typed into this command cannot be quietly ignored,
where a `[mounts]` list written months ago can be - with a line saying which.
**What it gives up.** The conversation log and the epitaph both read a
transcript on the host, and this mode mounts none, so both are off and the
launch says so rather than leaving you to find an empty record later. The
exit-time audit is skipped for the same reason: its subject is the project's
own files. If you want either back in this mode, that is
[#388](https://gitbox.branderson.io/dejima/dejima/issues/388) - both are your
record rather than the agent's, and neither ever reaches a future session.
**No forge, unless you name one on the same command.** `--isolated` sets
`[core] forge` to `none` and `[core] workflow` to `local-only` for that launch,
and withholds `[github] token_file` with them. Naming either is how you ask for
a session that clones and opens a pull request, which is a real use of this
mode:
```sh
dejima --isolated # no forge, pushes nothing
dejima --isolated --forge gitea --workflow worktree-pr # clones, pushes, opens a PR
```
Order does not matter: `--forge gitea --isolated` means the same thing, and
naming a forge brings the credentials back with it.
Two rows surprise people. `[github] token_file` is withheld even though it is
not a forge setting. And an `[identity.<name>] ssh_key` is mounted even though
the forwarded agent is not, so a session with no forge still holds a key that
can reach a host over ssh - withholding that key too, for any launch whose
workflow never pushes, is
[#392](https://gitbox.branderson.io/dejima/dejima/issues/392).
The agent row has a condition worth reading twice: it is withdrawn when the
workflow does not push **or** a usable key is configured. So the second
command above - a pushing workflow, on a host with no `ssh_key` - does forward
your agent into an isolated session.
The table is what this mode changes, not everything that crosses. `LOKI_URL`
still reaches the session, and the model credential follows the rules in
[docs/security.md](security.md#the-model-api-credential-never-enters-the-container)
whatever the mode.
**Why each of those is the way it is**, and why this is a narrower blast
radius rather than a stronger sandbox, is in
[docs/security.md](security.md#a-session-with-no-project-mounted). Worth
reading before relying on this mode.
## Reading what a session left behind
When a session compacts, the summary it writes is the only durable account of
@@ -1140,22 +1284,33 @@ without the reason it is normally off (see
[docs/security.md](security.md#github-push-blocked-by-default-optional-opt-in)),
so the reason is supplied where it will be read.
`git-push-guard.sh` (`PreToolUse`/`Bash`, also `DEJIMA`-gated) blocks
any `git push` whose resolved remote isn't the Gitea host, and any push to
`main`/`master` even there. Like the other hooks, this is a fast-fail guard
against an overeager agent, not a hard security boundary - a session has full
shell access and could in principle edit `.git/config` to route around it.
`git-push-guard.sh` (`PreToolUse`/`Bash`, also `DEJIMA`-gated) blocks any `git
push` whose resolved remote is not one of the configured forge's hosts, and any
push straight to a branch the workflow protects. Neither list is named here:
both are resolved host-side from the selected forge and workflow, so this is
the same rule whichever forge is in use. Like the other hooks, this is a
fast-fail guard against an overeager agent, not a hard security boundary - a
session has full shell access and could in principle edit `.git/config` to
route around it.
GitHub push is blocked by default too (see
[GitHub push](security.md#github-push-blocked-by-default-optional-opt-in) in
docs/security.md) unless `--allow-github-push` is given. For a real guarantee
against pushes to main/master, use branch protection on the remote itself.
`merge-guard.sh` (`PreToolUse`/`Bash`, `DEJIMA`-gated) blocks
`gitea-pr merge` inside the sandbox. It's the API-side counterpart to the rule
above: a merge puts a branch on `main` without ever performing a push, so
`git-push-guard.sh` never sees it. A hook is handed a command rather than a
conversation, so it can't tell an instructed merge from an unprompted one -
which is why it's scoped to the sandbox rather than applied everywhere. On the
Both this hook and the PATH shadow beside it read the decision out of
`docker/guard-lib.sh`, which is the only copy of the rules. A push that the
library cannot read is refused rather than passed through: an unresolvable
remote, `--mirror`, and `--git-dir` or `--work-tree`, each of which names a
repository other than the one the remote would be resolved in.
`merge-guard.sh` (`PreToolUse`/`Bash`, `DEJIMA`-gated) applies the configured
forge's policy inside the sandbox: the blocked merge command, and a command
aimed at a repository the session was not launched against. It's the API-side
counterpart to the rule above: a merge puts a branch on `main` without ever
performing a push, so `git-push-guard.sh` never sees it. A hook is handed a
command rather than a conversation, so it can't tell an instructed merge from
an unprompted one - which is why it's scoped to the sandbox rather than
applied everywhere. On the
host it does nothing, and you can still ask an agent to merge. The same
fast-fail caveat applies with more force than usual here: the session has the
Gitea token in its environment and could call the merge endpoint with `curl`
+44 -35
View File
@@ -25,9 +25,10 @@ run them.
| `docker/docker-compose.yml` | Wires up mounts/env; invoked via `bin/dejima`, not directly |
| `hooks/sandbox-context.sh` | `SessionStart` hook: injects sandbox context/workflow reminders, and any `.dejima.md` the project ships |
| `.dejima.md` | Project-level agent instructions, harness-agnostic: `CLAUDE.md`'s role under a neutral name. dejima loads a project's into the session context; this one is dejima's own |
| `hooks/git-push-guard.sh` | `PreToolUse` hook: blocks pushes outside the allowed host(s)/branches |
| `hooks/git-push-guard.sh` | `PreToolUse` hook: blocks pushes outside the allowed host(s)/branches. Turns the reported command into argv and asks `docker/guard-lib.sh` |
| `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/merge-guard.sh` | `PreToolUse` hook: applies the forge policy - the blocked merge command (`DEJIMA_MERGE_BLOCK`), the agent-written disclosure, and a command aimed at another repository - to a reported command |
| `docker/guard-lib.sh` | The push and merge policy itself, read out of argv. The two hooks above and the two PATH shadows all call it, so a bypass is fixed once rather than twice |
| `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 |
@@ -49,8 +50,8 @@ run them.
| `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 |
| `bin/harness/<name>.sh` | One per supported harness: what dejima does for that harness that no manifest value could state. Trusted computing base, sourced by `bin/dejima` |
| `bin/dejima-color-lib.sh` | The colour and terminal-detection helpers the CLIs share. Not on PATH |
| `bin/dejima-slug-lib.sh` | The `~/.claude/projects` slug scheme and which slugs belong to a workspace; sourced by `bin/dejima` (what to mount) and `bin/dejima-epitaph` (whose transcripts to keep), so the two cannot disagree. Not on PATH |
| `bin/dejima-version-lib.sh` | Which release a checkout is on, from `git describe` rather than from any file in the tree - so it cannot claim a release a checkout has been pulled past. Sourced by `bin/dejima` (`--version`, the banner) and `bin/dejima-update-check` (what the remote has that this does not). Not on PATH |
| `bin/dejima-ttyd` | Launches `ttyd` on loopback in front of `dejima-attach` |
| `systemd/*.service` | User units for `dejima-ttyd` and `dejima-notify-watch`; ship disabled, installed by hand |
| `docs/providers.md` | The manifest contract for the forge/workflow/harness axes, key by key, with the shipped manifests as worked examples |
@@ -58,6 +59,7 @@ run them.
| `docs/threat-model.md` | What is assumed hostile, and where each layer's compromise stops. Carries the blast-radius table `docs/security.md` also shows; a static test keeps the two identical |
| `docs/research-review.md` | When to check dejima's reasoning against the field's, the standing questions that are the agenda, and the queue of bypasses not yet run against this implementation |
| `docs/nginx-dejima.conf.example` | Reverse-proxy template for the web front door; deliberately not loadable as written |
| `CHANGELOG.md` | What each release changed, for one question: should I update. A release is a date and the tag of that name on `main` is the only record of it - see `docs/release.md` §6 |
`bin/dejima` is the entry point: it resolves the project path,
checks for a live `ssh-agent`, reads `GITEA_TOKEN`/`GH_TOKEN` from the token
@@ -106,27 +108,29 @@ notification path carries no sandbox-controlled byte, and that no marker
name outside `waiting`/`done` is ever acted on. `audit.bats` covers
`bin/dejima-audit`'s own diffing.
`config.bats`, `providers.bats` and `proxy.bats` round out the suites that
need no Docker either: `config.bats` covers `~/.config/dejima/config`
parsing and `install.sh`'s legacy-variable warnings (stubbing `docker` itself,
since the fast CI job's container deliberately has no daemon), `providers.bats`
covers the forge/workflow/harness manifest contract documented in
[docs/providers.md](providers.md), and `proxy.bats` covers the credential
proxy's own request handling. `conversation.bats` runs
`bin/dejima-conversation-ship` against a real transcript and a real HTTP
endpoint standing in for Loki, and asserts on what arrives: that a session's
lines ship, that a torn line waits until it is whole, that a sibling project
whose directory name merely extends this one is not swept up with it, and
that neither the in-memory batch nor the unread tail is lost to the kill that
ends a session.
`config.bats`, `providers.bats`, `proxy.bats`, `launch.bats` and `share.bats`
round out the suites that need no Docker either: `config.bats` covers
`~/.config/dejima/config` parsing and `install.sh`'s legacy-variable warnings
(stubbing `docker` itself, since the fast CI job's container deliberately has
no daemon), `providers.bats` covers the forge/workflow/harness manifest
contract documented in [docs/providers.md](providers.md), `proxy.bats` covers
the credential proxy's own request handling, `launch.bats` the command line a
launch builds, and `share.bats` the shared scratch directory's own layout.
`conversation.bats` runs `bin/dejima-conversation-ship` against a real
transcript and a real HTTP endpoint standing in for Loki, and asserts on what
arrives: that a session's lines ship, that a torn line waits until it is whole,
that a sibling project whose directory name merely extends this one is not
swept up with it, and that neither the in-memory batch nor the unread tail is
lost to the kill that ends a session.
One assertion is worth calling out because it can only run for real on a
Docker host: `runtime.bats` proves that a service bound to the host's
loopback is unreachable from inside a container while one bound to the
bridge gateway *is* reachable. The second half is a positive control - it is
what stops the first from passing in an environment where the container
reaches nothing at all. That pair is the ttyd front door's whole containment
argument, so run it before trusting a front door you just set up.
Docker host: `runtime.bats` proves that a service on the host is unreachable
from inside a container - bound to loopback, and bound to the bridge gateway
on a port the policy was not told to open. A second container, given that one
port, reaches it; that is the positive control, and it is what stops the
others from passing in an environment where the container reaches nothing at
all. That set is the ttyd front door's whole containment argument, so run it
before trusting a front door you just set up.
Every mutation in `runtime.bats` (firewall rules, address-set entries) happens
inside the test container's own network namespace, same as any other
@@ -178,10 +182,12 @@ 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`,
`conversation.bats`, `project.bats`, `providers.bats`, `proxy.bats`,
`remote.bats` and `static.bats`, runs anywhere a shell does, including inside
a dejima sandbox itself, which has no Docker daemon by design.
`ci-runner.bats` needs something rarer - a real CI runner of either tier - and
skips itself anywhere else. Everything in between runs anywhere a shell does,
including inside a dejima sandbox itself, which has no Docker daemon by design.
`test/run-confined.sh` discovers that set rather than naming it, so a suite
added under `test/` is in the lane the day it lands - which is also why no list
of them is kept here.
`.gitea/workflows/tests.yml` also runs `shellcheck` over every shell script in
the repo, as its own job. `test/shellcheck.sh` holds the file list, discovered
@@ -199,9 +205,10 @@ obscurely partway through; the file says why each is needed.
## Adding a new harness
`[core] harness` picks a directory under `harnesses/`, and that harness's
manifest declares eleven keys (see [providers.md](providers.md)). The
inventory below is everything dejima does that is shaped by *which* harness is
selected: which parts are the manifest's, which are the plugin's, and
manifest declares what dejima needs to know about it - every key is in
[providers.md](providers.md). The inventory below is everything dejima does
that is shaped by *which* harness is selected: which parts are the manifest's,
which are the plugin's, and
which are still written against Claude Code by name. Taken from a pass over
the tree, so each row names the file rather than describing a sketch - and a
row that says "in the plugin" is one a second harness supplies rather than one
@@ -245,12 +252,14 @@ R2 was a hard gate until the guards stopped needing it. Two of the three it
hosted - the push guard and the merge guard - are now PATH shadows over `git`
and the forge CLI (`docker/git-guard.sh`, `docker/forge-guard.sh`), which hold
whatever the harness can or cannot intercept, and read argv rather than
regex-matching a reconstructed command string. That left one guard depending
on interception, and it is the one this repository already describes as "a
fast-fail guard against an overeager agent doing something obviously
destructive, not a boundary against one that's actively trying to get around
them" (docs/security.md). A gate that costs a harness its support should be
guarding more than that.
regex-matching a reconstructed command string. The hooks beside them read argv
too: they split the reported command into words and ask the same library
(`docker/guard-lib.sh`), so the overlap cannot become a disagreement. That
left one guard depending on interception, and it is the one this repository
already describes as "a fast-fail guard against an overeager agent doing
something obviously destructive, not a boundary against one that's actively
trying to get around them" (docs/security.md). A gate that costs a harness its
support should be guarding more than that.
Declaring one absent is a real decision with a visible cost, not a
formality. It is there so a harness can be supported honestly at a smaller
+31 -12
View File
@@ -127,8 +127,8 @@ operator can set, resolved the same way as any other config key
| `capabilities` | `?list:identifier` | (empty) | What this forge can do - `pr`, `issues`, etc. A workflow's own `requires` is checked against this set at launch; a workflow asking for a capability this forge lacks is refused. |
| `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 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. |
| `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 reported command for the harnesses that can, through the same library, so the two cannot disagree. 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 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. **Enforced only for a CLI that also appears in `merge_block` or `disclose`:** those two are what name the commands the shadow is linked over and the hook checks against, so a forge declaring this and neither of them would get the rule applied to nothing. |
| `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. |
@@ -234,10 +234,11 @@ A workflow's `briefing.md` is plain Markdown plus two marker forms that
| `api_domains` | `?list:hostname` | (empty) | Hostnames this harness's own machinery must reach - its update host, its telemetry endpoint. They join the built-in part of the permitted set rather than the operator's, and are not exempted from the check that refuses an answer pointing inside a private network - a harness's endpoints are public names, and exempting a public name is what turns one influenced answer into egress to an internal host. |
| `api_styles` | `?list:identifier` | (empty) | Which backend `api_style`s this harness has a client for. `claude-code` speaks `anthropic`; `cline` speaks both, which is what makes it the backend-agnostic one. Empty means unchecked. |
| `guards` | `?list:identifier` | (empty) | Which of dejima's guard rails this harness can host: `bash-deny`, `push-guard`, `merge-guard`, `notify`. A name outside that set is refused rather than treated as an unknown guard, since a typo would otherwise turn a guard off silently. The ones a harness does not declare are named at launch - see "What a harness must be able to do" in [development.md](development.md). |
| `assumed_backend` | `?provider:backend` | (empty) | Which backend this harness talked to before `[harness.<name>] backend` existed. Read only when that key is unset, and it writes into the operator's config rather than being consulted at launch - the one key here that does. |
| `briefing_args` | `?args` | (empty) | The flag that carries the briefing to a harness taking it on the command line - for Claude Code, `--append-system-prompt`. `dejima-harness-wrapper` reads the rendered briefing and passes it as a single value, so every invocation carries it and not only the entrypoint's. Set, the session-start hook stops carrying the briefing and reduces to the session id, which is the one thing that does not exist until the harness starts. |
| `briefing_path` | `?homepath` | (empty) | Where this harness reads its briefing from, relative to `$HOME`, for a harness that has no session-start hook to be handed it through. Set, `entrypoint.sh` renders the briefing into that path root-owned and read-only before the session starts, and refuses to start it at all if that fails. Empty for a harness that takes the briefing on the command line (`briefing_args`, which is how Claude Code gets it) or that can be handed it through a session-start hook. |
| `no_confirm_args` | `?template` | (empty) | The flags that make the harness run without confirming each tool call. Required in practice: dejima refuses to launch a harness that names none, because unattended operation is the premise the container is built on (R1 in [development.md](development.md)). |
| `settings_args` | `?template` | (empty) | How this harness is told to read dejima's own settings layer, mounted at `/etc/dejima-settings.json`. Empty for a harness that takes its overrides some other way. |
| `no_confirm_args` | `?args` | (empty) | The flags that make the harness run without confirming each tool call. Required in practice: dejima refuses to launch a harness that names none, because unattended operation is the premise the container is built on (R1 in [development.md](development.md)). |
| `settings_args` | `?args` | (empty) | How this harness is told to read dejima's own settings layer, mounted at `/etc/dejima-settings.json`. Empty for a harness that takes its overrides some other way. |
| `keys` | `?list:keyspec` | (empty) | This harness's own `[harness.<name>]` config schema - same grammar as a forge's `keys`. |
| `resume_command` | `?template` | (empty) | Prose template (may use `{{session_id}}`) shown at the end of the briefing for resuming this exact session on the host. Substitution is not recursive and the `template` type has no `=`, so a resume command that needs a second placeholder or an environment variable goes in the harness's briefing fragment instead, where both resolve. |
| `transcript_update` | `enum:append\|rewrite` | `append` | How this harness's transcript files change, which decides how they can be read incrementally. `append` means the file only grows, one record per line, so the conversation shipper reads forward from a byte offset and consumes only whole lines. `rewrite` means the file is replaced wholesale, where an offset would land in the middle of a document - it is re-read when it changes and the records already shipped are skipped by count. Declared rather than sniffed, because append semantics against a rewritten file ship garbage. |
@@ -437,12 +438,21 @@ Every `{{placeholder}}` in the assembled text must be on a fixed whitelist
(`pr_command`, `issue_command`, `forge_name`, `forge_ssh_host`,
`blocked_branches`, `container_id`, `project_dir`, `workspace_dir`,
`session_id`, `resume_command`, `writable_mounts`, `network_allowlist`,
`no_confirm_args`, `harness_state_dir`) - an unrecognized one is a
`no_confirm_args`, `harness_state_dir`, `workspace_posture`,
`project_posture`) - an unrecognized one is a
hard refusal, not a literal pass-through. `{{session_id}}` and
`{{container_id}}` are deliberately left as `@@SESSION_ID@@`/
`@@CONTAINER_ID@@` markers instead of being resolved here, since neither is
known until the container is actually running.
`workspace_posture` and `project_posture` are what
[`--isolated`](configuration.md#isolated-sessions) changes. The preamble's
claims about the mount, about `~/shared` being shared with every session
against this project, and about the transcript outliving the container are
substituted rather than written inline, because an isolated launch makes all
three false - and a session that believes them leaves work for a resume that
cannot happen.
### How the briefing reaches the session
**A session-start hook is a size-limited channel, which is why it is not the
@@ -508,9 +518,10 @@ itself:
`GITEA_SSH_HOST`, `GITEA_TOKEN`).
`hooks/git-push-guard.sh` reads `DEJIMA_PUSH_HOSTS` and
`DEJIMA_BLOCKED_BRANCHES`; `hooks/merge-guard.sh` reads `DEJIMA_MERGE_BLOCK` as a
regex-escaped literal command prefix. Two different failure directions,
both deliberate:
`DEJIMA_BLOCKED_BRANCHES`; `hooks/merge-guard.sh` reads `DEJIMA_MERGE_BLOCK` as
the merge subcommand a workflow blocks, matched against the parsed argv rather
than against the command string. Two different failure directions, both
deliberate:
- `DEJIMA_PUSH_HOSTS` fails **closed** - empty means no host is allowed, so
every push is refused. A no-forge or non-pushing workflow becomes
@@ -518,8 +529,11 @@ both deliberate:
- `DEJIMA_BLOCKED_BRANCHES` fails **open** - empty means no branch is
protected, which is correct for a workflow with genuinely no branch
policy.
- `DEJIMA_MERGE_BLOCK` fails **open** the same way - empty makes
`merge-guard.sh` inert rather than conditionally installed.
- `DEJIMA_MERGE_BLOCK` fails **open** for the merge it names - empty blocks no
merge, which is correct for a workflow with no merge policy. The guard
itself stays active either way: it also adds the agent-written disclosure
and holds a command to the repository the session is for, and neither of
those is the workflow's to turn off.
## Validation
@@ -675,8 +689,13 @@ knowing about because they are what a second forge costs:
as such, and a count of what was hidden. Everything else `gitea-pr` does has
a `gh` subcommand already.
- **`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`
gitea CLIs resolve the repository from `origin` and refuse an origin that is
not on the configured host, so there is no flag or variable to compare and
nothing for this guard to declare. That is a narrower confinement than it
sounds: `origin` lives in `.git/config`, inside the read-write project mount,
and the gitea token is account-wide - so a session that rewrites `origin` to
another repository **on the same host** reaches it. What bounds that is the
token's scope, as it is for `gh`. `gh` takes `--repo`
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
+71 -1
View File
@@ -101,7 +101,7 @@ why each is not a test:
| `forges/`, the forge guard | Open a pull request through each forge and read the body that arrived |
| any guard | Try the refused thing by the route an agent would take, not by the route the test takes |
| `bin/dejima-tty-suspend`, the launch | Ctrl-Z a real session and `fg` it: the tests use a stand-in for the container run |
| `web/` | Load it on a phone; nothing here can see a soft keyboard |
| `web/` | `cd web && node test/browser/sess-button.mjs`, then load it on a phone; nothing here can see a soft keyboard |
| `harnesses/` | Launch the harness against a real model |
## 5. The release pull request
@@ -118,3 +118,73 @@ looks like the safety net is not one. Name the keys. A queued privileged job, a
manual check that was skipped, a forge that has still never been launched -
those are the things a reader of `main` is entitled to know, and the release
PR is where they are still true and still remembered.
Worth its own line too: **what an existing config stops being able to do.** A
release that tightens a boundary can refuse an arrangement that worked, with
nothing in the operator's config changed - and the ones that fail closed are
the ones to name, because failing closed is quiet. An observability path is
the worst case: a log shipper that can no longer reach its endpoint stops
shipping and nothing else happens. Name the arrangement, what it now needs,
and where the refusal shows up.
## 6. The version, and the entry in the changelog
A release is a date, and the tag of that name on `main` is the only record of
it. Two steps, both at merge time, both easy to forget because nothing fails
without them:
```sh
git tag 2026.10.12 <the merge commit on main>
git push origin 2026.10.12
```
and in the same pull request, rename `CHANGELOG.md`'s `## Unreleased` heading
to that date and open a fresh empty one above it. The date is the day it
merges, so it cannot be written before the merge is made - which is why the
heading waits rather than being guessed.
**There is nothing else to bump.** `dejima --version` reads `git describe`, so
the tag is the version: no file records it, and none can disagree with it. A
checkout pulled past a release reports `2026.10.12+37`, which is the honest
answer for a checkout on no release.
**A second release on the same day gets a letter** - `2026.10.12b`, then `c`.
Rare, and the format otherwise has no answer: the date is the whole name.
`sort -V` orders it after the bare date, which is what the update notice
compares with, so nothing else has to know about the shape. A letter rather
than a fourth dotted number, because four dot-separated numbers beginning
with a year and a month read as a private IPv4 address.
What an entry is for is one question - should I update, and what will I be
getting - so it names what an operator would notice rather than what changed.
The pull requests carry the detail and are linked from it. A tag is a record
of what shipped, not a version anyone may stay on; `CHANGELOG.md` says so at
the top, and `SECURITY.md` is where that is binding.
## 7. The GitHub mirror
`mirror/github-mirror.sh` publishes `main` and the tags on it, for this
repository and every other one in the organisation, to
[github.com/dejima-org](https://github.com/dejima-org) from the operator's
host, hourly, so a release needs no step here. Issues, pull requests and
Actions are off there; all of that stays on the forge. A new repository is
mirrored once it is added to `MIRROR_REPOS` in the script.
Each mirror's history is the forge's `main` with AI co-author trailers taken
out of the commit messages, so GitHub credits no tool as a contributor, and
with the values in the operator's scrub file replaced throughout. That file
stays on the host, because the list is the thing it hides. The commits keep
their authors and dates but not their SHAs, so a forge SHA does not resolve on
GitHub. On top sits one commit that points repository links and clone URLs at
GitHub, lets the README wordmark follow the reader's
theme, and says at the top of the README that it is a mirror. A README change
that breaks one of those rewrites stops that repository's mirror rather than
publishing the forge's version. Here, `test/mirror.bats` fails on it first; in
the other repositories, the hourly run fails and names it.
Setup is in the header of `mirror/dejima-github-mirror.service`. Once the
GitHub repositories exist, `github-mirror.sh --settings` turns their features
off, and `--dry-run` builds what a sync would publish without pushing it. A
sync refuses a mirror whose `main` is not built on the history it would
publish, which happens when the rewrite rules change or the scrub file gains a
value; `--reset` force-pushes over it.
+171 -17
View File
@@ -52,11 +52,16 @@ running.
## The bind address is the one thing to get right
`docker/egress-policy` deliberately permits traffic to the session's
default-route address - the Docker bridge gateway, which is your host. That is
the one host address a sandbox can reach at all. Combine it with the fact above
(a tmux client is a host shell) and the consequence is direct: **a front door
the sandbox can reach is a compromised session's own escape hatch.**
The session's default-route address - the Docker bridge gateway - is your
host, and it is the one host address a sandbox can route to at all. Combine
that with the fact above (a tmux client is a host shell) and the consequence
is direct: **a front door the sandbox can reach is a compromised session's own
escape hatch.**
`docker/egress-policy` opens no port on that address unless told to, so a
front door bound to it is refused by the packet filter as well. Do not spend
that: the clamp is the second layer, and binding the proxy correctly is the
first.
So:
@@ -900,10 +905,11 @@ invisibly, and on the same `XDG_RUNTIME_DIR` as the gotcha above.
**The marker directory is a write channel into your runtime tmpfs**, and
nothing bounds it. `/run/user/<uid>` is RAM-backed and sized at a fraction of
physical memory, so a session filling `/run/dejima-notify` degrades your
whole login session - D-Bus, the systemd user manager - not just dejima. A
session can already fill the workspace mount, so this is an increment rather
than a new exposure, but a RAM-backed filesystem fails harder and sooner.
physical memory, so a session filling `${XDG_RUNTIME_DIR}/dejima/notify`
degrades your whole login session - D-Bus, the systemd user manager - not just
dejima. A session can already fill the workspace mount, so this is an increment
rather than a new exposure, but a RAM-backed filesystem fails harder and
sooner.
There is deliberately no quota here; `rm -rf` on that run's marker directory (or
logging out, which discards the tmpfs) is the recovery.
@@ -1007,30 +1013,178 @@ debounce instead.
than arrow keys, because a phone soft keyboard handles those far better.
`--list` prints one tab-separated row per live session for scripting, and a
run-id argument attaches directly, which is what the deep link and `ttyd`'s
`--url-arg` produce. Rows carry the run id, the tmux label, the workspace
name, the session's age, its state, and the container id.
`--url-arg` produce. Oldest first in both: the numbers are how a session is
picked, so the order holds still rather than following the state.
**The picker shows three columns - name, state, age** - and the name comes
first, against the index, because those two are what you match up. A `--list`
row carries everything: the run id, the session name, the tmux label, the
workspace name, the age in minutes, the state, and the container id. The label
and the container id are deliberately not in the picker. Carrying all five made
a row about eighty characters wide, which on a phone wrapped onto a second line
and put the state and the age underneath the *next* session's number; and
neither is something to act on - picking a session goes to its window for you,
and every container id shares a prefix with every other.
The name column is whatever the terminal has room for after the rest of the
row, so a desktop shows every name whole and a phone shows as much as it can.
A name that did not fit is cut with a `~`, because two projects can differ only
past the cut and a row that said nothing about it would be worse than a short
column. `--list` always carries the whole name.
**Every session has a name, and `dejima --name <text>` is how you choose it.**
Without the flag it is the project directory's name, which is what both front
doors showed before and is the right answer for the usual one-session case.
**A name already in use gets the first free `-N`**, so a second session
against the same project is `myproject-2` rather than a second row reading
`myproject` - and rather than a launch that refuses to start, which would be a
worse answer than an ambiguous row. The first *free* suffix: a number a
finished session gives back is used again, instead of the count climbing for
the life of the host. Collisions are read from the live registry and from the
tmux window list, because each is blind to a case the other sees. A name you
passed explicitly is taken verbatim, collision or not; you typed it for this
launch.
Nothing reserves a name, so two launches in the same instant can still land on
one. That costs a pair of rows that read alike, which is where this started
rather than a failure, and the alternative is a lock held across a cold image
build.
A derived name is cut down where an explicit one is refused: control
characters are stripped and the whole thing is truncated to 32 characters,
leaving room for the suffix. A directory was not named for this purpose, so a
launch that stopped because a path was long would be refusing over something
it can fix itself.
The name is also the **tmux window name**, which is the part worth having even
with remote access off: `choose-window`, the status line and the window list
all show that name, and for a dozen sandboxes they otherwise show a dozen
copies of whatever `automatic-rename` makes of the container command. A window
you renamed yourself is left alone unless the launch passed `--name`.
The picker is coloured (gruvbox) when stdout is a terminal that says it can
show it; `NO_COLOR=1` or a `dumb` `$TERM` turns that off, along with the
box-drawing glyphs. `--list` is never coloured, terminal or not - the picker
parses those rows itself and the README calls them scriptable.
parses those rows itself and `dejima-attach --help` calls them scriptable.
Three states, from a registry `dejima` writes on launch and removes on
exit:
The state column answers two questions with one word, because they do not
overlap: a session that cannot be attached to has nothing useful to say about
whether it wants attention, and for one that can, "attachable" is the answer
to a question nobody asked.
Reachability, from a registry `dejima` writes on launch and removes on exit:
- `attachable` - a live tmux pane to join.
- `stale` - registered, but its pane is gone. Listed rather than hidden,
since the registry is a hint and not truth: the exit trap doesn't run
under `SIGKILL` or a host crash.
- `no-tmux` - the sandbox was started outside tmux, so there is no pane to
attach to. Still listed, deliberately, rather than silently omitted.
And for a session with a pane to join, what it last said about itself:
- `waiting` - it finished a turn, or asked for something, and has not been
given anything since. Both of the harness events the hook reports,
[`done` and `waiting`](#notifications), land here: they are one thing to an
operator, who does the same about either.
- `working` - it was given a prompt and has not stopped.
- `starting` - its image is still being built, so there is no session in there
to attach to yet. In the picker only: `--list` says this in its container
column, which the picker does not carry.
- `attachable` - it has said nothing yet. A harness that reports no state at
all stays here for its whole life, and so does a session between launching
and its first prompt.
That reporting is the notification hook writing one file into a directory of
its own, overwritten rather than consumed: the markers the
[notification watcher](#notifications) consumes are gone the moment it pushes
them, and with no watcher configured nothing reads them at all. So the states
above work whether or not you use notifications, which is the point - they are
the answer to "which sessions need me" for an operator who is at the machine.
It is the session reporting on itself, so it is best-effort by construction. A
harness killed mid-turn leaves `working` behind, and the age column is the
thing to read next to it.
Entries whose container has gone away are pruned as they're listed. When
Docker can't be asked at all, nothing is pruned and the container column
reads `?` - an unanswerable question is never treated as a "no".
Attaching creates a **grouped** tmux session (`dejima-<run-id>`) rather than
joining yours. Grouped sessions share windows but track their own current
### Watching all of them, and getting to one
`dejima-attach --watch` is the same picker redrawn every five seconds. One
window showing which sessions are working and which are waiting, with the same
numbers to go to one. A redraw is a full listing, which is two questions to
Docker and one to tmux however many sessions are running.
`dejima-attach --count-waiting` reduces it to a number, for a tmux status line:
```tmux
# ~/.tmux.conf - how many sessions are waiting for you, in the status bar
set -g status-interval 10
set -ag status-right ' waiting: #(dejima-attach --count-waiting)'
```
That path reads the registry, the pane list and the reported states and asks
Docker nothing, because the point of the number is not having to open
anything. What it gives up is noticing a session whose container died without
taking its launcher with it - every listing shows that one as gone - and
telling a session whose image is still building from one that is simply quiet;
both read as `attachable`.
`--counts` answers for every state in the same single pass, because two
numbers on a status bar would otherwise cost two invocations of something that
runs on a timer:
```console
$ dejima-attach --counts
waiting=3 working=5 attachable=8 stale=0 no-tmux=0 total=16
```
The words are the picker's, so a number on a status bar and a row on a screen
say the same thing about the same session. Formatting is yours; `cut` is
enough for the usual case, and nothing here decides how your status bar looks:
```tmux
set -ag status-right ' #(dejima-attach --counts | cut -d" " -f1,2)'
```
`--state-dir` prints where the registry and the reported states live, for
anything on this host that would rather read the files than run this. It is
under `$XDG_RUNTIME_DIR` when there is one and under `/tmp` when there is not,
so it is not a path to hard-code:
```console
$ ls "$(dejima-attach --state-dir)"/notify/*/level
```
The filename is the state, and it is the harness event rather than the word
the picker shows: `done` or `waiting` is a session waiting for you, `running`
is one working.
**The SESS button does not reload the page.** ttyd takes the run id as a
WebSocket argument rather than as part of the page, so pressing it closes the
socket and opens another without that argument, which is `dejima-attach` with
no argument, which is the picker. The half a megabyte of inlined terminal
stays loaded and stays running; on a phone, fetching and re-running it was
most of the wait. Closing the socket is also what ends the session you are
leaving, exactly as navigating away used to: ttyd kills the command when its
connection goes.
The terminal is reset on the way, and the mode tracker with it. What the
socket carries away is the process, not the state it left behind in the
terminal - an alternate screen, mouse reporting, a cursor key mode - and the
picker would otherwise open into whatever the session had switched on.
**Inside tmux, the picker switches your client to the session's own window**
rather than attaching. tmux refuses to nest a session inside one of its own
panes, so on your own machine the picker would otherwise list every session
and reach none of them - and switching is what makes it the window switcher
that a dozen parallel sessions actually need. `prefix` + a window number stops
at nine; this does not.
Attaching, on a client with no tmux of its own, creates a **grouped** tmux
session (`dejima-<run-id>`) rather than joining yours. Grouped sessions share windows but track their own current
window, so the phone can sit on one sandbox while the desktop sits on
another, and the throwaway carries `destroy-unattached`, so it disposes of
itself when the phone detaches or the connection drops.
+162 -12
View File
@@ -34,7 +34,9 @@ than the thing inferred from silence.
to [dejima-devbox-hardener](https://gitbox.branderson.io/dejima/dejima-devbox-hardener).
Run on a laptop with no VM layer, the row below the container is the
operator's own machine, and dejima has nothing to say about it. See
[Which deployment this describes](threat-model.md#which-deployment-this-describes).
[Which deployment this describes](threat-model.md#which-deployment-this-describes),
and [Using dejima on its own](threat-model.md#using-dejima-on-its-own) for
what still holds there and what to do about the rest.
- **Anything finer than a session.** The unit of isolation is one container for
one session. An MCP server, a hook, or a tool the session runs inherits the
session's whole reach - dejima reports that a session *added* an MCP server
@@ -87,7 +89,7 @@ last is what remains once something is.
| Layer | Reached by | Radius without | Bounded by | Radius with |
| --- | --- | --- | --- | --- |
| Agent, not yet hostile | ordinary operation | the workspace, its history, every mounted path, and the forge, at full speed | the session briefing and `.dejima.md`; the `PreToolUse` hooks | obvious destruction and out-of-remote pushes fail fast, and merging stays a human decision |
| Agent, not yet hostile | ordinary operation | the workspace, its history, every mounted path, and the forge, at full speed | the session briefing and `.dejima.md`; the command guards | obvious destruction and out-of-remote pushes fail fast, and merging stays a human decision |
| Container | ordinary operation | the operator's home directory, every credential in it, and the open internet | dejima | the workspace, allowlisted domains, and the forge token's scope |
| Devbox VM, unprivileged | container escape | everything the account can read, and unrestricted egress to the internet and the LAN | `dejima-devbox-hardener` | the same VM, with LAN reach cut to what is declared; internet egress stays open, deliberately |
| Devbox VM, root | escape plus local escalation, or misconfiguration | the whole VM, the same egress, and the model API credential | `dejima-devbox-hardener`, enforced outside the guest | the whole VM and no further: root cannot reach the rule set that holds the LAN boundary |
@@ -176,8 +178,8 @@ the sandbox at it instead of at you:
- `[forge.gitea] token_file`: that account's token, rather than one issued
under yours.
- `[core] ssh_key`: a deploy key belonging to it, rather than your forwarded
agent. See "Headless git push without agent forwarding" in
[docs/configuration.md](configuration.md).
agent - which is then not mounted at all. See "Headless git push without
agent forwarding" in [docs/configuration.md](configuration.md).
With the two identities separated, the same settings stop being decorative:
@@ -202,8 +204,7 @@ collaborator on the repositories it works on. Write is the ceiling here, not
a floor: an account that owns a repository, or holds admin on it, can merge
its own work and edit the settings in the table above, so every row of that
table goes back to being decorative. `write:repository` and `write:issue` are
the scopes its token needs (README, "Gitea/Forgejo PR workflow"), and nothing
about that changes for a collaborator.
the scopes its token needs, and nothing about that changes for a collaborator.
One consequence presents as a misconfiguration, and the obvious fix is the
wrong one. Parts of Gitea's API are gated on *ownership* rather than on
@@ -279,7 +280,9 @@ 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.
`backends/*/backend.conf` names the upstream every request is forwarded to and
the host path the credential is read from, so an edit there redirects where a
real credential is sent.
real credential is sent. `mirror/github-mirror.sh` runs on the host from a
timer with a key that can push to every repository in the GitHub organisation,
which is why its unit installs a copy rather than linking into the checkout.
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`
@@ -322,6 +325,52 @@ can make; it is one the audit reports. See
overlaid (they're config, not fixed paths a static mount can cover) and
remain fully writable; the exit-time audit below reports changes to
either, but only after the session ends - it doesn't stop them.
### A session with no project mounted
`dejima --isolated` removes this whole class rather than bounding it: there is
no mounted project to be written, no `.git` to plant a hook in, and no
harness state directory on the host for a session to leave anything in. Its
working directory is container-local and its `~/shared` is made fresh for that
session alone.
**It narrows the forge credential too**, which is the widest thing a session
holds: a Gitea token is account-wide (see
[To your forge, the sandbox is you](#to-your-forge-the-sandbox-is-you)), so an
ordinary session can read and write every repository its operator can.
`--isolated` sets `forge` to `none` and `workflow` to `local-only` for that
launch, and blanks the session's copy of `[github] token_file` - core's
read-only GitHub credential, which crosses for every ordinary session
whatever forge is selected, and which is read-only across the operator's
*private* repositories rather than scoped to one. So no forge credential of
either kind reaches an isolated session, and nothing tells it to push.
Naming a forge on the same command line brings that back: it is the operator
saying this session is meant to reach one, and nothing is narrowed from there.
The filter container keeps the GitHub token either way, because that is what
authenticates dejima's own fetch of GitHub's address ranges - a request about
the allowlist, made outside the session's reach.
**The forwarded agent is usually not mounted; a configured key always is.**
The agent is withdrawn when the workflow does not push **or** a usable key is
configured, and `--isolated` defaults to a workflow that does not push - so
the ordinary isolated launch forwards no agent. Name a pushing workflow on a
host with no `[identity.<name>] ssh_key`, which the example above does, and it
is forwarded like any other pushing launch. The flag does not decide this; the
workflow and the key do.
A configured key is mounted and loaded whatever the workflow, so an isolated
session with no forge still holds a key that can reach a host over ssh.
Withholding that for any launch whose workflow never pushes is
[#392](https://gitbox.branderson.io/dejima/dejima/issues/392).
So: a narrower blast radius on the host and a narrower credential, not a
stronger sandbox. The firewall, the credential boundary and the capability
drops are unchanged, and a container escape is exactly as serious. The cost is
that the conversation log, the epitaph and the exit-time audit are all off,
because each reads host state this mode does not mount. See
[Isolated sessions](configuration.md#isolated-sessions).
## The exit-time audit
`bin/dejima-audit` snapshots host-persistence surfaces before each run and
@@ -508,6 +557,13 @@ record, so there is no slice to take - dejima gives each workspace a data
directory of its own instead. Both reach the same place: a session sees its
own project's history and no other's.
That is about the per-project state each harness keeps. It is not true of
`~/.claude/backups`, `tasks`, `plans` and `sessions`, which are mounted
read-write and shared by every project's sessions - what a session writes
there, another project's session can read. See
[#380](https://gitbox.branderson.io/dejima/dejima/issues/380), which has what
was observed and why only the first of the four was free to fix.
### Claude Code: a slice of your own tree
- `~/.claude/projects` (session transcripts and per-project memory) is
@@ -726,6 +782,17 @@ reads it host-side per request instead. Without it the proxy would
withhold one file while dejima copied another, on every launch and whatever
backend was selected.
**The same staging narrows the `projects` map to the launching project.** That
map keys per-project state by absolute path, so copying all of it told a
session the name of every project on the host, a sample of each one's
filenames, and what each had cost - from inside a container whose claim is
that it cannot learn what you do elsewhere. The launching project's path and
anything under it are kept, because a session's working directory is routinely
a worktree inside it. Both halves run on the one path that stages this file,
including the re-seed for a backend whose credential IS this file and whose
proxy is off: that one keeps the credential and narrows the map exactly as the
other does.
**The residual the first two share is use-while-running.** A session that has
been turned can spend your quota, ask the model anything, and read the answers
- through the proxy, without ever holding the credential. That is inherent to
@@ -782,7 +849,22 @@ reach the model - which reads as a dejima fault rather than a missing
prerequisite. Sign in on the host first; see the README's
[Sign in on the host first](../README.md#sign-in-on-the-host-first).
Refreshing an expired credential is the same: on the host, not from inside a
session, where the credential deliberately never goes.
session, where the credential deliberately never goes. A session that outlives
its credential - one left open overnight is the ordinary way - meets a 502 from
the proxy naming that as the remedy, and the same line is the last thing the
proxy logs, so the exit audit repeats it on the host terminal.
**What the proxy may tell a session about a credential failure.** Anything it
learned from the provider stays on the host: a response body, a status line, an
exception. What crosses into the container is text this repository wrote, and
only when it names something the operator has to do - "log in again on the
host" is useful to an agent that would otherwise report itself broken, and
discloses nothing the session did not already know. The distinction is the
credential proxy's `CredentialNeedsOperator`, which carries the two separately
so neither can be sent by accident. The proxy's own log is bound by the same
rule - it keeps provider statuses and error-body prefixes for the exit audit -
so it lives outside the socket directory, which is the one proxy path the
session mounts.
### The refreshing OAuth path, in detail
@@ -967,8 +1049,10 @@ reasoning below is what both are built on.
failing cleanly (confirmed against a real read-only copy of this file -
upstream bug: [anthropics/claude-code#29162](https://github.com/anthropics/claude-code/issues/29162)).
Instead, `dejima` generates a throwaway copy of the file at
launch (seeded from the real file, same "generate a sandbox-specific
file on the host" pattern as the generated settings override layer)
launch (seeded from the real file with the credential dropped and the
`projects` map narrowed to this project - see "staged, not mounted" above -
the same "generate a sandbox-specific file on the host" pattern as the
generated settings override layer)
and mounts *that* read-write in its place; the copy is discarded,
unmodified in the real file, when the session ends. A compromised
session can add whatever it wants to `mcpServers` (or anything else in
@@ -999,6 +1083,9 @@ reasoning below is what both are built on.
Gitea repo it has access to" shape as the forwarded agent above - the
mitigation is that it's meant to be a dedicated deploy key scoped to only
the repos that actually need push access, not your personal identity.
Setting it also withdraws the forwarded agent: `SSH_AUTH_SOCK` is not passed
to the container, so the two paths are alternatives rather than both being
open at once.
That scoping is a convention, not something enforced here: nothing checks
what `[core] ssh_key` actually points at, so pointing it at your real
`~/.ssh/id_ed25519`/`id_rsa` instead of a dedicated key would load your
@@ -1219,7 +1306,9 @@ On start, the filter container establishes an allowlist-only egress policy
everything the session may reach expressed as named sets. Allowed by default:
- DNS resolution via a local proxy (see "DNS is allowlisted too")
- the Docker host itself (not its whole subnet)
- nothing on the Docker host. Its address is a named destination so that
reaching for it is counted and refused rather than falling off the end of the
chain, but no port on it is open (see "The host entry" below)
- GitHub's published IP ranges, port 443 only (see "GitHub push" below)
- `registry.npmjs.org`, plus whatever the selected harness declares as its own
(`platform.claude.com` for Claude Code; Cline declares none, so a session
@@ -1286,6 +1375,16 @@ of it loads. A tool that will not run and a family that is not there are
different conditions, and reporting the second on evidence of the first is how
a boundary comes to be described rather than held.
The startup self-check also resolves every permitted name and reads each answer
back out of the address set its class names, refusing to start if one is
missing. A name that resolves and whose address is not where the ports are
keyed to would be a session that reaches a configured destination only by
accident, or not at all - and the class an entry carries, the resolver line
built from it, and the set the rules use are three separate statements that can
each be right about a different one. A name that returns no address is reported
rather than fatal: that is the world being unavailable, not the policy
disagreeing with itself.
The startup self-check asserts the shape of the ruleset, not a connection that
fails: a v6 connection fails identically on a host with no v6 route, so a probe
alone would pass whether the family is refused or merely absent.
@@ -1293,6 +1392,56 @@ alone would pass whether the family is refused or merely absent.
If your Gitea host only resolves via a LAN-local DNS server, make sure the
Docker host itself can resolve it: containers inherit the host's resolver.
### The host entry
Every other destination in the policy is there because something declared it.
The host entry is not: it is the gateway of the container's default route,
derived from the routing table. Docker publishes container ports on that
address, so permitting it on every port would reach the host's sshd, anything
bound to `0.0.0.0`, and every published port of every other container on the
machine - including dejima's own remote-access front door, which is a host
shell behind a tmux prefix key.
So it opens no ports. The address is still named in the ruleset, with its own
refusal rather than a fall-through to the end of the chain, so that a session
reaching for the host shows up as a counted refusal instead of an anonymous
one.
Nothing in a launch needs it open. A host-resident forge, a host-resident Loki
and a self-served model endpoint are each reached through their own entry - by
name, or as a literal address - and each lands in a set of its own with its own
rules. Configuring one of those is how a host service becomes reachable, and
it is that service that becomes reachable rather than the host: the clamp is
matched **before** the wider class rules, so an address that is both the
host's and an open-class entry's is still held to these ports.
`EGRESS_HOST_PORTS`, set in the environment you launch from, takes TCP ports
and low-high ranges, comma or space separated. `EGRESS_HOST_PORTS=any`
restores the unclamped behaviour explicitly, and `EGRESS_HOST_ADDR=none`
removes the entry altogether. A value that is not a port stops the container
rather than becoming a rule nobody meant.
**A destination you pointed at the host's own address gets its port added for
you.** The two processes split that decision because neither knows both
halves. The launcher knows which destinations this launch configured - a
`[logging] loki_url`, a backend's `base_url` - and offers the ones it is sure
of: an explicit `:port` on a literal address. The filter, which works the
host's address out from its default route, keeps the port of each candidate
whose address is the host's. Neither resolves a name.
What that leaves out is deliberate in both directions. A scheme default is
not an explicit port, because a remote `https://loki.example.com` has none
and adding 443 for it would open the host's own 443 - which on a box
following
[Behind another reverse proxy](remote-access.md#behind-another-reverse-proxy)
is the proxy in front of the web terminal. And **a host-resident service
configured by name needs its port set here by hand**, because a name is a
destination neither process will resolve to decide what to open.
The policy's summary line is where a missing port shows up: `host <addr> on
ports none` is why nothing can reach it - the session, and the log shipper
that runs beside it.
### DNS is allowlisted too
Resolution itself is restricted, not just the IP connections made
@@ -1494,7 +1643,8 @@ execution, and the audit reports the config change it took.
## Network request logging
If `[logging] loki_url` is set (see Prerequisites in the README), every DNS query and every new
If `[logging] loki_url` is set (see [docs/configuration.md](configuration.md)),
every DNS query and every new
outbound connection attempt - allowed or rejected - is shipped to that Loki
endpoint. Two sources, both tailed and pushed by `log-shipper.sh`:
+78 -1
View File
@@ -110,7 +110,7 @@ if nothing is arranged against it, the last is what remains once something is.
| Layer | Reached by | Radius without | Bounded by | Radius with |
| --- | --- | --- | --- | --- |
| Agent, not yet hostile | ordinary operation | the workspace, its history, every mounted path, and the forge, at full speed | the session briefing and `.dejima.md`; the `PreToolUse` hooks | obvious destruction and out-of-remote pushes fail fast, and merging stays a human decision |
| Agent, not yet hostile | ordinary operation | the workspace, its history, every mounted path, and the forge, at full speed | the session briefing and `.dejima.md`; the command guards | obvious destruction and out-of-remote pushes fail fast, and merging stays a human decision |
| Container | ordinary operation | the operator's home directory, every credential in it, and the open internet | dejima | the workspace, allowlisted domains, and the forge token's scope |
| Devbox VM, unprivileged | container escape | everything the account can read, and unrestricted egress to the internet and the LAN | `dejima-devbox-hardener` | the same VM, with LAN reach cut to what is declared; internet egress stays open, deliberately |
| Devbox VM, root | escape plus local escalation, or misconfiguration | the whole VM, the same egress, and the model API credential | `dejima-devbox-hardener`, enforced outside the guest | the whole VM and no further: root cannot reach the rule set that holds the LAN boundary |
@@ -244,6 +244,8 @@ and no answer at all where it does not.
So `dejima-devbox-hardener` is load-bearing rather than optional-and-nice.
On a laptop, dejima is a hardened container and the honest description of what
is under it is "your home directory, and dejima has nothing to say about it".
[Using dejima on its own](#using-dejima-on-its-own) is what that leaves you
with, and what to do about it.
**Load-bearing for what, exactly**, because the field's tiering invites a
reading that project does not accept and has never claimed. A per-workload VM
@@ -263,6 +265,81 @@ repository's behalf.
**What this repository can do about that is say so**, which is the whole of
this section. Nothing in dejima can bound its own host.
## Using dejima on its own
Most installs are this: dejima on a laptop or a workstation, and none of the
sister projects. Everything above about the rows below the container is true,
and it is about one threat, the least likely one on the list.
### What holds
The container row, in full. Nothing in it depends on another project.
- **Disk:** the workspace, the mounts you declare, and this project's own slice
of harness state. Not your home directory, not `~/.ssh`, not your other
projects ([security.md](security.md#one-projects-session-cannot-reach-anothers)).
- **Network:** deny-by-default egress. No port on the host is open unless you
named one, and a permitted name that answers with a private address is
refused unless it is a destination you configured, so the LAN is out of
reach unless you put it there
([security.md](security.md#network-egress-firewall)).
- **The model credential** stays on the host behind the credential proxy
([security.md](security.md#the-model-api-credential-never-enters-the-container)).
- **The forge:** whatever the token and key you hand the session can reach.
- **Afterwards:** the exit-time audit reports changes to the host-side files a
session could use to run code later, before your next `git` in that repo
([security.md](security.md#the-exit-time-audit)).
Against [the ranked list](#ranked-by-what-is-actually-likely): items 1, 2 and 4
are the container row's, and are handled the same with or without the stack.
Item 3 depends on where your CI runs, not on your machine. Item 5 is a person
deciding, with or without the stack. Item 6, escape, is the one the
devbox-side projects exist for.
### What does not
- **An escape lands on your machine, as you.** Your home directory, your
network position, your credentials. Nothing here bounds that.
- **Pushing is code execution wherever your CI runs**, if the project has CI
([security.md](security.md#ci-push-access-is-code-execution-elsewhere)).
- **Nothing watches the machine between sessions.** The audit covers the
surfaces it lists, for the window the session ran.
### Measured caution
None of these needs another project.
1. **Keep the kernel and the container runtime patched.** An escape needs an
unpatched host or a working zero-day, and the first is the one you control.
2. **Point repos at dejima that you would run yourself.** That is
[an assumption](#what-this-tool-assumes) the rest rests on.
3. **Give the sandbox its own identity**, with tokens scoped to the
repositories it works on, so the forge's reach is what you granted rather
than all of yours ([security.md](security.md#give-the-sandbox-its-own-identity)).
4. **Decide about CI before granting push.** Hosted CI, a runner you are
willing to lose, or no push at all (`[core] forge = none` with
`[core] workflow = local-only`). Not a self-hosted runner with your LAN
behind it ([ci.md](ci.md)).
5. **Treat what a session hands you as its writing:** a command it asks you to
run, a domain or dependency it asks you to approve, and every file in
`share_dir` or a read-write mount. See
[what mounting it costs you](remote-access.md#what-mounting-it-costs-you)
before syncing those anywhere a desktop will open them.
6. **Read the audit before your next `git` in that repo.** `[audit] strict`
refuses a headless launch while CRITICAL findings are unacknowledged.
7. **Leave the credential proxy on.** `proxy = off` is the one arrangement that
puts the real model credential in the container.
### When the rest is worth adding
- **dejima-devbox-hardener**, when your machine sits on a network with things
you would mind an escape reaching. It enforces from outside a VM, so the
first step is moving dejima into one.
- **dejima-ci-hardener, or hosted CI**, as soon as sessions push to a project
whose CI you run yourself.
- **dejima-guard**, when sessions run unattended long enough that "would I
notice?" matters, and you have a second machine to run it from.
## Why a container, and not a microVM
The most frequent question about this project.
+6 -3
View File
@@ -35,9 +35,12 @@ api_domains =
# None. Cline registers no hook that can refuse a tool call, so bash-deny has
# nowhere to live, and it has no session lifecycle signal dejima can turn into
# a notification. push-guard and merge-guard are not listed because no harness
# hosts them any more - they are PATH shadows and hold regardless. Launch
# names what this costs; see harness_report_guards.
# a notification.
#
# push-guard and merge-guard are not listed because this harness cannot host
# them, not because nothing does - claude-code declares both, and the PATH
# shadows cover a harness that does not. Declaring none here costs the hook
# layer, not the guard. Launch names the cost; see harness_report_guards.
guards =
# Both, which is what makes this harness the backend-agnostic one: it has a
+1 -1
View File
@@ -221,7 +221,7 @@ if [ -f "$decl_dir/allowed-domains" ]; then
Tell the user to run, outside the sandbox:
dejima-project approve \"$project_dir\"
(or, for one domain right now:
dejima-allow --container $container_id <domain>)"
dejima-allow --container $container_id <domain>...)"
fi
fi
+22 -2
View File
@@ -20,7 +20,7 @@ set -euo pipefail
# Sanitizing an arbitrary name into a marker would make the file name a
# channel the sandbox controls, which is the one thing this hook must not be.
case "${1:-}" in
waiting|done) ;;
waiting|done|running) ;;
*) exit 0 ;;
esac
@@ -32,4 +32,24 @@ esac
[ -n "${DEJIMA_NOTIFY_DIR:-}" ] || exit 0
[ -d "$DEJIMA_NOTIFY_DIR" ] || exit 0
: > "$DEJIMA_NOTIFY_DIR/$1"
# The notification edge, for dejima-notify-watch to consume and push. Two
# names, because that is the set the watcher iterates: a marker it never looks
# for is a file nothing ever removes.
case "$1" in
waiting|done) : > "$DEJIMA_NOTIFY_DIR/$1" ;;
esac
# The level: which of those the session is in NOW, rather than that it just
# changed. Nothing consumes it, so it answers "which sessions need me" for a
# picker or a status line, and it does that whether or not a watcher is
# running - the edge above is gone the moment one is. Still a name and no
# content, and still only ever about this session: the directory is this run's
# own, so the worst a compromised session can do here is misreport itself.
#
# Siblings first, then the new one: a reader between the two calls sees no
# state rather than two, and "unknown" is a thing the readers already render.
[ -d "$DEJIMA_NOTIFY_DIR/level" ] || exit 0
for _other in waiting 'done' running; do
[ "$_other" = "$1" ] || rm -f "$DEJIMA_NOTIFY_DIR/level/$_other"
done
: > "$DEJIMA_NOTIFY_DIR/level/$1"
+35 -155
View File
@@ -3,19 +3,19 @@
# DEJIMA_PUSH_HOSTS, and never directly to a branch listed in
# DEJIMA_BLOCKED_BRANCHES - both resolved host-side by bin/dejima from the
# configured forge and workflow (see dejima-provider-lib.sh), not hardcoded
# to any one forge. This is a fast-fail guard against an overeager agent,
# not a hard security boundary - a sandbox session has full shell access
# and could in principle edit .git/config or re-export these variables to
# route around it; that is true today and is not a regression, and the
# firewall (see docker/egress-policy) is the actual boundary. No-op outside the
# sandbox (DEJIMA unset), since this restriction doesn't apply to normal
# host usage.
# to any one forge.
#
# DEJIMA_PUSH_HOSTS fails CLOSED: empty means no host is allowed, so every
# push is refused. That is deliberate - it is how a no-forge or
# non-pushing workflow becomes enforced rather than merely described.
# DEJIMA_BLOCKED_BRANCHES fails OPEN: empty means no branch is protected,
# which is correct for a workflow that genuinely has no branch policy.
# The rules themselves are docker/guard-lib.sh, which docker/git-guard.sh
# applies to the same push as a PATH shadow. This file's whole job is to turn
# the command string a harness reports into the argv that library reads, so
# that the two cannot reach different verdicts - which they had already started
# to do.
#
# A fast-fail guard against an overeager agent, not a hard security boundary: a
# sandbox session has full shell access and could in principle edit .git/config
# or re-export these variables to route around it, and the firewall (see
# docker/egress-policy) is the actual boundary. No-op outside the sandbox
# (DEJIMA unset), since this restriction doesn't apply to normal host usage.
if [ -z "${DEJIMA:-}" ]; then
exit 0
@@ -25,151 +25,31 @@ input=$(cat)
command=$(echo "$input" | jq -r '.tool_input.command // empty')
cwd=$(echo "$input" | jq -r '.cwd // empty')
# Only care about `git push` invocations - found by walking tokens rather than
# by matching a shape.
# shellcheck source=../docker/guard-lib.sh
source "${DEJIMA_GUARD_LIB:-/usr/local/lib/dejima/guard-lib.sh}"
# A push can be the second half of a compound command, and only a segment whose
# first word is git is a git invocation. Every segment that is one gets the same
# verdict function the shadow calls, on the words after `git`, so git's own
# options do not need anticipating here: they are argv either way.
#
# The pattern this replaced recognised `git push` and `git -C <dir> push` and
# nothing else, so every other spelling of git's own options - `git -c k=v
# push`, `git --git-dir=... push`, `git --no-pager push` - was not a push as
# far as this hook was concerned, and it exited 0 before any check ran. The
# PATH shadow in docker/git-guard.sh walks argv and caught them regardless,
# which is why this was a quiet gap in the earlier warning rather than an open
# one.
#
# Split on the shell separators first, because a push can be the second half
# of a compound command, and only the segment that STARTS with git is a git
# invocation - "echo push" is not.
push_seen=0
segment=""
IFS=$'\n' read -rd '' -a _segments < <(
printf '%s' "$command" | sed 's/&&/\n/g; s/||/\n/g; s/;/\n/g; s/|/\n/g'
printf '\0')
for seg in ${_segments[@]+"${_segments[@]}"}; do
read -ra _t <<< "$seg"
[ "${#_t[@]}" -gt 0 ] || continue
[ "${_t[0]}" = git ] || continue
_i=1
while [ "$_i" -lt "${#_t[@]}" ]; do
case "${_t[$_i]}" in
-c|-C|--exec-path|--git-dir|--work-tree|--namespace|--super-prefix)
_i=$((_i + 1)) ;;
-*) ;;
push) push_seen=1; segment="$seg"; break ;;
*) break ;;
esac
_i=$((_i + 1))
done
[ "$push_seen" -eq 1 ] && break
done
[ "$push_seen" -eq 1 ] || exit 0
# Everything below reads the git invocation, not the whole command line.
command="$segment"
repo_dir="$cwd"
if [[ "$command" =~ git[[:space:]]+-C[[:space:]]+([^[:space:]]+) ]]; then
repo_dir="${BASH_REMATCH[1]}"
fi
# One pass over the whole command, the way the shadow walks argv: skip git's
# own options until the first token that IS the subcommand, then take the
# first non-flag token as the remote and the second as the refspec.
#
# Splitting on the first occurrence of the substring "push" is what this
# replaced, and it split inside `remote.origin.pushurl=...`, a branch named
# push-fix, and anything else carrying the word. What came out was a nonsense
# remote, which then took the unresolvable path below.
#
# Word-splitting rather than a full shell parse, so quoting edge cases are not
# handled - the same pragmatic tradeoff as the sibling
# deny-dangerous-bash.sh denylist, and the reason the unresolvable case
# refuses rather than guesses.
#
# `-c key=value` is kept rather than dropped: git resolves where a push goes
# from the config the push runs under, so asking without these answers for a
# different configuration. `git -c remote.origin.pushurl=... push origin
# branch` is that case.
config_overrides=()
positional=()
read -ra tokens <<< "$command"
t=0
seen_push=0
while [ "$t" -lt "${#tokens[@]}" ]; do
tok="${tokens[$t]}"
if [ "$seen_push" -eq 0 ]; then
case "$tok" in
-c) t=$((t + 1))
[ "$t" -lt "${#tokens[@]}" ] && config_overrides+=(-c "${tokens[$t]}") ;;
-C|--exec-path|--git-dir|--work-tree|--namespace|--super-prefix)
t=$((t + 1)) ;;
push) seen_push=1 ;;
*) ;;
esac
else
case "$tok" in
-*) ;;
*) positional+=("$tok") ;;
esac
fi
t=$((t + 1))
done
remote="origin"
refspec=""
if [ "${#positional[@]}" -ge 1 ]; then
remote="${positional[0]}"
fi
if [ "${#positional[@]}" -ge 2 ]; then
refspec="${positional[1]}"
fi
# --push: the URL the push will actually use. remote.<name>.pushurl wins over
# remote.<name>.url when it is set, and reading the latter checks a host the
# push is not going to. See docker/git-guard.sh, which asks the same question.
remote_url=$(git ${config_overrides[@]+"${config_overrides[@]}"} \
-C "$repo_dir" remote get-url --push "$remote" 2>/dev/null)
if [ -z "$remote_url" ]; then
# Refused, not passed through. A remote that does not resolve is usually a
# typo git would reject in a moment anyway - but it is also what a
# disagreement between this parser and git's own looks like, and the
# failure of that disagreement has to be a refusal rather than a push
# nobody checked. docker/git-guard.sh takes the same direction; the two
# answering differently was the bug.
echo "Blocked by git-push-guard.sh: cannot resolve the remote '$remote' to a URL, so there is nothing to check this push against. Name a remote that exists." >&2
exit 2
fi
host=$(echo "$remote_url" | sed -E 's#^[a-zA-Z]+://([^/@]+@)?([^/:]+).*#\2#; s#^[^@/]+@([^:/]+)[:/].*#\1#')
host_allowed=0
for allowed_host in ${DEJIMA_PUSH_HOSTS:-}; do
if [ "$host" = "$allowed_host" ]; then
host_allowed=1
break
fi
done
if [ "$host_allowed" != 1 ]; then
echo "Blocked by git-push-guard.sh: this sandbox may only push to ${DEJIMA_FORGE_NAME:-the configured forge}'s allowed host(s) (${DEJIMA_PUSH_HOSTS:-none allowed}), not '$host'." >&2
exit 2
fi
# Resolve the target branch: a src:dst refspec's dst, a bare branch name, or
# (if no refspec was given at all, or it's HEAD) the current branch - `git
# push origin` and `git push origin HEAD` both push the current branch.
if [[ "$refspec" == *:* ]]; then
branch="${refspec#*:}"
elif [ -n "$refspec" ] && [ "$refspec" != "HEAD" ]; then
branch="$refspec"
else
branch=$(git -C "$repo_dir" branch --show-current 2>/dev/null)
fi
branch="${branch#refs/heads/}"
for blocked_branch in ${DEJIMA_BLOCKED_BRANCHES:-}; do
if [ "$branch" = "$blocked_branch" ]; then
echo "Blocked by git-push-guard.sh: direct pushes to '$branch' aren't allowed in the sandbox. Push a feature branch and open a PR/MR instead." >&2
# What a segment's FIRST word has to be is still git. A `FOO=bar git push`
# prefix, or `env git push`, is a git invocation this does not see - the PATH
# shadow does, which is the division of labour the pair exists for rather than
# a gap in it.
# Basenamed, like hooks/merge-guard.sh and like the shadow, which reads its own
# $0. `/usr/bin/git push origin main` is the one spelling that reaches neither
# layer otherwise - the absolute path steps over the PATH shadow, and comparing
# the whole word meant this hook did not see a git invocation at all. It is
# also the form the library's own header names as the reason both layers exist.
while IFS= read -r segment; do
read -ra tokens <<< "$segment"
[ "${#tokens[@]}" -gt 0 ] || continue
[ "$(basename -- "${tokens[0]}")" = git ] || continue
if ! dejima_push_verdict "$cwd" "${tokens[@]:1}"; then
echo "Blocked by git-push-guard.sh: $DEJIMA_GUARD_REASON" >&2
exit 2
fi
done
done < <(dejima_guard_segments "$command")
exit 0
+51 -22
View File
@@ -1,10 +1,15 @@
#!/usr/bin/env bash
# PreToolUse hook: inside dejima, block a merge command matching
# DEJIMA_MERGE_BLOCK - a literal command prefix resolved host-side by
# bin/dejima from the configured forge and workflow, never hardcoded to one
# forge. What lands on the default branch is an operator decision, as it
# already is for `git push`; this is the API-side counterpart to
# git-push-guard.sh, since a merge reaches the branch without a push.
# PreToolUse hook: inside dejima, apply the forge policy to a forge CLI call -
# the merge a workflow blocks, and a command aimed at a repository the session
# was not launched against. What lands on the default branch is an operator
# decision, as it already is for `git push`; this is the API-side counterpart
# to git-push-guard.sh, since a merge reaches the branch without a push.
#
# The rules are docker/guard-lib.sh, which docker/forge-guard.sh applies to the
# same command as a PATH shadow. This file's job is to turn the command string
# a harness reports into the argv that library reads, so the two cannot reach
# different verdicts. Named for the merge block because that is the rule it was
# created for; the rest came with the shared policy.
#
# Not conditional on intent: a hook is handed a command, not a conversation,
# so it cannot tell an instructed merge from an unprompted one. The line is
@@ -16,32 +21,56 @@
# shell access and the forge token, so it could call the merge endpoint with
# curl. It stops the mistake, not the attack.
#
# Fails OPEN - empty means nothing is blocked, so a workflow with no
# merge-block policy genuinely has none rather than the hook guessing.
# Fails OPEN - a forge that declares no policy has none blocked, rather than
# the hook guessing.
if [ -z "${DEJIMA:-}" ]; then
exit 0
fi
if [ -z "${DEJIMA_MERGE_BLOCK:-}" ]; then
if [ -z "${DEJIMA_MERGE_BLOCK:-}" ] && [ -z "${DEJIMA_DISCLOSE:-}" ] &&
[ -z "${DEJIMA_FORGE_REPO_FLAGS:-}" ] && [ -z "${DEJIMA_FORGE_REPO_ENV:-}" ] &&
[ -z "${DEJIMA_FORGE_REPO_PATHS:-}" ]; then
exit 0
fi
command=$(jq -r '.tool_input.command // empty')
# DEJIMA_MERGE_BLOCK is a literal command prefix from the forge manifest,
# not a regex - escape its regex metacharacters before matching so a value
# like ".*" matches only that literal text, never "everything".
escaped_block=$(printf '%s' "$DEJIMA_MERGE_BLOCK" | sed -E 's/[][(){}.*+?^$\\|]/\\&/g')
# shellcheck source=../docker/guard-lib.sh
source "${DEJIMA_GUARD_LIB:-/usr/local/lib/dejima/guard-lib.sh}"
# The leading class includes '/' so a path-qualified call is caught too -
# the Dockerfile installs each forge's CLI under /usr/local/bin, and that's
# what an agent reaching past a shadowed PATH would type. Word-splitting
# rather than a full shell parse, the same pragmatic tradeoff the sibling
# hook makes.
if [[ "$command" =~ (^|[[:space:]&\|\;/])$escaped_block([[:space:]]|$) ]]; then
echo "Blocked by merge-guard.sh: merging is a human decision in the sandbox - open a PR/MR, then the operator merges. Report its URL and stop." >&2
exit 2
fi
forge_commands="$(dejima_forge_commands | sort -u)"
[ -n "$forge_commands" ] || exit 0
# Basenamed, so a path-qualified call is checked too - the Dockerfile installs
# each forge's CLI under /usr/local/bin, and the real one sits behind the
# shadow at a fixed path, which is what an agent reaching past a shadowed PATH
# would type. It is also what the shadow itself compares, since that reads its
# own basename.
#
# A later word counts only when it names a path, which is what divides the two
# layers: a bare `gh` anywhere in a segment is the shadow's, because reaching
# it means executing what PATH resolves, while a path names the real binary
# directly and the shadow never runs. `env /usr/local/lib/dejima/forge/gh pr
# merge 7` is both at once - a prefix word hiding the first position, and a
# path stepping over the shadow - so without the path case it belonged to
# neither. `echo gh pr merge 3` stays allowed, and must: it names the CLI
# without running it, and the shadow it would run is not being bypassed.
while IFS= read -r segment; do
read -ra tokens <<< "$segment"
for i in "${!tokens[@]}"; do
case "$i:${tokens[$i]}" in
0:*) ;;
*:*/*) ;;
*) continue ;;
esac
self="$(basename -- "${tokens[$i]}")"
grep -qxF -- "$self" <<< "$forge_commands" || continue
if ! dejima_forge_verdict "$self" "${tokens[@]:$((i + 1))}"; then
echo "Blocked by merge-guard.sh: $DEJIMA_GUARD_REASON" >&2
exit 2
fi
done
done < <(dejima_guard_segments "$command")
exit 0
+3 -1
View File
@@ -140,7 +140,9 @@ else
# only what the operator has actually decided.
{
printf '# dejima configuration. See README.md.\n'
printf '# Every key below is shown at its default; uncomment to change one.\n'
printf '# This file holds what you have decided, not every key there is.\n'
printf '# defaults.conf beside it is the complete reference, rewritten by\n'
printf '# every ./install.sh - do not edit that one.\n'
printf '#\n'
printf '# [core] harness, forge and workflow are written out uncommented, so\n'
printf '# this file states what it selects rather than inheriting a future\n'
+50
View File
@@ -0,0 +1,50 @@
# User unit for the GitHub mirror (#209), on a host that can push to GitHub.
# Never in a dejima session: a sandbox may push only to its own forge.
#
# install -m 755 mirror/github-mirror.sh ~/.local/bin/dejima-github-mirror
# cp mirror/dejima-github-mirror.service mirror/dejima-github-mirror.timer ~/.config/systemd/user/
# systemctl --user daemon-reload
# systemctl --user enable --now dejima-github-mirror.timer
#
# A copy, not a link into the checkout: the timer holds a key that can push to
# every repository in the organisation, and a session working in this
# checkout can edit the script. Reinstall it after reviewing an update.
#
# ~/.config/dejima/github-mirror.env sets MIRROR_SOURCE, the forge
# organisation each repository name is appended to, and whatever else
# mirror/github-mirror.sh documents. The GitHub key belongs behind an ssh Host
# alias named in MIRROR_TARGET, so it is not also offered to the forge. A
# deploy key covers one repository, so this one is an account's key with push
# access to every repository in the organisation:
#
# MIRROR_SOURCE=git@forge.example.com:dejima
# MIRROR_TARGET=git@github-mirror:dejima-org
#
# A sync opens several connections per repository. One multiplexed connection
# to GitHub's port 443 endpoint keeps a network that throttles bursts of
# outbound ssh from timing out every repository after the first:
#
# Host github-mirror
# HostName ssh.github.com
# Port 443
# User git
# IdentityFile ~/.ssh/id_ed25519
# IdentitiesOnly yes
# ControlMaster auto
# ControlPath ~/.ssh/cm-%C
# ControlPersist 60
#
# ~/.config/dejima/github-mirror.scrub lists values replaced throughout the
# published history, one "value replacement" pair per line. Adding one changes
# history already published, so the next run refuses until it is given
# --reset, once, by hand:
#
# gitea.internal.example gitea.example.com
# 10.9.8. 192.0.2.
[Unit]
Description=Mirror the dejima repositories' main and release tags to GitHub
[Service]
Type=oneshot
EnvironmentFile=%h/.config/dejima/github-mirror.env
ExecStart=%h/.local/bin/dejima-github-mirror
+9
View File
@@ -0,0 +1,9 @@
[Unit]
Description=Mirror the dejima repositories' main and release tags to GitHub, hourly
[Timer]
OnCalendar=hourly
Persistent=true
[Install]
WantedBy=timers.target
+282
View File
@@ -0,0 +1,282 @@
#!/usr/bin/env bash
# Publishes main and its release tags of every dejima repository to a
# read-only mirror on GitHub (#209).
#
# MIRROR_SOURCE=<forge org URL> mirror/github-mirror.sh sync every repository
# ... mirror/github-mirror.sh --dry-run build and print, push nothing
# ... mirror/github-mirror.sh --reset replace a mirror built on other history
# mirror/github-mirror.sh --settings configure the GitHub repos (needs gh)
# mirror/github-mirror.sh --transform DIR rewrite a checkout in place
#
# Runs on the host, never in a session: a sandbox may push only to its own
# forge, and this needs push access to GitHub.
#
# The GitHub copy is not byte-for-byte. Its history is the forge's main with
# AI co-author trailers taken out of the commit messages, so no tool is
# credited as a contributor, and the MIRROR_SCRUB values replaced in every
# file and message (rewrite_history). That changes the SHA of every commit
# from the first one touched on, but the same input always makes the same
# commits. On top sits one extra commit, whose tree is the forge's with the
# rewrites in transform() applied, and whose parents are the rewritten commit
# and the previous mirror commit, so each sync is a fast-forward for anyone
# tracking it. A tag on main is published as a lightweight tag on the
# rewritten commit it maps to.
#
# A mirror whose main is not built on that history - an earlier rule, or a
# forge main that was rewound - is refused. --reset force-pushes over it.
#
# Environment:
# MIRROR_SOURCE forge URL each repository name is appended to, as
# $MIRROR_SOURCE/<name> (required to sync)
# MIRROR_ORG GitHub organisation, default dejima-org
# MIRROR_TARGET push URL prefix, default git@github.com:$MIRROR_ORG
# MIRROR_REPOS repositories, by their name on both sides
# MIRROR_STATE working bare repositories, default ~/.cache/dejima-github-mirror
# MIRROR_SCRUB values to replace throughout the published history, one
# "value replacement" pair per line, default
# ~/.config/dejima/github-mirror.scrub. Kept on the host
# because the list itself is what it hides.
set -euo pipefail
MIRROR_ORG="${MIRROR_ORG:-dejima-org}"
MIRROR_TARGET="${MIRROR_TARGET:-git@github.com:$MIRROR_ORG}"
MIRROR_REPOS="${MIRROR_REPOS:-dejima dejima-badge dejima-ci-hardener dejima-devbox-hardener dejima-guard .github}"
MIRROR_STATE="${MIRROR_STATE:-${XDG_CACHE_HOME:-$HOME/.cache}/dejima-github-mirror}"
MIRROR_SCRUB="${MIRROR_SCRUB:-${XDG_CONFIG_HOME:-$HOME/.config}/dejima/github-mirror.scrub}"
export MIRROR_ORG MIRROR_SCRUB
NAME=""
say() { echo "github-mirror: ${NAME:+$NAME: }$*"; }
die() { say "$*" >&2; exit 1; }
# Rewrites a fast-export stream. Commit messages lose Co-Authored-By trailers
# crediting Claude or anything else at anthropic.com, and every data block -
# file contents and messages - has the MIRROR_SCRUB values replaced, matched
# only where the value does not continue a longer name or number. Data
# needing neither change passes through byte for byte, so its commit keeps
# its SHA.
rewrite_history() {
perl -e '
binmode STDIN; binmode STDOUT;
my @pairs;
if (open my $fh, "<", $ENV{MIRROR_SCRUB}) {
while (<$fh>) {
next if /^\s*(?:#|$)/;
my ($from, $to) = split " ";
defined $to or die "MIRROR_SCRUB: no replacement on line $.\n";
push @pairs, [qr/(?<![\w.-])\Q$from\E/, $to];
}
}
my $in_commit = 0;
while (my $line = <STDIN>) {
if ($line =~ /^commit /) {
$in_commit = 1;
} elsif ($line =~ /^data (\d+)$/) {
my $len = $1;
read(STDIN, my $data, $len) == $len or die "short data block\n";
if ($in_commit
&& $data =~ s/^Co-Authored-By:[^\n]*(?:claude|anthropic\.com)[^\n]*(?:\n|\z)//mgi) {
$data =~ s/\s+\z/\n/;
}
$data =~ s/$_->[0]/$_->[1]/g for @pairs;
$in_commit = 0;
print "data ", length($data), "\n", $data;
next;
}
print $line;
}
'
}
# Fails if a MIRROR_SCRUB value survives in a tree about to be published.
check_scrubbed() {
[ -f "$MIRROR_SCRUB" ] || return 0
local from to
while read -r from to; do
case "$from" in ''|'#'*) continue ;; esac
if git -C "$1" grep -qIF -- "$from"; then
die "a MIRROR_SCRUB value is still in the tree to publish"
fi
done < "$MIRROR_SCRUB"
}
# The rewritten commit a forge commit became, from the two runs' marks.
rewritten() {
local mark
mark="$(awk -v c="$1" '$2 == c { print $1; exit }' "$STATE/source.marks")"
[ -n "$mark" ] || die "no rewritten commit for $1"
awk -v m="$mark" '$1 == m { print $2; exit }' "$STATE/clean.marks"
}
WORK=""
STATE=""
cleanup() {
[ -n "$WORK" ] || return 0
git -C "$STATE" worktree remove --force "$WORK" 2>/dev/null || true
rm -rf "$WORK"
}
# Rewrites a checkout for GitHub. Each rewrite is checked, so a README that
# drifted away from what this expects stops the mirror instead of publishing
# the forge's version. Running it twice changes nothing, so a clone of the
# mirror still passes test/mirror.bats.
transform() {
local dir="$1"
local readme="$dir/README.md"
# perl -i would write a symlink's target back as the README, publishing it.
[ -f "$readme" ] && [ ! -L "$readme" ] || die "README.md is missing or a symlink in $dir"
# Repository links and clone URLs move to the org on GitHub, in code as
# well as docs; issue and pull request links stay on the forge, which is
# the only place those exist. Gitea's /src/branch/ is GitHub's /blob/, and
# its /raw/branch/ GitHub's /raw/.
# Exit 1 from git grep is no match, which a repository may well have. The
# paths come from the forge, so -- keeps one from being read as a perl option.
(cd "$dir" && { git grep -lzIF 'gitbox.branderson.io/dejima/' || [ $? -eq 1 ]; } | xargs -0r perl -pi -e '
s{https://gitbox\.branderson\.io/dejima/([\w.-]++)(?!/(?:issues|pulls)/)}{https://github.com/$ENV{MIRROR_ORG}/$1}g;
s{(https://github\.com/\Q$ENV{MIRROR_ORG}\E/[\w.-]+)/src/branch/}{$1/blob/}g;
s{(https://github\.com/\Q$ENV{MIRROR_ORG}\E/[\w.-]+)/raw/branch/}{$1/raw/}g;
' --)
# GitHub follows the reader's light/dark setting through <picture>, which
# the forge cannot; see the comment above the tag in README.md.
if [ -f "$dir/assets/dejima-wordmark-lightmode.png" ]; then
perl -0pi -e '
s{<img src="assets/dejima-wordmark-darkmode\.png" alt="dejima" height="72">}
{<picture><source media="(prefers-color-scheme: dark)" srcset="assets/dejima-wordmark-darkmode.png"><img src="assets/dejima-wordmark-lightmode.png" alt="dejima" height="72"></picture>}
' "$readme"
grep -qF 'darkmode.png"><img src="assets/dejima-wordmark-lightmode.png"' "$readme" \
|| die "README.md: the wordmark tag was not found"
fi
# The first thing a visitor to a mirror does is open a pull request. Under
# the rule after the wordmark, or under the title where there is none.
grep -q '^> \*\*This is a read-only mirror' "$readme" || perl -0pi -e '
s{^(---|# [^\n]*)\n}{$1\n\n> **This is a read-only mirror.** dejima is developed on a private forge,\n> and issues and pull requests are not accepted here. See\n> [CONTRIBUTING.md](https://github.com/$ENV{MIRROR_ORG}/dejima/blob/main/CONTRIBUTING.md).\n}m
' "$readme"
grep -q '^> \*\*This is a read-only mirror' "$readme" \
|| die "README.md: no --- line or title to put the mirror notice under"
}
# Issues, pull requests and Actions off. Settings rather than content, so a
# sync does not reapply them; run once after creating the repositories.
settings() {
command -v gh >/dev/null || die "--settings needs the gh CLI"
local repo
for repo in $MIRROR_REPOS; do
gh api -X PATCH "repos/$MIRROR_ORG/$repo" --silent \
-F has_issues=false -F has_pull_requests=false -F has_projects=false \
-F has_wiki=false -F has_discussions=false
gh api -X PUT "repos/$MIRROR_ORG/$repo/actions/permissions" --silent -F enabled=false
done
}
sync_repo() {
NAME="$1"
STATE="$MIRROR_STATE/$NAME.git"
local source="$MIRROR_SOURCE/$NAME" target="$MIRROR_TARGET/$NAME"
local git=(git -C "$STATE")
trap cleanup EXIT
[ -d "$STATE" ] || git init -q --bare "$STATE"
"${git[@]}" fetch -q --no-tags --prune "$source" \
'+refs/heads/main:refs/source/main' '+refs/tags/*:refs/source/tags/*'
local src clean prev=""
src="$("${git[@]}" rev-parse refs/source/main)"
# Rebuilt from the whole history each run.
"${git[@]}" fast-export --signed-tags=strip --reencode=yes \
--export-marks="$STATE/source.marks" \
--refspec refs/source/main:refs/clean/main refs/source/main \
| rewrite_history \
| "${git[@]}" fast-import --quiet --force --export-marks="$STATE/clean.marks"
clean="$("${git[@]}" rev-parse refs/clean/main)"
# An absent main (exit 2) is a first run; anything else is a real failure.
local rc=0
git ls-remote -q --exit-code "$target" refs/heads/main >/dev/null || rc=$?
if [ "$rc" -eq 0 ]; then
"${git[@]}" fetch -q --no-tags "$target" '+refs/heads/main:refs/mirror/main'
prev="$("${git[@]}" rev-parse refs/mirror/main)"
elif [ "$rc" -ne 2 ]; then
die "cannot read $target"
fi
local force=""
if [ -n "$prev" ] && ! "${git[@]}" merge-base --is-ancestor "$prev^1" "$clean"; then
[ -n "$RESET" ] \
|| die "GitHub's main is not built on the history this run publishes; --reset force-pushes over it"
prev=""
force=+
fi
WORK="$(mktemp -d)"
"${git[@]}" worktree add -q --detach "$WORK" "$clean"
transform "$WORK"
check_scrubbed "$WORK"
git -C "$WORK" add -A
local tree
tree="$(git -C "$WORK" write-tree)"
if [ -n "$prev" ] && [ "$("${git[@]}" rev-parse "$prev^1")" = "$clean" ] \
&& [ "$("${git[@]}" rev-parse "$prev^{tree}")" = "$tree" ]; then
say "already at ${src:0:12}"
else
# The forge commit's committer and date, so the same input always
# makes the same commit.
local name email date commit
IFS=$'\t' read -r name email date < <("${git[@]}" log -1 --format='%cn%x09%ce%x09%cI' "$src")
commit="$(GIT_AUTHOR_NAME="$name" GIT_AUTHOR_EMAIL="$email" GIT_AUTHOR_DATE="$date" \
GIT_COMMITTER_NAME="$name" GIT_COMMITTER_EMAIL="$email" GIT_COMMITTER_DATE="$date" \
"${git[@]}" commit-tree "$tree" -p "$clean" ${prev:+-p "$prev"} \
-m "Mirror ${src:0:12} for GitHub" \
-m "Forge links, the README header and commit trailers rewritten by dejima's mirror/github-mirror.sh.")"
"${git[@]}" update-ref refs/mirror/next "$commit"
[ -n "$DRY_RUN" ] || "${git[@]}" push -q "$target" "$force$commit:refs/heads/main"
say "main is ${src:0:12} as ${commit:0:12}"
fi
# Only tags on main: one on another branch would publish unreleased work.
local ref
local refspecs=()
while IFS= read -r ref; do
if "${git[@]}" merge-base --is-ancestor "$ref" "$src"; then
local at
at="$(rewritten "$("${git[@]}" rev-parse "$ref^{commit}")")"
refspecs+=("$force$at:refs/tags/${ref#refs/source/tags/}")
say "tag ${ref#refs/source/tags/} at ${at:0:12}"
fi
done < <("${git[@]}" for-each-ref --format='%(refname)' refs/source/tags/)
[ "${#refspecs[@]}" -eq 0 ] || [ -n "$DRY_RUN" ] \
|| "${git[@]}" push -q "$target" "${refspecs[@]}"
}
# Each repository in a subshell of its own, so one that fails does not keep
# the rest from syncing. Not `( ... ) || ...`, which turns off set -e inside.
sync() {
[ -n "${MIRROR_SOURCE:-}" ] || die "MIRROR_SOURCE is not set"
local repo rc failed=""
for repo in $MIRROR_REPOS; do
set +e
(set -e; sync_repo "$repo")
rc=$?
set -e
[ "$rc" -eq 0 ] || failed="$failed $repo"
done
[ -z "$failed" ] || die "failed:$failed"
}
DRY_RUN=""
RESET=""
case "${1:-}" in
--settings) settings; exit ;;
--transform) [ -n "${2:-}" ] || die "--transform needs a directory"; transform "$2"; exit ;;
esac
while [ "$#" -gt 0 ]; do
case "$1" in
--dry-run) DRY_RUN=1 ;;
--reset) RESET=1 ;;
*) sed -n '5,9p' "$0" >&2; exit 2 ;;
esac
shift
done
sync
+4 -10
View File
@@ -77,16 +77,10 @@ PY
[ -e /var/run/docker.sock ]
}
@test "the two runners are not the same runner" {
# Whatever else is true, exactly one of them gets the daemon. If both jobs
# report the same answer here, the labels are not routing to two different
# runners and the tiering exists only on paper.
if [ "$DEJIMA_CI_TIER" = confined ]; then
[ ! -e /var/run/docker.sock ]
else
[ -e /var/run/docker.sock ]
fi
}
# The two directions above are each one job's answer: the confined runner must
# not have the docker socket and the privileged one must. That the two jobs land
# on DIFFERENT runners does not follow from either, and a suite cannot see it -
# it runs in one job. Comparing the two jobs' results is the workflow's to do.
# --- egress, for the tier the rules actually apply to ------------------------
#
+425 -20
View File
@@ -569,6 +569,54 @@ write_defaults() { printf '%s\n' "$@" > "$CONFIG_DIR/defaults.conf"; }
[[ "$output" == *"nonesuch"* ]]
}
@test "the isolated mode has no config key, in any plausible spelling" {
# --isolated is a flag and nothing else, on purpose: it changes what the
# session IS rather than how much it may do, and a file that decided it
# would make the next launch's answer to "where is my work" depend on a
# line nobody reread. The schema is what holds that - an unknown key is
# fatal, so every spelling someone might reach for has to stay unknown.
local key
for key in isolated isolate ephemeral no_workspace; do
write_config '[core]' "$key = on"
run lib config_validate
[ "$status" -ne 0 ] \
|| { echo "[core] $key was accepted, so the mode is settable from a file"; return 1; }
[[ "$output" == *"unknown key"* ]] || { echo "$output"; return 1; }
done
return 0
}
@test "the session name has no config key either, and refuses what would break a row" {
# Same reasoning as the mode above, from the other direction: a name every
# launch of a project shares is the ambiguity --name exists to remove, so
# a file is the one place it cannot usefully live.
local key
for key in name session_name label; do
write_config '[core]' "$key = whatever"
run lib config_validate
[ "$status" -ne 0 ] \
|| { echo "[core] $key was accepted"; return 1; }
[[ "$output" == *"unknown key"* ]] || { echo "$output"; return 1; }
done
# The value lands in a flat key=value registry entry that dejima-attach
# parses last-match-wins, and in a tab-separated listing. A newline or a
# tab is refused rather than sanitized, like the workspace path beside it.
local bad
for bad in $'two\nlines' $'two\tcolumns' $'bell\a'; do
run "$REPO_ROOT/bin/dejima" --name "$bad" .
[ "$status" -ne 0 ] || { echo "accepted a control character"; return 1; }
[[ "$output" == *"control character"* ]] || { echo "$output"; return 1; }
done
run "$REPO_ROOT/bin/dejima" --name "$(printf 'x%.0s' {1..33})" .
[ "$status" -ne 0 ] || { echo "accepted 33 characters"; return 1; }
[[ "$output" == *"32 is the most"* ]] || { echo "$output"; return 1; }
# And the boundary itself is accepted, or the cap is off by one: this gets
# past the name check and fails on something else further in.
run "$REPO_ROOT/bin/dejima" --name "$(printf 'x%.0s' {1..32})" .
[[ "$output" != *"32 is the most"* ]] || { echo "32 characters was refused"; return 1; }
}
@test "an invalid value is fatal and names the key" {
write_config '[audit]' 'keep = 0'
run lib config_validate
@@ -791,7 +839,7 @@ write_defaults() { printf '%s\n' "$@" > "$CONFIG_DIR/defaults.conf"; }
_identity_fixture() {
ID_DIR="$(mktemp -d)"
: > "$ID_DIR/default-key"; : > "$ID_DIR/work-key"; : > "$ID_DIR/oss-key"
printf "k\n" | tee "$ID_DIR/default-key" "$ID_DIR/work-key" > "$ID_DIR/oss-key"
: > "$ID_DIR/default-token"; : > "$ID_DIR/work-token"
write_config '[core]' 'forge = gitea' "ssh_key = $ID_DIR/default-key" \
'[forge.gitea]' 'ssh_host = git.example' 'api = https://git.example/api/v1' \
@@ -1009,7 +1057,7 @@ _identity_fixture() {
mkdir -p "$FAKE_HOME/.claude"
echo '{}' > "$FAKE_HOME/.claude/settings.json"
key="$FAKE_HOME/sandbox-key"
: > "$key"
printf "not-a-real-key\n" > "$key"
chmod 600 "$key"
# forge = none, workflow = local-only: provider_validate_selection runs
# ahead of this check, and the default gitea/worktree-pr pair is refused
@@ -1285,7 +1333,7 @@ _identity_fixture() {
mkdir -p "$FAKE_HOME/.claude"
echo '{}' > "$FAKE_HOME/.claude/settings.json"
key="$FAKE_HOME/sandbox-key"
: > "$key"
printf "not-a-real-key\n" > "$key"
chmod 600 "$key"
write_config '[core]' "ssh_key = $key" 'forge = none' 'workflow = local-only'
workspace="$(mktemp -d)"
@@ -1315,7 +1363,7 @@ EOF
mkdir -p "$FAKE_HOME/.claude"
echo '{}' > "$FAKE_HOME/.claude/settings.json"
key="$FAKE_HOME/sandbox-key"
: > "$key"
printf "not-a-real-key\n" > "$key"
chmod 600 "$key"
write_config '[core]' "ssh_key = $key" \
'[forge.gitea]' 'ssh_host = git.example' 'api = https://api.example/v1' \
@@ -1385,12 +1433,14 @@ EOF
@test "the github forge with no push_token_file leaves the read-only GH_TOKEN alone at launch" {
# forge_env_exports must not emit `GH_TOKEN=` (empty) when
# push_token_file is unset: `-e GH_TOKEN=` on `docker
# compose run` overrides docker-compose.yml's own `environment: GH_TOKEN:
# ${GH_TOKEN:-}` entry - the one carrying the independent read-only
# [github] token_file value every session is supposed to get regardless
# of forge. Asserted on the actual argument list handed to `docker`, not
# on a warning, so this catches the container-facing symptom directly.
# push_token_file is unset: `-e GH_TOKEN=` on `docker compose run`
# overrides docker-compose.yml's own `environment: GH_TOKEN:` entry - the
# one carrying the independent read-only [github] token_file value every
# session is supposed to get regardless of forge. (That entry reads
# bin/dejima's SANDBOX_GH_TOKEN, which is the same value except for an
# isolated launch; the override is what matters here either way.)
# Asserted on the actual argument list handed to `docker`, not on a
# warning, so this catches the container-facing symptom directly.
FAKE_HOME="$(mktemp -d)"
mkdir -p "$FAKE_HOME/.claude"
echo '{}' > "$FAKE_HOME/.claude/settings.json"
@@ -1449,7 +1499,7 @@ EOF
# dedicated key file is the headless form of that, and keeps this test
# independent of whether the host running it has an agent.
local key="$FAKE_HOME/sandbox-key"
: > "$key"
printf "not-a-real-key\n" > "$key"
chmod 600 "$key"
write_config '[core]' 'forge = github' 'workflow = worktree-pr' \
"ssh_key = $key" \
@@ -1506,7 +1556,7 @@ EOF
mkdir -p "$FAKE_HOME/.claude"
echo '{}' > "$FAKE_HOME/.claude/settings.json"
local key="$FAKE_HOME/sandbox-key"
: > "$key"
printf "not-a-real-key\n" > "$key"
chmod 600 "$key"
write_config '[core]' 'forge = gitea' 'workflow = branch-push' \
"ssh_key = $key" \
@@ -1530,7 +1580,7 @@ EOF
mkdir -p "$FAKE_HOME/.claude"
echo '{}' > "$FAKE_HOME/.claude/settings.json"
key="$FAKE_HOME/sandbox-key"
: > "$key"
printf "not-a-real-key\n" > "$key"
chmod 600 "$key"
write_config '[core]' "ssh_key = $key" 'forge = none' 'workflow = local-only' \
'[logging]' 'loki_url = https://loki.example.invalid'
@@ -1707,7 +1757,7 @@ _rw_setup() {
mkdir -p "$RW_HOME/.claude"
echo '{}' > "$RW_HOME/.claude/settings.json"
RW_KEY="$RW_HOME/sandbox-key"
: > "$RW_KEY"
printf "not-a-real-key\n" > "$RW_KEY"
chmod 600 "$RW_KEY"
}
@@ -1831,6 +1881,139 @@ _kill_agent() { [ -n "${RW_AGENT_PID:-}" ] && kill "$RW_AGENT_PID" 2>/dev/null;
rm -rf "$workspace"
}
# A launch that really reaches `docker compose`, with a pushing workflow, so the
# rule that blanks the agent socket for a workflow that never pushes is not the
# one doing the work. Its own fixture rather than _rw_setup's: this needs a
# forge configured, and stdin closed because a full launch opens /dev/tty,
# which under bats is not the terminal it expects.
#
# The stub records its own environment rather than its arguments, because the
# volume line resolves ${SSH_AUTH_SOCK:-/dev/null} inside docker-compose - so
# the environment handed to it IS whether the agent is mounted. Into a file,
# because the launch prints plenty of its own and the assertion is about one
# value.
#
# Leaves AGENT_LOG and AGENT_OUT for the caller to read on failure.
_agent_launch() { # <ssh_key or "">
local key="$1" h c s w
# The suite exports DEJIMA_CONFIG_DIR, which is where a launch under it
# reads its config from - a config written under $HOME would not be found.
h="$(mktemp -d)"; c="$CONFIG_DIR"
printf 'tok\n' > "$c/token"
{
printf '[core]\nharness = claude-code\nfirewall = on\nupdate_check = off\n'
printf 'forge = gitea\nworkflow = worktree-pr\n'
printf '[harness.claude-code]\nbackend = anthropic\n'
printf '[forge.gitea]\nssh_host = git.example\napi = https://api.example\n'
# The forge credential moves under the identity when there is one: an
# identity that leaves one behind is a session acting under whichever
# account owns it, which the launch refuses outright.
if [ -n "$key" ]; then
printf '[identity.default]\nssh_key = %s\ntoken_file = %s/token\n' "$key" "$c"
else
printf 'token_file = %s/token\n' "$c"
fi
} > "$c/config"
AGENT_LOG="$(mktemp)"
s="$(mktemp -d)"
cat > "$s/docker" <<'STUB'
#!/bin/sh
printf 'SSH_AUTH_SOCK=[%s]\n' "${SSH_AUTH_SOCK-unset}" >> "$AGENT_SOCK_LOG"
exit 0
STUB
chmod +x "$s/docker"
w="$(mktemp -d)"; git -C "$w" init -q
# `|| true` because a launch here ends however the stubbed session ended,
# and this is not a test about its exit status.
AGENT_OUT="$(HOME="$h" PATH="$s:$PATH" SSH_AUTH_SOCK="$AGENT_SOCK" \
AGENT_SOCK_LOG="$AGENT_LOG" \
bash "$REPO_ROOT/bin/dejima" "$w" </dev/null 2>&1 || true)"
rm -rf "$h" "$s" "$w"
}
# A real agent, because bin/dejima tests the socket with `[ -S ]` and refuses a
# path that is not one.
_real_agent() {
eval "$(ssh-agent -s)" >/dev/null
AGENT_SOCK="$SSH_AUTH_SOCK"
AGENT_PID="$SSH_AGENT_PID"
}
@test "a configured ssh_key means the operator's agent is not in the container at all" {
# entrypoint.sh loads that key into an agent of its own and points
# SSH_AUTH_SOCK at it, so the forwarded one was already unused - but it
# stayed mounted at the path docker-compose.yml names, where
# `SSH_AUTH_SOCK=/ssh-agent ssh ...` signs with the operator's personal
# identities. Choosing a dedicated key has to mean the personal agent is
# not in the container, not that it is merely not the default.
local key
_real_agent
key="$(mktemp)"; printf 'not-a-real-key\n' > "$key"; chmod 600 "$key"
_agent_launch "$key"
kill "$AGENT_PID" 2>/dev/null || true
rm -f "$key"
[ -s "$AGENT_LOG" ] \
|| { echo "the launch never reached docker:"; echo "$AGENT_OUT" | tail -15; return 1; }
[ "$(sort -u "$AGENT_LOG")" = "SSH_AUTH_SOCK=[]" ] \
|| { echo "the forwarded agent still reached compose:"; sort -u "$AGENT_LOG"; return 1; }
}
@test "without a configured ssh_key the forwarded agent still reaches the container" {
# The positive control for the test above, and the reason it means
# anything: without it, a launch that simply never set the variable would
# satisfy that assertion too.
_real_agent
_agent_launch ""
local want="SSH_AUTH_SOCK=[$AGENT_SOCK]"
kill "$AGENT_PID" 2>/dev/null || true
[ -s "$AGENT_LOG" ] \
|| { echo "the launch never reached docker:"; echo "$AGENT_OUT" | tail -15; return 1; }
[ "$(sort -u "$AGENT_LOG")" = "$want" ] \
|| { echo "the forwarded agent did not reach compose:"; sort -u "$AGENT_LOG"; return 1; }
}
@test "an empty ssh_key file is not a key, and a pushing launch says so" {
# It passes the is-a-file check and loads nothing in the container, so it
# cannot be what stops the forwarded agent being mounted. Treated as no key
# at all: harmless for a workflow that never pushes - which is what most of
# this suite's fixtures are, with an empty file standing in for a path -
# and named for one that does.
# A pushing workflow with an empty key and no agent: the one case where it
# costs anything, and the message names the file rather than only saying no
# key is set. The with-an-agent direction is its own test above.
AGENT_SOCK=""
local key; key="$(mktemp)"; chmod 600 "$key"
_agent_launch "$key"
rm -f "$key"
[[ "$AGENT_OUT" == *"names an empty file"* ]] \
|| { echo "an empty key went unmentioned:"; echo "$AGENT_OUT" | tail -12; return 1; }
[ ! -s "$AGENT_LOG" ] \
|| { echo "the launch reached docker with no credential at all"; return 1; }
}
@test "an empty ssh_key file with an agent running says the agent is still signing" {
# The configuration nothing else would tell an operator about: the key is
# configured, the launch starts, and pushes work - because the personal
# agent is doing them, which is what a dedicated key is chosen to avoid.
# No key is loaded, so the withdrawal above does not fire.
local key
_real_agent
key="$(mktemp)"; chmod 600 "$key"
_agent_launch "$key"
kill "$AGENT_PID" 2>/dev/null || true
rm -f "$key"
[[ "$AGENT_OUT" == *"names an empty file"* ]] \
|| { echo "an empty key went unmentioned:"; echo "$AGENT_OUT" | tail -12; return 1; }
[[ "$AGENT_OUT" == *"will sign for this session"* ]] \
|| { echo "the consequence went unnamed:"; echo "$AGENT_OUT" | tail -12; return 1; }
# And it is not a false alarm: the agent really does reach compose here.
[ "$(sort -u "$AGENT_LOG")" = "SSH_AUTH_SOCK=[$AGENT_SOCK]" ] \
|| { echo "the warning does not match what reached compose:"; sort -u "$AGENT_LOG"; return 1; }
}
# The positive control for the test above: with no key configured the
# forwarded agent really is the only way to push, and an empty one is worth
# saying so before it fails deep into a session.
@@ -3469,7 +3652,7 @@ EOF
# 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"
ID_DIR="$(mktemp -d)"; printf "k\n" | tee "$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" \
@@ -3484,7 +3667,7 @@ EOF
@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"
ID_DIR="$(mktemp -d)"; printf "k\n" | tee "$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
@@ -3497,7 +3680,7 @@ EOF
@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"
ID_DIR="$(mktemp -d)"; printf "not-a-real-key\n" > "$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; }
@@ -3508,7 +3691,7 @@ EOF
}
@test "a named identity still beats [identity.default]" {
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/default-key"; : > "$ID_DIR/bot-key"
ID_DIR="$(mktemp -d)"; printf "not-a-real-key\n" > "$ID_DIR/default-key"; printf "not-a-real-key\n" > "$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"
@@ -3523,7 +3706,7 @@ EOF
# 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"
ID_DIR="$(mktemp -d)"; printf "not-a-real-key\n" > "$ID_DIR/old-key"; printf "not-a-real-key\n" > "$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
@@ -3544,7 +3727,7 @@ EOF
# 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"
ID_DIR="$(mktemp -d)"; printf "not-a-real-key\n" > "$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
@@ -3783,6 +3966,188 @@ EOF
rm -rf "$workspace"
}
@test "the staged ~/.claude.json carries the launching project's entry and no other" {
# The projects map keys per-project state by absolute path, so a verbatim
# copy tells a session the name of every project on the host, a sample of
# each one's filenames (exampleFiles) and what each has cost. Narrowed for
# the reason #104 narrowed the projects MOUNT: the whole map is every
# project's (#380).
_rw_setup
workspace="$(mktemp -d)"
# A worktree under the project, because a session's cwd routinely is one and
# Claude Code keys that path separately - and a sibling whose path merely
# begins with the project's, which is the comparison a bare prefix test
# gets wrong.
#
# The other project's path is deliberately dull. Calling it anything
# credential-shaped - "secret" was the first attempt - makes the staged
# copy trip dejima's own credential scan, so the launch refuses and this
# test passes on a refusal rather than on the narrowing it is named for.
mkdir -p "$workspace/.worktrees/feature"
cat > "$RW_HOME/.claude.json" <<JSON
{"primaryApiKey":"sk-ant-api03-REAL-KEY-FROM-THE-HOST",
"projects":{
"$workspace":{"exampleFiles":["mine.py"],"lastCost":1},
"$workspace/.worktrees/feature":{"exampleFiles":["branch.py"]},
"${workspace}-sibling":{"exampleFiles":["sibling.py"]},
"/home/someone/else/their-project":{"exampleFiles":["theirs.py"],"lastCost":99}},
"numStartups":7}
JSON
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'harness = claude-code' '[harness.claude-code]' 'backend = anthropic'
local stub log staged
stub="$(mktemp -d)"; log="$(mktemp)"; staged="$(mktemp)"
cat > "$stub/docker" <<EOF
#!/bin/sh
[ -n "\$SANDBOX_CLAUDE_JSON_FILE" ] && cp "\$SANDBOX_CLAUDE_JSON_FILE" "$staged" 2>/dev/null
exit 0
EOF
chmod +x "$stub/docker"
HOME="$RW_HOME" PATH="$stub:$PATH" SSH_AUTH_SOCK= \
run "$REPO_ROOT/bin/dejima" "$workspace"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ -s "$staged" ] || { echo "no staged ~/.claude.json was mounted"; return 1; }
# The project and anything under it.
[ "$(jq -r --arg p "$workspace" '.projects[$p].lastCost' "$staged")" = 1 ] \
|| { echo "the launching project's own entry is gone"; return 1; }
[ "$(jq -r --arg p "$workspace/.worktrees/feature" '.projects[$p].exampleFiles[0]' "$staged")" = branch.py ] \
|| { echo "a worktree inside the project was dropped"; return 1; }
# And nothing else, including the sibling a bare prefix test would keep.
[ "$(jq -r --arg p "${workspace}-sibling" '.projects | has($p)' "$staged")" = false ] \
|| { echo "a sibling path sharing the project's prefix was kept"; return 1; }
[ "$(jq -r '.projects | has("/home/someone/else/their-project")' "$staged")" = false ] \
|| { echo "another project reached the container"; return 1; }
[ "$(jq -r '.projects | length' "$staged")" = 2 ] \
|| { echo "wrong number of entries: $(jq -c '.projects | keys' "$staged")"; return 1; }
grep -q 'theirs.py' "$staged" \
&& { echo "another project's filenames reached the container"; return 1; }
# The rest of the file is why the copy exists, and the credential still goes.
[ "$(jq -r '.numStartups' "$staged")" = 7 ] || { echo "lost app state"; return 1; }
[ "$(jq -r '.primaryApiKey // "absent"' "$staged")" = absent ] \
|| { echo "the console API key reached the container"; return 1; }
rm -rf "$workspace" "$stub" "$log" "$staged"
}
@test "the proxy-off re-seed narrows the projects map too" {
# With the proxy off for a backend whose credential IS ~/.claude.json,
# the staged copy is re-seeded so the key survives - and that was a plain
# `cp`, which put every other project's entry back. Worse, it ran after
# the credential scan had read the narrowed copy, so nothing ever looked
# at what came back.
_rw_setup
workspace="$(mktemp -d)"
cat > "$RW_HOME/.claude.json" <<JSON
{"primaryApiKey":"sk-ant-api03-REAL-KEY-FROM-THE-HOST",
"projects":{
"$workspace":{"exampleFiles":["mine.py"],"lastCost":1},
"/home/someone/else/their-project":{"exampleFiles":["theirs.py"]}},
"numStartups":7}
JSON
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'harness = claude-code' '[harness.claude-code]' \
'backend = anthropic-api-key' '[backend.anthropic-api-key]' \
"credential_file = $RW_HOME/.claude.json" 'proxy = off'
local stub staged
stub="$(mktemp -d)"; staged="$(mktemp)"
cat > "$stub/docker" <<EOF
#!/bin/sh
[ -n "\$SANDBOX_CLAUDE_JSON_FILE" ] && cp "\$SANDBOX_CLAUDE_JSON_FILE" "$staged" 2>/dev/null
exit 0
EOF
chmod +x "$stub/docker"
HOME="$RW_HOME" PATH="$stub:$PATH" SSH_AUTH_SOCK= \
run "$REPO_ROOT/bin/dejima" "$workspace"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ -s "$staged" ] || { echo "no staged ~/.claude.json was mounted"; return 1; }
# The credential is the point of this path, so it stays.
[ "$(jq -r '.primaryApiKey' "$staged")" = sk-ant-api03-REAL-KEY-FROM-THE-HOST ] \
|| { echo "proxy = off did not pass the credential through"; return 1; }
# And nothing else came back with it.
[ "$(jq -r '.projects | length' "$staged")" = 1 ] \
|| { echo "the re-seed restored other projects: $(jq -c '.projects|keys' "$staged")"
return 1; }
grep -q 'theirs.py' "$staged" \
&& { echo "another project's filenames reached the container"; return 1; }
[ "$(jq -r '.numStartups' "$staged")" = 7 ] || { echo "lost app state"; return 1; }
rm -rf "$workspace" "$stub" "$staged"
}
@test "a backend needing no credential mounts no credential" {
# P1, driven rather than grepped. The mount sat under
# `elif [ -f ~/.claude/.credentials.json ]`, which fired for anything the
# proxy branch did not take - including a backend whose credential kind
# is none, the self-served arrangement docs/security.md calls the one with
# nothing in the container to steal. The real subscription login was
# mounted read-write into a session that had no use for it.
#
# The grep over the branch's conditions is in static.bats and would still
# pass on a restructuring that tested the same words differently, which is
# why this one reads the argv a launch actually produced.
_rw_setup
workspace="$(mktemp -d)"
# A real-looking credential on the host, which is what there is to leak.
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-REAL","refreshToken":"r","expiresAt":1}}\n' \
> "$RW_HOME/.claude/.credentials.json"
chmod 600 "$RW_HOME/.claude/.credentials.json"
# cline rather than claude-code: a credential kind of none means a backend
# the harness reaches directly, and claude-code speaks no API that has
# one. This is the pairing the release pass reproduced it with.
local cline_state; cline_state="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'harness = cline' '[harness.cline]' \
"state_dir = $cline_state" 'model = m' 'backend = self-hosted' \
'[backend.self-hosted]' 'base_url = http://127.0.0.1:11434'
_rw_launch "$workspace"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qF "$RW_HOME/.claude/.credentials.json:$RW_HOME/.claude/.credentials.json" \
"$docker_log" \
&& { echo "a self-served backend was handed the real credential"; return 1; }
rm -rf "$cline_state"
# And the arrangement the mount IS for still gets it: this backend's
# credential is that file, and the proxy is off for it.
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'harness = claude-code' '[harness.claude-code]' \
'backend = anthropic' '[backend.anthropic]' 'proxy = off'
_rw_launch "$workspace"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qF "$RW_HOME/.claude/.credentials.json:$RW_HOME/.claude/.credentials.json" \
"$docker_log" \
|| { echo "proxy = off no longer mounts the credential it is for"; return 1; }
rm -rf "$workspace"
}
@test "a projects key that is not a map does not refuse the launch" {
# Narrowing a `projects` that holds a string would error, and the staging
# treats a jq failure as fatal - so an odd but parseable file would become
# a launch that will not start, over a key with nothing in it to narrow.
_rw_setup
workspace="$(mktemp -d)"
printf '{"projects":"not-a-map","numStartups":3}\n' > "$RW_HOME/.claude.json"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'harness = claude-code' '[harness.claude-code]' 'backend = anthropic'
local stub staged
stub="$(mktemp -d)"; staged="$(mktemp)"
cat > "$stub/docker" <<EOF
#!/bin/sh
[ -n "\$SANDBOX_CLAUDE_JSON_FILE" ] && cp "\$SANDBOX_CLAUDE_JSON_FILE" "$staged" 2>/dev/null
exit 0
EOF
chmod +x "$stub/docker"
HOME="$RW_HOME" PATH="$stub:$PATH" SSH_AUTH_SOCK= \
run "$REPO_ROOT/bin/dejima" "$workspace"
[ "$status" -eq 0 ] || { echo "an odd projects key refused the launch: $output"; return 1; }
[ "$(jq -r '.numStartups' "$staged")" = 3 ] || { echo "the copy lost its state"; return 1; }
rm -rf "$workspace" "$stub" "$staged"
}
@test "a console API key in ~/.claude.json does not reach the container" {
# Found on a real desktop launch. A Claude Code signed in with a console
# API token keeps the key as a top-level primaryApiKey in ~/.claude.json,
@@ -4109,6 +4474,46 @@ _scan_staged_has_token() {
rm -rf "$workspace" "$SCAN_STUB" "$SCAN_STAGED"
}
@test "a scrub finds the value again whatever the key is spelled like" {
# Deleting by document order made each later index stale: the second secret
# survived and an innocent value went instead. The staged file is mounted
# into the container, so the operator answers "keep it out" and it goes in.
#
# Driven against the program's own functions rather than a reimplementation
# of the ordering, which is the thing under test.
local scan="$REPO_ROOT/bin/dejima-credential-scan"
run python3 - "$scan" <<'PYEOF'
import json, sys
mod = {}
exec(compile(open(sys.argv[1]).read(), "scan", "exec"), mod)
doc = {"mcpServers": {"x": {"args": ["sk-SECRET-ONE-aaaaaaaaaaaaaaaaaaaa",
"sk-SECRET-TWO-bbbbbbbbbbbbbbbbbbbb",
"--harmless-flag"]}}}
def scrub(doc, want):
pairs = list(mod["walk_json"](json.loads(json.dumps(doc))))
drop = [parts for p, v, parts in pairs if p in want]
out = json.loads(json.dumps(doc))
mod["delete_json_components"](out, drop)
return out
print(json.dumps(scrub(doc, {"mcpServers.x.args[0]",
"mcpServers.x.args[1]"})["mcpServers"]["x"]["args"]))
# A key that spells an index, and a key carrying a dot: both used to be
# re-parsed as traversals, deleting a bystander or raising out of the scrub.
print(json.dumps(scrub({"x[0]": "sk-SECRET-key", "x": ["innocent"]}, {"x[0]"})))
print(json.dumps(scrub({"a.b": "sk-SECRET-dotted"}, {"a.b"})))
PYEOF
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" != *SECRET* ]] \
|| { echo "a secret survived the scrub: $output"; return 1; }
[[ "$output" == *harmless-flag* ]] \
|| { echo "the scrub removed a value that was not a secret: $output"; return 1; }
# No line raised: a KeyError here aborts the scrub and bin/dejima then
# copies the file in unscrubbed.
[ "${#lines[@]}" -eq 3 ] \
|| { echo "the scrub did not complete for every shape: $output"; return 1; }
}
@test "a standing scrub written by hand is honoured, and holds no value" {
# Not offered at the prompt, but available to someone who has read what it
# does - and it is the only way a scrubbed field survives an unattended
+9 -3
View File
@@ -21,6 +21,9 @@ import select
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import loadscale # noqa: E402
# The default is the credential scan's prompt. --marker takes another, so a
# test for a different prompt does not need a second copy of this.
MARKER = "[s/a/o/r]"
@@ -33,9 +36,12 @@ AMBIENT = "Press Enter to continue"
# An IDLE timeout: how long to wait with no output before giving up. Generous
# because the thing it guards against is a command that never finishes, and
# any finite bound catches that - while a tight one just times the machine.
# Sixty seconds was enough on an idle box and not enough under the full gate,
# where every suite runs at once (#295).
TIMEOUT = 240.0
#
# Multiplied by how oversubscribed the run asked the machine to be, rather than
# raised again the next time it is not enough: this one went from sixty seconds
# to this because sixty was ample on an idle box and expired under the full
# gate, which is the shape of guess loadscale exists to stop (#295).
TIMEOUT = loadscale.deadline(240.0)
def main():
+10 -4
View File
@@ -16,8 +16,10 @@ import json
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlsplit
STATE = {"oauth_hits": 0, "oauth_last": {}, "returned_401": False, "oauth_reject": False}
STATE = {"oauth_hits": 0, "oauth_last": {}, "returned_401": False, "oauth_reject": False,
"oauth_reject_say": None}
LOCK = threading.Lock()
@@ -50,12 +52,15 @@ class Handler(BaseHTTPRequestHandler):
}).encode())
def _handle(self):
if self.path == "/oauth-reject":
if self.path.startswith("/oauth-reject"):
# Make the next refresh fail the way a finished refresh token does.
# Set rather than always-on, so the ordinary refresh tests keep the
# same endpoint.
# same endpoint. ?say= replaces the error_description, for tests
# about upstream-chosen text landing in the proxy's log.
with LOCK:
STATE["oauth_reject"] = True
STATE["oauth_reject_say"] = \
parse_qs(urlsplit(self.path).query).get("say", [None])[0]
self._send(200, b"ok", "text/plain")
elif self.path == "/v1/oauth/token":
req = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))))
@@ -63,11 +68,12 @@ class Handler(BaseHTTPRequestHandler):
STATE["oauth_hits"] += 1
STATE["oauth_last"] = req
reject = STATE.get("oauth_reject")
say = STATE.get("oauth_reject_say")
if reject:
# Verbatim from a real overnight expiry.
self._send(400, json.dumps({
"error": "invalid_grant",
"error_description": "Refresh token expired",
"error_description": say or "Refresh token expired",
}).encode())
return
self._send(200, json.dumps({
+43
View File
@@ -0,0 +1,43 @@
"""How much longer a bounded wait should be given on a loaded machine.
The suites here wait on things that take as long as the machine makes them take
- a pty reaching raw mode, a shipper polling, a prompt being answered - and
each wait has a deadline. A deadline that is right on an idle box is wrong
under the gate, which runs every suite at once, and lengthening it until both
work means guessing a number that is generous in one place and useless in the
other (#295).
So the runner says how much concurrency it asked for, and the deadline is
multiplied by how oversubscribed that makes the machine. Nothing gets shorter
than it is today: the multiplier floors at 1, so an idle run and a run with no
variable set behave exactly as before.
Capped, because this is a multiplier on a timeout: a variable set wrongly, or a
machine reporting one core, would otherwise turn a test that should fail in
seconds into one that hangs for the length of the CI job.
DEJIMA_TEST_CONCURRENCY is how many tests the runner intends to have in flight
at once. test/run-confined.sh sets it to the budget it divided among the
suites; the CI workflow sets it to what it passes bats. Neither is a guess
about load - it is what that runner asked the machine for.
"""
import os
MAX_SCALE = 4.0
def scale():
"""The multiplier, >= 1.0 and <= MAX_SCALE."""
try:
want = int(os.environ.get("DEJIMA_TEST_CONCURRENCY", "0"))
except ValueError:
return 1.0
if want <= 0:
return 1.0
cores = os.cpu_count() or 1
return max(1.0, min(MAX_SCALE, want / float(cores)))
def deadline(seconds):
"""A bounded wait, lengthened for the concurrency this run asked for."""
return seconds * scale()
+11 -3
View File
@@ -25,6 +25,9 @@ import sys
import termios
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from loadscale import deadline # noqa: E402
HELPER = os.path.abspath(sys.argv[1])
SHELL = sys.argv[2] if len(sys.argv) > 2 else "bash"
# Required, not defaulted. The fallback was "/tmp", and the three paths below
@@ -200,7 +203,7 @@ def pump(seconds):
def expect(text, timeout=8.0):
"""Wait for text to appear in output since the last call to forget()."""
end = time.time() + timeout
end = time.time() + deadline(timeout)
while time.time() < end:
if text in "".join(seen):
return True
@@ -238,7 +241,7 @@ def reclaimed():
def await_reclaim(timeout=10.0):
end = time.time() + timeout
end = time.time() + deadline(timeout)
while time.time() < end:
if reclaimed():
return True
@@ -259,8 +262,13 @@ def await_raw(timeout=15.0):
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.
Every bound in this file is multiplied by how oversubscribed the run asked
the machine to be (loadscale), because lengthening them until both an idle
box and the full gate pass means picking a number that is wrong in one of
the two. They are unchanged when nothing says otherwise.
"""
end = time.time() + timeout
end = time.time() + deadline(timeout)
while time.time() < end:
if raw_restored():
return True
+1 -1
View File
@@ -51,7 +51,7 @@ esac
exit 0
EOF
chmod +x "$LAUNCH_DIR/docker"
: > "$LAUNCH_DIR/sandbox-key"
printf "not-a-real-key\n" > "$LAUNCH_DIR/sandbox-key"
chmod 600 "$LAUNCH_DIR/sandbox-key"
PROJ="$(mktemp -d)"
}
+245
View File
@@ -0,0 +1,245 @@
#!/usr/bin/env bats
# mirror/github-mirror.sh: what the GitHub copies are rewritten to, and how
# their main is built. Nothing here pushes; --dry-run stops before the push, which
# is the one line these tests leave to the operator's first sync.
setup() {
REPO_ROOT="$BATS_TEST_DIRNAME/.."
MIRROR="$REPO_ROOT/mirror/github-mirror.sh"
T="$(mktemp -d)"
export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1
export GIT_AUTHOR_NAME=test GIT_AUTHOR_EMAIL=test@example.com \
GIT_COMMITTER_NAME=test GIT_COMMITTER_EMAIL=test@example.com
# The forge: this tree's docs, as one commit on main.
F="$T/forge/dejima"
git init -q -b main "$F"
cp -r "$REPO_ROOT"/*.md "$REPO_ROOT/docs" "$REPO_ROOT/assets" "$F/"
git -C "$F" add -A
git -C "$F" commit -qm base
mkdir "$T/github"
git init -q --bare "$T/github/dejima"
export MIRROR_SOURCE="$T/forge" MIRROR_TARGET="$T/github" MIRROR_STATE="$T/state" \
MIRROR_REPOS=dejima MIRROR_SCRUB="$T/scrub"
unset MIRROR_ORG
}
teardown() {
rm -rf "$T"
}
mirrored() {
git -C "$MIRROR_STATE/dejima.git" rev-parse refs/mirror/next
}
@test "the mirror's README follows the reader's colour scheme" {
"$MIRROR" --transform "$F"
local readme="$F/README.md"
grep -qF '<source media="(prefers-color-scheme: dark)" srcset="assets/dejima-wordmark-darkmode.png">' "$readme"
grep -qF '<img src="assets/dejima-wordmark-lightmode.png" alt="dejima" height="72"></picture>' "$readme"
}
@test "the mirror's README says it takes no issues or pull requests, above the fold" {
"$MIRROR" --transform "$F"
local notice heading
notice="$(grep -n 'This is a read-only mirror' "$F/README.md" | cut -d: -f1)"
heading="$(grep -n '^## ' "$F/README.md" | head -1 | cut -d: -f1)"
[ -n "$notice" ] && [ "$notice" -lt "$heading" ]
grep -q 'issues and pull requests are not accepted here' "$F/README.md"
}
@test "repository links move to GitHub and issue links stay on the forge" {
grep -q 'gitbox.branderson.io/dejima/dejima/\(issues\|pulls\)/' "$F/CHANGELOG.md"
"$MIRROR" --transform "$F"
cd "$F"
run grep -rhoE 'https://gitbox\.branderson\.io/dejima/[^)]*' --include='*.md' .
local link
for link in "${lines[@]}"; do
[[ "$link" =~ ^https://gitbox\.branderson\.io/dejima/dejima/(issues|pulls)/[0-9]+$ ]] \
|| { echo "still on the forge: $link"; return 1; }
done
grep -qF '](https://github.com/dejima-org/dejima)' README.md
grep -qF '(https://github.com/dejima-org/dejima-guard/blob/main/SECURITY.md)' docs/threat-model.md
! grep -rq 'github.com/dejima-org/[^)]*/src/branch/' --include='*.md' .
}
@test "transforming twice changes nothing, so a clone of the mirror passes this suite" {
"$MIRROR" --transform "$F"
cp -r "$F" "$T/once"
"$MIRROR" --transform "$F"
diff -r -x .git "$T/once" "$F"
}
@test "a README the transform no longer recognises stops the mirror" {
sed -i 's/dejima-wordmark-darkmode.png/renamed.png/' "$F/README.md"
run "$MIRROR" --transform "$F"
[ "$status" -ne 0 ]
[[ "$output" == *"wordmark tag was not found"* ]]
}
@test "the mirror commit sits on the forge's main, and the same input makes the same commit" {
run "$MIRROR" --dry-run
[ "$status" -eq 0 ]
local first src
first="$(mirrored)"
src="$(git -C "$F" rev-parse main)"
[ "$(git -C "$MIRROR_STATE/dejima.git" rev-parse "$first^1")" = "$src" ]
run git -C "$MIRROR_STATE/dejima.git" rev-parse -q --verify "$first^2"
[ "$status" -ne 0 ]
MIRROR_STATE="$T/state2" run "$MIRROR" --dry-run
[ "$(git -C "$T/state2/dejima.git" rev-parse refs/mirror/next)" = "$first" ]
}
@test "each sync fast-forwards the previous mirror commit" {
"$MIRROR" --dry-run
local prev
prev="$(mirrored)"
git -C "$MIRROR_TARGET/dejima" fetch -q "$MIRROR_STATE/dejima.git" "$prev:refs/heads/main"
echo change >> "$F/docs/ci.md"
git -C "$F" commit -qam change
"$MIRROR" --dry-run
local next
next="$(mirrored)"
[ "$(git -C "$MIRROR_STATE/dejima.git" rev-parse "$next^1")" = "$(git -C "$F" rev-parse main)" ]
[ "$(git -C "$MIRROR_STATE/dejima.git" rev-parse "$next^2")" = "$prev" ]
git -C "$MIRROR_TARGET/dejima" fetch -q "$MIRROR_STATE/dejima.git" "+$next:refs/heads/main"
run "$MIRROR" --dry-run
[[ "$output" == *"already at"* ]]
}
@test "only tags on main are mirrored" {
git -C "$F" tag released
git -C "$F" checkout -qb development
git -C "$F" commit -q --allow-empty -m unreleased
git -C "$F" tag unreleased
run "$MIRROR" --dry-run
[ "$status" -eq 0 ]
[[ "$output" == *"tag released"* ]]
[[ "$output" != *"tag unreleased"* ]]
}
@test "clone URLs in code move too, and a repo with no wordmark gets the notice under its title" {
# Assembled, so the mirror's copy of this file is not itself rewritten.
local B="$T/forge/dejima-badge" forge="https://gitbox.branderson.io"
git init -q -b main "$B"
printf '# Developed in dejima\n\nSee [dejima](%s/dejima/dejima).\n\n## Use\n\n curl -fLO %s/dejima/dejima-badge/raw/branch/main/dejima-badge.svg\n' "$forge" "$forge" > "$B/README.md"
printf '#!/bin/sh\nURL=${URL:-%s/dejima/dejima.git}\n' "$forge" > "$B/clone"
git -C "$B" add -A
"$MIRROR" --transform "$B"
[ "$(sed -n 3p "$B/README.md")" = '> **This is a read-only mirror.** dejima is developed on a private forge,' ]
grep -qF 'https://github.com/dejima-org/dejima-badge/raw/main/dejima-badge.svg' "$B/README.md"
grep -qF 'URL=${URL:-https://github.com/dejima-org/dejima.git}' "$B/clone"
}
@test "a repository that fails does not stop the others, and the run says which" {
MIRROR_REPOS="missing dejima" run "$MIRROR" --dry-run
[ "$status" -ne 0 ]
[[ "$output" == *"dejima: main is "* ]]
[[ "$output" == *"failed: missing"* ]]
}
@test "a repository with no forge links still gets its notice" {
local B="$T/forge/plain"
git init -q -b main "$B"
printf '# Plain\n\nNothing to rewrite.\n' > "$B/README.md"
git -C "$B" add -A
"$MIRROR" --transform "$B"
grep -q '^> \*\*This is a read-only mirror' "$B/README.md"
}
@test "a file named like a perl option is rewritten, not run" {
local forge="https://gitbox.branderson.io"
printf 'See %s/dejima/dejima-guard\n' "$forge" > "$F/-eBEGIN{exit 3}"
git -C "$F" add -A
"$MIRROR" --transform "$F"
grep -qF 'https://github.com/dejima-org/dejima-guard' "$F/-eBEGIN{exit 3}"
}
@test "a README that is a symlink stops the mirror instead of publishing its target" {
local B="$T/forge/linked"
git init -q -b main "$B"
printf '# Not for publishing\n' > "$T/outside"
ln -s "$T/outside" "$B/README.md"
git -C "$B" add -A
run "$MIRROR" --transform "$B"
[ "$status" -ne 0 ]
[ -L "$B/README.md" ]
[ "$(cat "$T/outside")" = '# Not for publishing' ]
}
published() {
git -C "$MIRROR_STATE/dejima.git" "$@" refs/mirror/next
}
@test "no AI co-author trailer reaches the published history, and other commits keep their SHA" {
local base
base="$(git -C "$F" rev-parse HEAD)"
echo change >> "$F/docs/ci.md"
git -C "$F" commit -qa -m change -m 'Keeps this line.' \
-m 'Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>'
"$MIRROR" --dry-run
run published log --format=%B
[[ "$output" != *"Co-Authored-By"* ]] || { echo "$output"; return 1; }
[[ "$output" == *"Keeps this line."* ]]
run published rev-list --max-parents=0
[ "$output" = "$base" ]
}
@test "MIRROR_SCRUB values are replaced in every file and message in the history" {
printf '# example\ngitbox.internal.example gitbox.example.com\n10.9.8. 192.0.2.\n' > "$T/scrub"
printf 'forge = gitbox.internal.example\nhost = 10.9.8.7\nother = 110.9.8.7\n' > "$F/docs/net.md"
git -C "$F" add -A
git -C "$F" commit -qm 'Point at gitbox.internal.example'
git -C "$F" rm -q docs/net.md
git -C "$F" commit -qm 'Take it back out'
"$MIRROR" --dry-run
run published log -p --format=%B
[[ "$output" != *"internal.example"* ]] || { echo "$output"; return 1; }
[[ "$output" == *"Point at gitbox.example.com"* ]]
[[ "$output" == *"host = 192.0.2.7"* ]]
[[ "$output" == *"other = 110.9.8.7"* ]]
}
@test "a scrub value the rewrite could not reach stops the mirror" {
printf 'gitbox.internal.example gitbox.example.com\n' > "$T/scrub"
echo 'see xgitbox.internal.example' > "$F/docs/net.md"
git -C "$F" add -A
git -C "$F" commit -qm net
run "$MIRROR" --dry-run
[ "$status" -ne 0 ]
[[ "$output" == *"MIRROR_SCRUB value is still in the tree"* ]]
}
@test "tags are published on the rewritten commit they map to" {
git -C "$F" commit -q --allow-empty -m tagged \
-m 'Co-Authored-By: Claude <noreply@anthropic.com>'
git -C "$F" tag release
run "$MIRROR" --dry-run
[ "$status" -eq 0 ]
local at
at="$(git -C "$MIRROR_STATE/dejima.git" rev-parse refs/clean/main)"
[ "$at" != "$(git -C "$F" rev-parse release)" ]
[[ "$output" == *"tag release at ${at:0:12}"* ]]
}
@test "a mirror built on other history is refused, and --reset replaces it" {
# What GitHub held before trailers were rewritten: a mirror commit whose
# first parent is the forge commit itself.
git -C "$F" commit -q --allow-empty -m tagged \
-m 'Co-Authored-By: Claude <noreply@anthropic.com>'
local old
old="$(git -C "$F" commit-tree "$(git -C "$F" rev-parse HEAD^{tree})" -p HEAD -m mirror)"
git -C "$T/github/dejima" fetch -q "$F" "$old:refs/heads/main"
run "$MIRROR" --dry-run
[ "$status" -ne 0 ]
[[ "$output" == *"--reset force-pushes over it"* ]]
run "$MIRROR" --dry-run --reset
[ "$status" -eq 0 ]
run git -C "$MIRROR_STATE/dejima.git" rev-parse -q --verify refs/mirror/next^2
[ "$status" -ne 0 ]
}
+607 -2
View File
@@ -61,6 +61,11 @@ launch_setup() {
cat > "$LAUNCH_DIR/docker" <<'EOF'
#!/bin/bash
echo "$*" >> "${SANDBOX_TEST_DOCKER_LOG:-/dev/null}"
# Stands in for a session that handed something back, for the one test about
# an isolated share surviving. Written where the launch says it mounted it.
if [ -n "${LAUNCH_SHARE_MARKER:-}" ] && [ -n "${SANDBOX_SHARE_DIR:-}" ]; then
: > "$SANDBOX_SHARE_DIR/handed-back" 2>/dev/null || true
fi
# For a real `docker build` (never `docker compose ... build`, whose $1 is
# "compose"), also log the generated Dockerfile's own content, tagged by -t -
# argv alone can't tell whether the Dockerfile's FROM line actually points at
@@ -84,10 +89,15 @@ done
exit 0
EOF
chmod +x "$LAUNCH_DIR/docker"
: > "$LAUNCH_DIR/sandbox-key"
printf "not-a-real-key\n" > "$LAUNCH_DIR/sandbox-key"
chmod 600 "$LAUNCH_DIR/sandbox-key"
}
# SSH_AUTH_SOCK is emptied rather than passed through, so a test never reads
# the agent of whoever is running the suite - an inherited one would make the
# forwarded-agent path depend on the machine. LAUNCH_SSH_AUTH_SOCK is how the
# one test about that path states its own precondition instead.
#
# ssh_key is written into whatever config file config_root() currently
# resolves to (DEJIMA_CONFIG_DIR, which a few tests point elsewhere) rather
# than always $CONFIG_DIR/config, and appended rather than written, so it
@@ -110,7 +120,8 @@ launch() {
SANDBOX_TEST_RUN_ENV="$LAUNCH_ENV" \
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
SANDBOX_TEST_DOCKERFILE_LOG="$LAUNCH_DIR/dockerfile-log" \
SSH_AUTH_SOCK= ALLOWED_DOMAINS= \
SSH_AUTH_SOCK="${LAUNCH_SSH_AUTH_SOCK-}" ALLOWED_DOMAINS= \
LAUNCH_SHARE_MARKER="${LAUNCH_SHARE_MARKER:-}" \
"$REPO_ROOT/bin/dejima" "$@"
}
@@ -1310,6 +1321,569 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
done
}
# --isolated: a session with nothing before it and nothing after it. The
# claim is one sentence - nothing of the host crosses in but the operator's
# own read-only config, and nothing survives but ~/shared - and these are the
# parts of it that a stubbed launch can decide.
#
# Driven through the real launcher rather than by grepping it, because every
# one of these is a property of what `compose run` is handed, and a grep over
# the source passes just as well on a line that no longer takes effect.
@test "an isolated launch mounts no host path for its working directory" {
launch_setup
run launch --isolated
[ "$status" -eq 0 ]
# The whole spec, because the compose file cannot build one: a source and
# a target would have to be two variables and neither can be empty. The
# source here names the compose file's own volume, so nothing of the host
# is behind the working directory.
local spec; spec="$(launched_env SANDBOX_WORKSPACE_MOUNT)"
[ "$spec" = "isolated-work:/dejima/work" ] \
|| { echo "the workspace mount is $spec, not the container-local volume"; return 1; }
[ "$(launched_env WORKSPACE_DIR)" = /dejima/work ]
# Whatever the source is, it must not be a path: a source starting with /
# is a bind mount, which is the one thing this mode cannot have here.
case "${spec%%:*}" in
/*) echo "the workspace mount names a host path: $spec"; return 1 ;;
esac
# That the volume it names is this run's own, and is removed afterwards,
# belongs to the test below with the other four.
# And the ordinary case still mounts the project at its own path, so the
# assertion above cannot pass by the variable having stopped working.
run launch "$PROJ"
[ "$status" -eq 0 ]
[ "$(launched_env SANDBOX_WORKSPACE_MOUNT)" = "$PROJ:$PROJ" ]
}
@test "an isolated launch takes nothing from the project it was started in" {
# The one in this file that is about an attack rather than about tidiness.
# resolve_project_store walks UPWARDS from the directory it is given, so
# an operator in $HOME on a machine whose dotfiles were once approved as a
# project would otherwise have handed an anonymous session that project's
# allowed domains and its dependency layer. The flag means the directory
# it was run from decides nothing.
#
# Two independent things in bin/dejima hold that - the working directory
# being container-local, and the approval store not being resolved - and
# this fails only when BOTH are reversed. Worth knowing when reading a
# mutation result: either one alone still passes, which is what defence
# in depth looks like from here rather than a test that misses.
"$REPO_ROOT/bin/dejima-allow" --project "$PROJ" example.invalid >/dev/null
"$REPO_ROOT/bin/dejima-dep" add --project "$PROJ" apt:libpq-dev >/dev/null
launch_setup
local slug; slug="$(bash -c 'source "$1"; project_slug "$2"' _ "$LIB" "$PROJ")"
# First that the fixture works at all: an ordinary launch from in there
# DOES get both. Without this the assertions below pass on a project
# whose approvals were never recorded.
cd "$PROJ"
run launch
cd - >/dev/null
[ "$status" -eq 0 ]
[[ "$(launched_env ALLOWED_DOMAINS)" == *example.invalid* ]] \
|| { echo "the fixture's project domains were never approved"; return 1; }
[ "$(launched_env SANDBOX_IMAGE)" = "localhost/dejima:$(id -un)-$slug" ] \
|| { echo "the fixture's project deps were never approved"; return 1; }
# And then that the isolated launch from the same directory gets neither.
# The log accumulates across launches in one fixture, so the build line
# asserted absent below would otherwise be found from the launch above.
: > "$LAUNCH_DIR/docker-log"
cd "$PROJ"
run launch --isolated
cd - >/dev/null
[ "$status" -eq 0 ]
[[ "$(launched_env ALLOWED_DOMAINS)" != *example.invalid* ]] \
|| { echo "an isolated launch inherited the cwd's approved domains"; return 1; }
[ "$(launched_env SANDBOX_IMAGE)" != "localhost/dejima:$(id -un)-$slug" ] \
|| { echo "an isolated launch inherited the cwd's dependency layer"; return 1; }
grep -q -- "-t localhost/dejima:$(id -un)-$slug -f" <(docker_log) \
&& { echo "an isolated launch built the cwd's dependency layer"; return 1; }
return 0
}
@test "an isolated launch mounts the operator's read-only config and none of the writable state" {
launch_setup
run launch --isolated
[ "$status" -eq 0 ]
local log; log="$(docker_log)"
# The operator's own hooks and skills: theirs, read-only, and not
# something an earlier session could have written.
grep -q -- "-v $LAUNCH_DIR/home/.claude/skills:" <<< "$log" \
|| { echo "the operator's skills are not mounted"; return 1; }
# The shared read-write state every project's sessions append to (#380),
# and the transcripts and memory under projects/.
local d
for d in backups tasks plans sessions projects file-history; do
grep -q -- "-v $LAUNCH_DIR/home/.claude/$d" <<< "$log" \
&& { echo "an isolated launch mounts ~/.claude/$d"; return 1; }
done
# Not mounted and also not CREATED: the pre-create loop exists so Docker
# does not make a root-owned mount source, and a directory nothing mounts
# needs no source.
for d in backups tasks plans sessions; do
[ ! -d "$LAUNCH_DIR/home/.claude/$d" ] \
|| { echo "an isolated launch created ~/.claude/$d on the host"; return 1; }
done
return 0
}
@test "an isolated launch gets a fresh share directory that no other session has had" {
local share; share="$(mktemp -d)"
launch_setup
printf '[core]\nshare_dir = %s\n' "$share" \
>> "$(bash -c 'source "$1"; config_path' _ "$LIB")"
run launch --isolated
[ "$status" -eq 0 ]
local first; first="$(launched_env SANDBOX_SHARE_DIR)"
run launch --isolated
[ "$status" -eq 0 ]
local second; second="$(launched_env SANDBOX_SHARE_DIR)"
[ -n "$first" ] && [ -n "$second" ] || { echo "no share directory was set"; return 1; }
[ "$first" != "$second" ] \
|| { echo "two isolated launches were handed the same directory: $first"; return 1; }
case "$first" in
"$share"/isolated/*) ;;
*) echo "the isolated share is not under the isolated root: $first"; return 1 ;;
esac
# Neither survives, because neither was written to: an empty isolated
# share is removed in the exit trap. That covers a session that handed
# nothing back and a launch refused after the directory was made, which
# is most refusals - each used to leave one for an operator to find.
[ ! -d "$first" ] && [ ! -d "$second" ] \
|| { echo "an empty isolated share was left behind"; return 1; }
# And one that WAS written to stays, which is what the directory is for -
# and is also what shows the launch creates and mounts it rather than
# just computing a path.
LAUNCH_SHARE_MARKER=1 run launch --isolated
[ "$status" -eq 0 ]
local used; used="$(launched_env SANDBOX_SHARE_DIR)"
[ -f "$used/handed-back" ] \
|| { echo "the share directory the launch mounted does not exist: $used"; return 1; }
rm -rf "$used"
rm -rf "$share"
}
@test "an isolated launch names its volumes for the run and removes them" {
launch_setup
run launch --isolated
[ "$status" -eq 0 ]
# Two are sharper than the rest. The shell history is shared by every
# project even when the nvim ones are not, so under its usual name an
# isolated session reads what was typed in any session before it; and the
# working directory is where everything the session did actually lives.
local v names=""
for v in SANDBOX_NVIM_DATA SANDBOX_NVIM_STATE SANDBOX_NVIM_CACHE SANDBOX_BASH_HISTORY SANDBOX_ISOLATED_WORK; do
names="$names $(launched_env "$v")"
[[ "$(launched_env "$v")" == dejima_isolated-* ]] \
|| { echo "$v is not this run's own: $(launched_env "$v")"; return 1; }
[[ "$(launched_env "$v")" =~ ^[a-zA-Z0-9][a-zA-Z0-9_.-]*$ ]] \
|| { echo "$v is not a valid Docker volume name"; return 1; }
done
grep -q "volume rm$names" <(docker_log) \
|| { echo "the run's volumes are not removed:$names"; cat "$LAUNCH_DIR/docker-log"; return 1; }
local before="$names"
run launch --isolated
[ "$status" -eq 0 ]
names=""
for v in SANDBOX_NVIM_DATA SANDBOX_NVIM_STATE SANDBOX_NVIM_CACHE SANDBOX_BASH_HISTORY SANDBOX_ISOLATED_WORK; do
names="$names $(launched_env "$v")"
done
[ "$before" != "$names" ] \
|| { echo "two isolated launches asked for the same volumes:$names"; return 1; }
# And an ordinary launch still gets the shared history volume, which is
# what makes a session started tomorrow see what was typed today.
#
# Asserted as "sets nothing", not as a name. What the volume is called
# then comes from the compose default, and whether THAT is the name Docker
# would have chosen on its own is a property of the compose file - checked
# there, in static.bats, against the project name rather than against a
# string repeated here. A name in this test would have been updated to
# match a wrong default rather than catching one.
run launch "$PROJ"
[ "$status" -eq 0 ]
[ -z "$(launched_env SANDBOX_BASH_HISTORY)" ] \
|| { echo "an ordinary launch renamed the history volume to $(launched_env SANDBOX_BASH_HISTORY)"
return 1; }
}
@test "an isolated launch reaches no forge unless the same command names one" {
# The forge credential is the widest thing a session holds - a Gitea token
# is account-wide - so the mode whose purpose is a session nobody is
# watching does not carry one by default. It narrows, like every other
# flag here.
#
# Both orders, because the defaults are applied after the argument loop
# rather than inside it: a version that set them in the loop would pass
# one of these two and fail the other.
launch_setup
local conf
conf="$(bash -c 'source "$1"; config_path' _ "$LIB")"
# With the section header: launch() appends its own [core] block after
# this, so a bare key here would sit outside any section and be refused.
printf '[core]\nforge = gitea\nworkflow = worktree-pr\n' >> "$conf"
run launch --isolated --print-config
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qx 'core.forge=none' <<< "$output" \
|| { echo "an isolated launch kept the configured forge"; return 1; }
grep -qx 'core.workflow=local-only' <<< "$output" \
|| { echo "an isolated launch kept a workflow that pushes"; return 1; }
# Both halves of the name: a forge named on the command line, and a
# workflow, in either order relative to the flag. It only ever named a
# workflow before, so the forge half of what it is called went untested.
local args
for args in "--isolated --forge gitea" "--forge gitea --isolated"; do
# shellcheck disable=SC2086
run launch $args --print-config
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qx 'core.forge=gitea' <<< "$output" \
|| { echo "'$args' did not keep the forge the operator named"; return 1; }
# And the workflow still defaults, since only the forge was named.
grep -qx 'core.workflow=local-only' <<< "$output" \
|| { echo "'$args' changed the workflow as well"; return 1; }
done
for args in "--isolated --workflow worktree-pr" "--workflow worktree-pr --isolated"; do
# shellcheck disable=SC2086
run launch $args --print-config
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qx 'core.workflow=worktree-pr' <<< "$output" \
|| { echo "'$args' did not keep the workflow the operator named"; return 1; }
grep -qx 'core.forge=none' <<< "$output" \
|| { echo "'$args' changed the forge as well"; return 1; }
done
# And the config file is untouched for a launch without the flag, so the
# assertions above are about --isolated rather than about the fixture.
run launch --print-config
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qx 'core.forge=gitea' <<< "$output" \
|| { echo "an ordinary launch lost the configured forge"; return 1; }
return 0
}
@test "an isolated launch mounts the configured ssh key and not the forwarded agent" {
# Opposite answers for the two, which is the whole reason this is one
# test rather than a line in another: a workflow that never pushes gets
# no agent socket, and --isolated defaults to one of those, while a
# configured key is exported whatever the workflow. Both docs state the
# pair, and a test that checked only the key - as this one first did -
# cannot catch the half that was written backwards.
#
# Withholding the key as well, for any launch that never pushes, is #392.
launch_setup
run launch --isolated
[ "$status" -eq 0 ]
[ "$(launched_env SANDBOX_SSH_KEY_FILE)" = "$LAUNCH_DIR/sandbox-key" ] \
|| { echo "the configured key did not cross: $(launched_env SANDBOX_SSH_KEY_FILE)"
echo "- if that is deliberate, the two docs saying it does need updating"
return 1; }
# Now the agent, which needs its own fixture for two reasons. The helper
# empties SSH_AUTH_SOCK so no test reads the suite runner's own agent, so
# a launch that never sets it could not have forwarded one whatever the
# code did - asserting its absence there proves nothing. And a configured
# key withdraws the agent too, independently of the workflow, so the key
# above has to go for this to be about the rule the docs name. An EMPTY
# key file is "not a key" by design, which is how that is arranged.
: > "$LAUNCH_DIR/sandbox-key"
# A real socket, because the launcher tests for one with -S. Binding
# creates the filesystem entry and leaves it there after the process
# exits, which is all this needs.
python3 -c 'import socket,sys
s = socket.socket(socket.AF_UNIX)
s.bind(sys.argv[1])' "$LAUNCH_DIR/agent.sock"
[ -S "$LAUNCH_DIR/agent.sock" ] || { echo "the fixture made no socket"; return 1; }
LAUNCH_SSH_AUTH_SOCK="$LAUNCH_DIR/agent.sock" run launch --isolated
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ -z "$(launched_env SSH_AUTH_SOCK)" ] \
|| { echo "a workflow that pushes nothing still forwarded the agent"; return 1; }
# And the positive control. A pushing workflow needs a forge with a host
# to push to, so this names both - which is also the combination an
# operator asking for an isolated session that pushes would type.
printf 'ghp_not_a_real_push_token\n' > "$LAUNCH_DIR/gh-push-token"
chmod 600 "$LAUNCH_DIR/gh-push-token"
printf '[forge.github]\npush_token_file = %s\n' "$LAUNCH_DIR/gh-push-token" \
>> "$(bash -c 'source "$1"; config_path' _ "$LIB")"
LAUNCH_SSH_AUTH_SOCK="$LAUNCH_DIR/agent.sock" \
run launch --isolated --forge github --workflow branch-push
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$(launched_env SSH_AUTH_SOCK)" = "$LAUNCH_DIR/agent.sock" ] \
|| { echo "naming a pushing workflow did not forward the agent"; return 1; }
}
@test "an isolated launch gets no GitHub token unless the command names a forge" {
# [github] token_file is not a forge setting, which is what makes it easy
# to miss: core fills it for every session whatever forge is selected. A
# read-only PAT is still read-only across the operator's private
# repositories, so the mode that carries no forge credential cannot carry
# this one either.
#
# The filter container keeps it, because that is what authenticates
# dejima's own fetch of GitHub's address ranges - a request about the
# allowlist, made where no session can reach it. So the two are separate
# variables now, and this asserts both halves: the session's is empty and
# the launcher's, which is what `-e GH_TOKEN` hands the filter, is not.
launch_setup
printf 'ghp_not_a_real_token\n' > "$LAUNCH_DIR/gh-token"
chmod 600 "$LAUNCH_DIR/gh-token"
printf '[github]\ntoken_file = %s\n' "$LAUNCH_DIR/gh-token" \
>> "$(bash -c 'source "$1"; config_path' _ "$LIB")"
run launch --isolated
[ "$status" -eq 0 ]
[ -z "$(launched_env SANDBOX_GH_TOKEN)" ] \
|| { echo "an isolated session was handed core's GitHub token"; return 1; }
[ -n "$(launched_env GH_TOKEN)" ] \
|| { echo "the filter container lost the token that authenticates its ranges fetch"
return 1; }
# `--forge` naming the forge with no capabilities is an operator asking
# for no forge, and it counted as naming one - so the launch that said
# most clearly that it wanted no forge access was the one that got the
# credential.
run launch --isolated --forge none
[ "$status" -eq 0 ]
[ -z "$(launched_env SANDBOX_GH_TOKEN)" ] \
|| { echo "--isolated --forge none was handed core's GitHub token"; return 1; }
# Naming a forge is the operator saying this session is meant to reach
# one, and nothing is narrowed from there.
run launch --isolated --forge gitea
[ "$status" -eq 0 ]
[ -n "$(launched_env SANDBOX_GH_TOKEN)" ] \
|| { echo "a named forge did not restore the GitHub token"; return 1; }
# And an ordinary launch is untouched, including one whose config selects
# no forge - this narrows for the flag, not for every forge-less launch.
run launch --forge none --workflow local-only "$PROJ"
[ "$status" -eq 0 ]
[ -n "$(launched_env SANDBOX_GH_TOKEN)" ] \
|| { echo "an ordinary launch lost core's GitHub token"; return 1; }
}
@test "an isolated launch says nothing is kept, and every reader agrees" {
# The banner worked the epitaph out from [epitaph] enabled alone and said
# "epitaph on" a few lines above the isolated notice saying it was off -
# and nothing was being kept either way, because the save was gated on
# the mode instead. --print-config had the same split for both keys.
#
# Both are overridden off where the flag is read now, so this asserts the
# thing every reader is reading rather than one reader's output.
launch_setup
printf '[epitaph]\nenabled = on\n[logging]\nconversation = on\nloki_url = http://loki.invalid:3100\n' \
>> "$(bash -c 'source "$1"; config_path' _ "$LIB")"
run launch --isolated --print-config
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qx 'epitaph.enabled=off' <<< "$output" \
|| { echo "--print-config says the epitaph is on for an isolated launch"; return 1; }
grep -qx 'logging.conversation=off' <<< "$output" \
|| { echo "--print-config says the conversation log is on"; return 1; }
# And on, as configured, without the flag - so the two lines above are
# about --isolated rather than about the keys having stopped working.
run launch --print-config
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qx 'epitaph.enabled=on' <<< "$output" \
|| { echo "an ordinary launch lost its epitaph setting"; return 1; }
grep -qx 'logging.conversation=on' <<< "$output" \
|| { echo "an ordinary launch lost its conversation setting"; return 1; }
}
@test "an isolated cline launch is configured, and writes nothing to the host" {
# Cline's model configuration reaches the container through the state
# mount, which this mode does not have - so it was written to a host
# directory keyed on the container-local working directory, shared by
# every isolated cline launch, and then not mounted. Cline started with no
# model and fell back to its own hosted endpoint, which the firewall was
# never asked to allow.
#
# It is seeded from the per-run staging directory now, so it arrives
# without a state mount and the host state directory is untouched.
launch_setup
local state; state="$(mktemp -d)"
printf '[harness.cline]\nstate_dir = %s\nmodel = m\nbackend = self-hosted\n[backend.self-hosted]\nbase_url = http://127.0.0.1:11434\n' \
"$state" >> "$(bash -c 'source "$1"; config_path' _ "$LIB")"
# --harness rather than a config line: launch() appends its own
# `harness = claude-code` after whatever a test writes, and a repeated
# key takes the last value.
run launch --isolated --harness cline
[ "$status" -eq 0 ] || { echo "$output"; rm -rf "$state"; return 1; }
# The configuration arrives, as a seed rather than a mount - so the
# session owns the copy it edits and nothing it writes reaches the host.
local log; log="$(docker_log)"
grep -qE -- "-v [^ ]*/providers\.json:/etc/dejima-seed/\.cline/data/settings/providers\.json:ro" \
<<< "$log" \
|| { echo "cline got no provider configuration"; rm -rf "$state"; return 1; }
# From a path that goes away with the run, not from the state directory.
grep -qE -- "-v $state[^ ]*:/.*providers\.json" <<< "$log" \
&& { echo "it was mounted out of the host state directory"; rm -rf "$state"; return 1; }
# And the state directory itself is neither mounted nor written.
grep -qF -- "-v $state" <<< "$log" \
&& { echo "an isolated launch mounted the host state directory"; rm -rf "$state"; return 1; }
[ -z "$(ls -A "$state")" ] \
|| { echo "it wrote to the host: $(ls -A "$state")"; rm -rf "$state"; return 1; }
rm -rf "$state"
return 0
}
@test "a launch leaves no per-run staging directory behind" {
# The staging directory is created with mktemp -d above the trap and
# removed by cleanup(), and for one release it was neither: the block
# that initialises trap-owned names to empty runs AFTER the mktemp in
# straight-line order, so it threw the real path away and cleanup()
# removed "". Every launch on every harness left one in TMPDIR.
#
# Nothing caught it because nothing looked after exit. This does, with its
# own TMPDIR so it sees only what this launch made - and for both
# harnesses, since the one that puts a file in there is not the default.
launch_setup
local t; t="$(mktemp -d)"
TMPDIR="$t" run launch "$PROJ"
[ "$status" -eq 0 ] || { echo "$output"; rm -rf "$t"; return 1; }
local left; left="$(find "$t" -maxdepth 1 -name 'dejima-harness-stage.*' -print)"
[ -z "$left" ] \
|| { echo "left behind: $left"; rm -rf "$t"; return 1; }
# cline is the harness that writes into it, so it is the one where a
# leak carries content rather than being an empty directory.
local state; state="$(mktemp -d)"
printf '[harness.cline]\nstate_dir = %s\nmodel = m\nbackend = self-hosted\n[backend.self-hosted]\nbase_url = http://127.0.0.1:11434\n' \
"$state" >> "$(bash -c 'source "$1"; config_path' _ "$LIB")"
TMPDIR="$t" run launch --harness cline "$PROJ"
[ "$status" -eq 0 ] || { echo "$output"; rm -rf "$t" "$state"; return 1; }
left="$(find "$t" -maxdepth 1 -name 'dejima-harness-stage.*' -print)"
[ -z "$left" ] \
|| { echo "cline left behind: $left ($(find "$left" -type f | head -3))"
rm -rf "$t" "$state"; return 1; }
# And the other staging paths, which were never broken and are what this
# one should have looked like.
left="$(find "$t" -maxdepth 1 \( -name 'dejima-settings.*' -o -name 'dejima-claude-json.*' \) -print)"
[ -z "$left" ] || { echo "also left behind: $left"; rm -rf "$t" "$state"; return 1; }
rm -rf "$t" "$state"
}
@test "an ordinary cline launch still gets its provider configuration" {
# The same seed, which for a launch that HAS a state mount lands inside
# it - so the end state is what it was before, written by the entrypoint
# rather than by the launcher onto the host.
launch_setup
local state; state="$(mktemp -d)"
printf '[harness.cline]\nstate_dir = %s\nmodel = m\nbackend = self-hosted\n[backend.self-hosted]\nbase_url = http://127.0.0.1:11434\n' \
"$state" >> "$(bash -c 'source "$1"; config_path' _ "$LIB")"
run launch --harness cline "$PROJ"
[ "$status" -eq 0 ] || { echo "$output"; rm -rf "$state"; return 1; }
local log; log="$(docker_log)"
grep -qE -- "-v [^ ]*/providers\.json:/etc/dejima-seed/\.cline/data/settings/providers\.json:ro" \
<<< "$log" \
|| { echo "cline got no provider configuration"; rm -rf "$state"; return 1; }
# And this one does mount the state directory, which is what makes the
# seeded copy land in it.
grep -qF -- "-v $state" <<< "$log" \
|| { echo "an ordinary launch did not mount the state directory"; rm -rf "$state"; return 1; }
rm -rf "$state"
return 0
}
@test "an isolated launch refuses a project path and the mount flags" {
launch_setup
# Each refusal names what it refused. A bare nonzero status would pass on
# a launch that failed for an unrelated reason.
run launch --isolated "$PROJ"
[ "$status" -ne 0 ] || { echo "a project path was accepted"; return 1; }
[[ "$output" == *"takes no project directory"* ]] \
|| { echo "the refusal does not say why: $output"; return 1; }
local flag
for flag in --ro --rw; do
run launch --isolated "$flag" /tmp
[ "$status" -ne 0 ] || { echo "$flag was accepted"; return 1; }
[[ "$output" == *"cannot take --ro or --rw"* ]] \
|| { echo "the $flag refusal does not say why: $output"; return 1; }
[[ "$output" == *"/tmp"* ]] \
|| { echo "the $flag refusal does not name the path: $output"; return 1; }
done
# Harness arguments after -- are not a project path, and refusing them
# would make the flag unusable with every launch that passes any.
run launch --isolated -- --version
[ "$status" -eq 0 ] \
|| { echo "harness arguments were read as a project path: $output"; return 1; }
# And the other order, which is the one that went wrong quietly: the flag
# loop stops at the first positional, so `--isolated` after a path was
# passed to the harness while the project was mounted read-write - the
# operator asking for isolation got the opposite with no sign of it.
run launch "$PROJ" --isolated
[ "$status" -ne 0 ] \
|| { echo "--isolated after the path was accepted and mounted $PROJ"; return 1; }
[[ "$output" == *"would be passed to the"* ]] \
|| { echo "the refusal does not say what would have happened: $output"; return 1; }
# Still passed through when -- says it is the harness's.
run launch "$PROJ" -- --isolated
[ "$status" -eq 0 ] \
|| { echo "-- did not hand --isolated to the harness: $output"; return 1; }
}
@test "an isolated launch says what it is and what it does without" {
launch_setup
run launch --isolated
[ "$status" -eq 0 ]
# Printed whatever [ui] banner says and with stderr redirected, unlike
# the banner: the share path is the only route back to what the session
# hands over, and the two features it does without are ones an operator
# would otherwise go looking for afterwards and find empty.
[[ "$output" == *"no project mounted, no memory, no transcript"* ]] \
|| { echo "the launch does not say what this mode is: $output"; return 1; }
[[ "$output" == *"conversation log and epitaph off"* ]] \
|| { echo "the launch does not say what is off: $output"; return 1; }
[[ "$output" == *"$(launched_env SANDBOX_SHARE_DIR)"* ]] \
|| { echo "the launch does not name the share directory: $output"; return 1; }
}
@test "an isolated launch does not apply the configured mounts, and says so" {
local extra; extra="$(mktemp -d)"
launch_setup
printf '[mounts]\nreadonly = %s\n' "$extra" \
>> "$(bash -c 'source "$1"; config_path' _ "$LIB")"
run launch --isolated
[ "$status" -eq 0 ]
grep -q -- "-v $extra" <(docker_log) \
&& { echo "an isolated launch applied a configured mount"; return 1; }
[[ "$output" == *"[mounts] readonly not applied"* ]] \
|| { echo "the launch does not say the mount was dropped: $output"; return 1; }
# Dropped for an isolated launch only - the same config still mounts it
# for an ordinary one, so the assertion above is about the flag rather
# than about the list having stopped working.
run launch "$PROJ"
[ "$status" -eq 0 ]
grep -q -- "-v $extra" <(docker_log) \
|| { echo "an ordinary launch stopped applying [mounts] readonly"; return 1; }
rm -rf "$extra"
}
@test "the launcher builds and runs the global layer when only global deps are approved" {
"$REPO_ROOT/bin/dejima-dep" add --global apt:libpq-dev >/dev/null
launch_setup
@@ -1383,6 +1957,37 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
[[ "$output" == *"approve its own domains and dependencies"* ]]
}
@test "the launcher warns about the epitaph and share stores inside the workspace too" {
# Three stores were warned about and six were guarded, so a path dejima
# refuses to mount through [mounts] or --ro was mounted without a word
# when it happened to sit inside the project. Each consequence differs,
# which is why each store says its own.
launch_setup
local conf; conf="$(bash -c 'source "$1"; config_path' _ "$LIB")"
printf '[epitaph]\ndir = %s\n[core]\nshare_dir = %s\n' \
"$PROJ/.epitaphs" "$PROJ/.share" >> "$conf"
run launch "$PROJ"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"the epitaph store"* ]] \
|| { echo "no warning for the epitaph store: $output"; return 1; }
[[ "$output" == *"what every previous one in this project concluded"* ]] \
|| { echo "the epitaph warning does not name its own consequence"; return 1; }
[[ "$output" == *"share directories of every project"* ]] \
|| { echo "no warning for the per-project share root"; return 1; }
[[ "$output" == *"share directories of every isolated session"* ]] \
|| { echo "no warning for the isolated share root"; return 1; }
# And none of them for a project that contains none of these, so the
# three above are about the overlap rather than printed always.
local other; other="$(mktemp -d)"
run launch "$other"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" != *"writable from inside the sandbox"* ]] \
|| { echo "a clean project was warned about: $output"; return 1; }
rm -rf "$other"
}
@test "the launcher warns when the audit report or baseline store sits inside the mounted workspace" {
launch_setup
run launch "$PROJ"
+27
View File
@@ -215,6 +215,33 @@ assert_remedy_forges_exist() {
[ "$status" -ne 0 ]
}
@test "the briefing tells an agent how a refusal differs from a hang" {
# The policy rejects rather than drops, for the reason its own chain
# comment gives: silently dropping makes every blocked destination look
# like a hang. The briefing described both as failing to connect, which
# throws that signal away - and two agents in one day reported a host
# firewall on the far side as dejima blocking a domain, because from
# inside a sandbox the two are indistinguishable except by timing.
local lib="$REPO_ROOT/bin/dejima-provider-lib.sh"
grep -q 'refused at once rather than' "$lib" \
|| { echo "the briefing no longer says a refusal is immediate"; return 1; }
grep -q 'HANGS is not this allowlist' "$lib" \
|| { echo "the briefing no longer tells an agent what a hang means"; return 1; }
# And the property it describes is still true of the policy, so the advice
# cannot outlive the behaviour it rests on.
local chain
chain=$(sed -n '/^ chain refuse {/,/^ }/p' "$REPO_ROOT/docker/egress-policy")
[ -n "$chain" ] || { echo "no refuse chain in the policy"; return 1; }
grep -q 'reject with tcp reset' <<< "$chain" \
|| { echo "the policy no longer rejects TCP, so a block would hang"; return 1; }
grep -q 'reject with icmpx' <<< "$chain" \
|| { echo "the policy no longer rejects non-TCP"; return 1; }
grep -qE '(^|[^a-z])drop([^a-z]|$)' <<< "$chain" \
&& { echo "the refuse chain drops as well as rejects: $chain"; return 1; }
true
}
@test "gitea's CLIs moved out of bin/ and into its forge" {
[ ! -e "$REPO_ROOT/bin/gitea-pr" ]
[ ! -e "$REPO_ROOT/bin/gitea-issue" ]
+215 -11
View File
@@ -292,17 +292,18 @@ teardown() {
@test "forwarder bridges TCP to the proxy's unix socket" {
start_proxy
SANDBOX_CREDENTIAL_PROXY_SOCKET="$SOCK" SANDBOX_CREDENTIAL_PROXY_PORT=18399 \
local fport; fport="$(free_port)"
SANDBOX_CREDENTIAL_PROXY_SOCKET="$SOCK" SANDBOX_CREDENTIAL_PROXY_PORT="$fport" \
SANDBOX_CREDENTIAL_TRANSPORT=loopback \
node "$REPO_ROOT/docker/credential-proxy-forwarder.js" &
FWD_PID=$!
ok=""
for _ in $(seq 1 50); do
if curl -s -o /dev/null http://127.0.0.1:18399/echo; then ok=1; break; fi
if curl -s -o /dev/null http://127.0.0.1:$fport/echo; then ok=1; break; fi
sleep 0.1
done
[ -n "$ok" ]
run curl -s -H "Authorization: Bearer $PLACEHOLDER" -d '{}' http://127.0.0.1:18399/v1/messages
run curl -s -H "Authorization: Bearer $PLACEHOLDER" -d '{}' http://127.0.0.1:$fport/v1/messages
kill "$FWD_PID" 2>/dev/null || true
[ "$(jq -r '.headers.authorization' <<<"$output")" = "Bearer real-access-token" ]
}
@@ -353,6 +354,20 @@ PYEOF
[[ "$output" != *"501"* ]]
}
# The forwarder is given a port rather than asking for one, so these tests
# have to name a number. Asking the kernel for a free one and handing that over
# leaves a window where something else could take it; naming a constant leaves
# the test failing on any machine already using it, which is the precondition
# these tests were reading from the environment instead of stating. The window
# is the smaller of the two, and the suites run on a developer's machine.
free_port() {
python3 -c 'import socket
s = socket.socket()
s.bind(("127.0.0.1", 0))
print(s.getsockname()[1])
s.close()'
}
# The forwarder is the container-side half of the chain. In tls mode it
# terminates TLS for api.anthropic.com on loopback so Claude Code needs no
# ANTHROPIC_BASE_URL at all, then pipes raw bytes into the same unix socket
@@ -382,13 +397,14 @@ make_cert() {
@test "forwarder tls mode: https to api.anthropic.com reaches the proxy, which injects the real token" {
make_cert
start_proxy
start_forwarder tls 18443
local fport; fport="$(free_port)"
start_forwarder tls "$fport"
# --resolve is what stands in for the container's /etc/hosts entry: the
# name must really be api.anthropic.com for certificate verification to
# be a meaningful check, not a hostname the test picked to be easy.
run curl -s --cacert "$TD/cert.pem" \
--resolve "api.anthropic.com:18443:127.0.0.1" \
-X POST "https://api.anthropic.com:18443/v1/messages" -d '{}'
--resolve "api.anthropic.com:$fport:127.0.0.1" \
-X POST "https://api.anthropic.com:$fport/v1/messages" -d '{}'
kill "$FWD_PID" 2>/dev/null || true
[ "$status" -eq 0 ]
[[ "$output" == *'"authorization": "Bearer real-access-token"'* ]]
@@ -403,8 +419,9 @@ make_cert() {
# no HTTP awareness to catch it.
make_cert
start_proxy
start_forwarder tls 18444
run openssl s_client -connect 127.0.0.1:18444 -alpn h2,http/1.1 \
local fport; fport="$(free_port)"
start_forwarder tls "$fport"
run openssl s_client -connect "127.0.0.1:$fport" -alpn h2,http/1.1 \
-servername api.anthropic.com -CAfile "$TD/cert.pem" </dev/null
kill "$FWD_PID" 2>/dev/null || true
[[ "$output" == *"ALPN protocol: http/1.1"* ]]
@@ -412,8 +429,9 @@ make_cert() {
@test "forwarder loopback mode serves plaintext http on the port it is given" {
start_proxy
start_forwarder loopback 18445
run curl -s -X POST "http://127.0.0.1:18445/v1/messages" -d '{}'
local fport; fport="$(free_port)"
start_forwarder loopback "$fport"
run curl -s -X POST "http://127.0.0.1:$fport/v1/messages" -d '{}'
kill "$FWD_PID" 2>/dev/null || true
[ "$status" -eq 0 ]
[[ "$output" == *'"authorization": "Bearer real-access-token"'* ]]
@@ -437,7 +455,7 @@ make_cert() {
# today, so a fall-through lands on an unused port if that ever changes.
run env -u SANDBOX_CREDENTIAL_TLS_CERT -u SANDBOX_CREDENTIAL_TLS_KEY \
SANDBOX_CREDENTIAL_PROXY_SOCKET="$SOCK" SANDBOX_CREDENTIAL_TRANSPORT=tls \
SANDBOX_CREDENTIAL_PROXY_PORT=18398 \
SANDBOX_CREDENTIAL_PROXY_PORT="$(free_port)" \
node "$REPO_ROOT/docker/credential-proxy-forwarder.js"
[ "$status" -ne 0 ]
[[ "$output" == *"SANDBOX_CREDENTIAL_TLS_CERT"* ]]
@@ -467,6 +485,45 @@ make_cert() {
return 0
}
@test "an oversized body is refused before the 100 Continue that invites it" {
# The test above is about what the client ends up with, and it can pass
# either way when the timing is kind: the proxy used to answer
# `100 Continue` first and refuse from the body reader afterwards, so
# whether a client read the 413 or only the 100 was a race. This one asks
# the question the race cannot reach - what the FIRST answer is - and never
# sends a body at all.
start_proxy
run python3 - "$SOCK" "$PLACEHOLDER" <<'PROBE'
import socket, sys
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(10)
sock.connect(sys.argv[1])
# Declared far over the cap, and not one byte of it sent: a client offering a
# body is entitled to be told no before it starts.
sock.sendall(
b"POST /v1/messages HTTP/1.1\r\n"
b"Host: x\r\n"
b"Authorization: Bearer " + sys.argv[2].encode() + b"\r\n"
b"Content-Type: application/json\r\n"
b"Content-Length: 99999999999\r\n"
b"Expect: 100-continue\r\n\r\n")
data = b""
while b"\r\n" not in data:
chunk = sock.recv(4096)
if not chunk:
break
data += chunk
print(data.split(b"\r\n")[0].decode("ascii", "replace"))
PROBE
[ "$status" -eq 0 ] || { echo "the probe failed: $output"; return 1; }
[[ "$output" == *"413"* ]] \
|| { echo "the first answer was not a refusal: $output"; return 1; }
grep -q 'refused a request body' "$TD/stderr" \
|| { echo "nothing logged: $(cat "$TD/stderr")"; return 1; }
}
@test "a normal-sized request body still passes through" {
# The cap must be far above anything real. A conversation with attachments
# is the biggest thing a Messages request carries.
@@ -861,3 +918,150 @@ print(raw.decode().strip())
[[ "$output" != *"HTTP 400"* ]] \
|| { echo "the last host line still leads with the provider's answer: $output"; return 1; }
}
# The exit audit in bin/dejima reads this log. Extracted and driven here rather
# than restated, and fed logs this proxy actually wrote, because the two live in
# different files and the classification only works if they agree about what a
# line looks like.
run_exit_audit() { # <proxy log>
C_WARN='' C_RESET='' SANDBOX_CREDENTIAL_PROXY_LOG="$1" bash -c '
C_WARN=""; C_RESET=""
SANDBOX_CREDENTIAL_PROXY_LOG="$1"
'"$(sed -n '/_audit_counts="\$(awk/,/unset _audit_counts _audit_last/p' \
"$REPO_ROOT/bin/dejima")"'
' _ "$1" 2>&1
}
@test "the exit audit does not report a credential failure as the API answering" {
# An overnight expiry logs no upstream status, because no request was made -
# the credential could not be loaded. Announcing "the model API answered
# with an error" there sends an operator to check the provider, and the
# retry caveat invites them to hope it succeeded anyway. Neither is true,
# and the line the proxy leaves last is an instruction rather than a status.
write_creds -60
start_proxy
curl -s "http://127.0.0.1:$PORT/oauth-reject" >/dev/null
pcurl -o /dev/null -X POST --data '{}' http://x/v1/messages || true
run run_exit_audit "$TD/stderr"
[[ "$output" != *"model API answered with an error"* ]] \
|| { echo "reported as the API answering: $output"; return 1; }
[[ "$output" != *"may still have succeeded"* ]] \
|| { echo "invited a retry that cannot succeed: $output"; return 1; }
[[ "$output" == *"could not authenticate"* ]] \
|| { echo "did not say what happened: $output"; return 1; }
[[ "$output" == *"log in again"* ]] \
|| { echo "did not carry the remedy: $output"; return 1; }
}
@test "an upstream that answered with errors is still reported as one" {
# The control: the audit's original purpose has to survive the split.
start_proxy
pcurl -o /dev/null "http://localhost/status-429"
pcurl -o /dev/null "http://localhost/status-429"
run run_exit_audit "$TD/stderr"
[[ "$output" == *"model API answered with an error"* ]] \
|| { echo "an upstream error stopped being reported as one: $output"; return 1; }
[[ "$output" == *"2 x HTTP 429"* ]] \
|| { echo "the grouping by status is gone: $output"; return 1; }
[[ "$output" == *"may still have succeeded"* ]] \
|| { echo "the retry caveat is gone from the case it is about: $output"; return 1; }
}
@test "a request that never reached the upstream is reported as neither case" {
# `upstream request failed` carries no status - nothing answered - and no
# credential failed either. Classified by "no status lines anywhere", it
# read as an authentication failure, sending an operator to log in again
# over a connection problem.
start_proxy --upstream "http://127.0.0.1:1"
pcurl -o /dev/null -X POST --data '{}' http://x/v1/messages || true
run run_exit_audit "$TD/stderr"
[[ "$output" != *"could not authenticate"* ]] \
|| { echo "a connection failure read as an authentication failure: $output"; return 1; }
[[ "$output" != *"model API answered with an error"* ]] \
|| { echo "reported as the API answering: $output"; return 1; }
[[ "$output" == *"credential proxy reported a problem"* ]] \
|| { echo "the failure went unreported: $output"; return 1; }
[[ "$output" == *"upstream request failed"* ]] \
|| { echo "the line itself is missing: $output"; return 1; }
}
@test "upstream text saying 'answered 503' cannot fabricate a status count" {
# The refresh failure's detail line carries up to 200 bytes of the token
# endpoint's response body - text the upstream chose. Counted wherever
# `answered N` appeared, that body minted "1 x HTTP 503" for a session
# that made no request, and brought the retry caveat with it. The count
# is anchored to the proxy's own `upstream answered N for` lines.
write_creds -60
start_proxy
curl -s "http://127.0.0.1:$PORT/oauth-reject?say=the+upstream+answered+503+today" >/dev/null
pcurl -o /dev/null -X POST --data '{}' http://x/v1/messages || true
run run_exit_audit "$TD/stderr"
[[ "$output" != *"model API answered with an error"* ]] \
|| { echo "an upstream string was counted as an API answer: $output"; return 1; }
[[ "$output" != *"HTTP 503"* ]] \
|| { echo "a status count was minted from body text: $output"; return 1; }
[[ "$output" == *"could not authenticate"* ]] \
|| { echo "the real failure went unreported: $output"; return 1; }
[[ "$output" == *"answered 503 today"* ]] \
|| { echo "the detail line is not shown: $output"; return 1; }
}
@test "a negative Content-Length is refused rather than read to EOF" {
# The cap exists because this runs on the host as the operator's own user.
# int() accepts a negative, a negative passes a `> MAX` test, and a buffered
# reader takes read(-1) as "to EOF" - so `Content-Length: -1` read an
# unbounded body into host RAM past the cap. A non-numeric value raised out
# past the types handle_error knows and put a traceback on the terminal.
start_proxy
run curl -s -o /dev/null -w '%{http_code}' --unix-socket "$SOCK" \
-H "Authorization: Bearer $PLACEHOLDER" -H 'Content-Length: -1' \
-X POST --data 'x' http://x/v1/messages
[ "$output" = 413 ] \
|| { echo "a negative Content-Length was not refused: $output"; return 1; }
# A value that is not a number is left alone: it still raises, because the
# test above for narrow error suppression uses exactly that fault to prove
# the suppression is specific to a client going away. This fix is about the
# value that passes the cap and then reads to EOF, not about input tidiness.
# The log says what arrived, which is what tells a hostile negative length
# apart from a genuinely oversized upload.
grep -q 'Content-Length of -1' "$TD/stderr" \
|| { echo "the refusal log does not name the offending length:"; cat "$TD/stderr"; return 1; }
# And an ordinary request still works, so the check did not close the door.
run pcurl -o /dev/null -w '%{http_code}' -X POST --data '{}' http://x/v1/messages
[ "$output" = 200 ] || { echo "a normal request was refused: $output"; return 1; }
}
@test "a negative chunk size is refused too, not only Content-Length" {
# The two paths share one helper today. A refactor that inlines the chunked
# branch - int(readline().split(b";")[0], 16) - puts the unbounded read back
# with nothing failing, so the chunked shape is asserted in its own right.
start_proxy
python3 - "$SOCK" <<'PYEOF'
import socket, sys
s = socket.socket(socket.AF_UNIX)
s.connect(sys.argv[1])
s.sendall(b"POST /v1/messages HTTP/1.1\r\nHost: proxy\r\n"
b"Authorization: Bearer placeholder\r\n"
b"Transfer-Encoding: chunked\r\n\r\n-1\r\n")
s.settimeout(5)
try:
got = s.recv(200)
except Exception:
got = b"<no answer>"
print(got.split(b"\r\n")[0].decode("latin-1"))
s.close()
PYEOF
run cat "$TD/stderr"
[[ "$output" == *"chunk size of -1"* ]] \
|| { echo "a negative chunk size was not refused as one: $output"; return 1; }
[[ "$output" != *"Traceback"* ]] \
|| { echo "it raised instead of being refused: $output"; return 1; }
}
+25 -7
View File
@@ -143,6 +143,22 @@ touches '^tools/' &&
touches '^\.dejima/allowed-domains$' &&
manual "A domain here is read from main by the CI hardener and widens every
future CI job on that runner. Say in the PR what it is for."
# The boundary itself had no pattern at all, so a release that rewrote the
# egress policy, the capability floor or the credential path was reported as
# touching nothing that needs a manual check - over exactly the things this
# project exists to hold.
touches '^(docker/egress-policy|docker/firewall-up|docker/fixed-domains|docker/dns-lookup-log|docker/cidr-collapse|bin/dejima-allow)' &&
manual "Reach an allowed destination and a refused one from inside a real
session, and check a refusal is immediate rather than a hang. The suites
drive the policy; only a session drives it with a real resolver."
touches '^(docker/caps-lib|docker/docker-compose)' &&
manual "Check the capability set of a running session against the floor the
compose file declares. A container that starts says nothing about what
it kept."
touches '^(bin/dejima-credential-proxy|docker/credential-proxy-forwarder|backends/|bin/dejima-credential-scan)' &&
manual "Run a session against a real model through the proxy, and let a
credential expire or be refused. The failure paths are what the suites
reach least."
[ "$printed" = 1 ] || echo " (none - this release touches nothing with a manual check)"
note "Required before this merges"
@@ -158,9 +174,11 @@ echo " launch on the other configured harness to confirm it starts."
# turns the obvious rollback into a second outage, so it is worth knowing
# before rather than after.
note "Config keys this release adds (a rollback refuses a config using them)"
# Both sides through git, because this reports on $base...$head and the working
# tree is neither of them once a head is named explicitly.
added="$(comm -13 \
<(git show "$base:bin/dejima-config-lib.sh" 2>/dev/null | grep -oE "^[a-z-]+\\.[a-z_]+" | sort -u) \
<(grep -oE "^[a-z-]+\\.[a-z_]+" bin/dejima-config-lib.sh | sort -u))"
<(git show "$head:bin/dejima-config-lib.sh" 2>/dev/null | grep -oE "^[a-z-]+\\.[a-z_]+" | sort -u))"
# Providers too, and this is the sharper half: a [harness.<name>] section for
# a harness the older dejima does not ship is an unknown SECTION, which fails
# validation exactly as an unknown key does. So adopting a new provider is
@@ -193,12 +211,12 @@ new_providers=""
for pair in $DEJIMA_RELEASE_AXES; do
axis="${pair%%:*}"
one="${pair#*:}"
[ -d "$axis" ] || continue
git cat-file -e "$head:$axis" 2>/dev/null || continue
while IFS= read -r name; do
[ -n "$name" ] || continue
git cat-file -e "$base:$axis/$name/$one.conf" 2>/dev/null ||
new_providers="$new_providers $one.$name"
done < <(find "$axis" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort)
done < <(git ls-tree -d --name-only "$head:$axis" 2>/dev/null | sort)
done
# And the keys a provider DECLARES, which are config keys an operator writes
@@ -210,18 +228,18 @@ new_keys=""
for pair in $DEJIMA_RELEASE_AXES; do
axis="${pair%%:*}"
one="${pair#*:}"
[ -d "$axis" ] || continue
git cat-file -e "$head:$axis" 2>/dev/null || continue
while IFS= read -r name; do
[ -n "$name" ] || continue
now="$(sed -n 's/^keys *= *//p' "$axis/$name/$one.conf" 2>/dev/null \
| tr ' ' '\n' | sed 's/:.*//' | sort -u)"
now="$(git show "$head:$axis/$name/$one.conf" 2>/dev/null \
| sed -n 's/^keys *= *//p' | tr ' ' '\n' | sed 's/:.*//' | sort -u)"
was="$(git show "$base:$axis/$name/$one.conf" 2>/dev/null \
| sed -n 's/^keys *= *//p' | tr ' ' '\n' | sed 's/:.*//' | sort -u)"
while IFS= read -r k; do
[ -n "$k" ] || continue
new_keys="$new_keys [$one.$name]|$k"
done < <(comm -13 <(printf '%s\n' "$was") <(printf '%s\n' "$now"))
done < <(find "$axis" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort)
done < <(git ls-tree -d --name-only "$head:$axis" 2>/dev/null | sort)
done
if [ -n "$added" ] || [ -n "$new_providers" ] || [ -n "$new_keys" ]; then
+750 -9
View File
@@ -33,15 +33,59 @@ setup() {
export XDG_RUNTIME_DIR="$RUNTIME_DIR"
}
teardown() {
# An attach client this test backgrounded. `kill` only sends the signal, and
# the client - plus the script(1) and tmux processes under it - takes a
# moment to go. Removing its directories in that window is what produced
# "rm: cannot remove ...: Directory not empty": rm lists a directory,
# deletes what it saw, and the still-dying client creates something else
# before the rmdir. So the wait is for the process to be gone, not a sleep.
if [ -n "${attach_pid:-}" ]; then
kill "$attach_pid" 2>/dev/null || true
_await_gone kill -0 "$attach_pid"
fi
# Only ever the private server start_tmux created (its own TMUX_TMPDIR),
# never the operator's.
if [ -n "${TMUX_DIR:-}" ]; then
TMUX_TMPDIR="$TMUX_DIR" tmux kill-server 2>/dev/null || true
# kill-server returns once the server has been told, and the server
# unlinks its socket on its own schedule.
_await_gone env TMUX_TMPDIR="$TMUX_DIR" tmux has-session
fi
rm -rf "$CONFIG_DIR" "$RUNTIME_DIR" ${STUB_DIR:+"$STUB_DIR"} ${LAUNCH_DIR:+"$LAUNCH_DIR"} \
_rm_settled "$CONFIG_DIR" "$RUNTIME_DIR" ${STUB_DIR:+"$STUB_DIR"} ${LAUNCH_DIR:+"$LAUNCH_DIR"} \
${TMUX_DIR:+"$TMUX_DIR"} ${FAKE_HOME:+"$FAKE_HOME"}
}
# Waits until the given command FAILS, which is how each of the things above
# reports that it is gone. Bounded, and silent on expiry: teardown's job is to
# clean up, and a cleanup that fails a passing test is the bug being fixed.
_await_gone() {
local i
for i in $(seq 1 50); do
"$@" >/dev/null 2>&1 || return 0
sleep 0.1
done
return 1
}
# rm -rf, retried. The waits above remove the cause; this is the backstop for
# anything else that writes into a temp directory as it exits, and it reports
# what is left rather than failing the test - a leaked temp directory costs
# disk, and a red check on a passing test costs a re-run and some trust in the
# suite.
_rm_settled() {
local d i
for d in "$@"; do
[ -n "$d" ] || continue
for i in 1 2 3 4 5; do
rm -rf "$d" 2>/dev/null
[ -e "$d" ] || break
sleep 0.2
done
[ ! -e "$d" ] \
|| echo "teardown: $d survived removal, holding: $(ls -A "$d" 2>/dev/null | tr '\n' ' ')" >&3
done
}
lib() { bash -c 'source "$1"; shift; "$@"' _ "$LIB" "$@"; }
# Writes a [remote-access] config with enabled = on and base_url set (so
@@ -160,10 +204,22 @@ for arg in "$@"; do
[ -z "${SANDBOX_TEST_SETTINGS_COPY:-}" ] || cp "$SANDBOX_SETTINGS_FILE" "$SANDBOX_TEST_SETTINGS_COPY"
break
done
# The marker directory and what the host created inside it, while it still
# exists: bin/dejima's EXIT trap rm -rf's the whole thing, so from outside the
# run there is nothing left to look at. The host path comes off the mount
# argument, which is the only place this stub can learn it.
if [ -n "${SANDBOX_TEST_LEVEL_PROBE:-}" ]; then
for arg in "$@"; do
case "$arg" in
*:/run/dejima-notify)
[ ! -d "${arg%%:*}/level" ] || echo level >> "$SANDBOX_TEST_LEVEL_PROBE" ;;
esac
done
fi
exit 0
EOF
chmod +x "$LAUNCH_DIR/docker"
: > "$LAUNCH_DIR/sandbox-key"
printf "not-a-real-key\n" > "$LAUNCH_DIR/sandbox-key"
chmod 600 "$LAUNCH_DIR/sandbox-key"
PROJ="$(mktemp -d)"
}
@@ -188,6 +244,7 @@ launch() {
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
SANDBOX_TEST_SETTINGS_COPY="$LAUNCH_DIR/settings-copy.json" \
SANDBOX_TEST_LEVEL_PROBE="$LAUNCH_DIR/level-probe" \
SSH_AUTH_SOCK= ALLOWED_DOMAINS= \
"${DEJIMA_BIN:-$REPO_ROOT/bin/dejima}" "$@"
}
@@ -566,7 +623,9 @@ stub_docker_run_id() {
#!/bin/bash
case "\$1" in
ps) echo dejima-test-container ;;
inspect) echo "DEJIMA_RUN_ID=$1" ;;
# One line per container, both values JSON, which is the shape
# dejima-attach asks for so that one call answers for all of them.
inspect) echo '"/dejima-test-container" ["PATH=/usr/bin","DEJIMA_RUN_ID=$1"]' ;;
esac
exit 0
EOF
@@ -589,6 +648,9 @@ label=$3
started=${4:-$(date +%s)}
EOF
[ -z "${5:-}" ] || printf 'pid=%s\n' "$5" >> "$RUNTIME_DIR/dejima/sessions/$1"
# bin/dejima writes name= first; written last here on purpose, so the
# reader is exercised against a field order it does not control.
[ -z "${6:-}" ] || printf 'name=%s\n' "$6" >> "$RUNTIME_DIR/dejima/sessions/$1"
}
# A pid that is certain to be dead: spawn a process and reap it, so the number
@@ -674,6 +736,552 @@ dead_pid() {
[[ "$output" == *"no-tmux"* ]]
}
# A `docker` that records the launching window's name instead of its argv. The
# rename happens before the first docker call and is undone by the EXIT trap,
# so from outside the run there is no moment at which it is observable - the
# stub is standing in for that moment.
stub_window_probe() {
cat > "$LAUNCH_DIR/docker" <<EOF
#!/bin/bash
tmux display-message -p -t '$TARGET_PANE' '#{window_name}' >> "$LAUNCH_DIR/winname"
# And what the registry recorded for the same launch, which the exit trap
# removes too. The two have to be one value: the window is how an operator
# finds the session and the entry is how both front doors name it.
sed -n 's/^name=//p' "$RUNTIME_DIR"/dejima/sessions/* 2>/dev/null >> "$LAUNCH_DIR/regname"
exit 0
EOF
chmod +x "$LAUNCH_DIR/docker"
: > "$LAUNCH_DIR/winname"
: > "$LAUNCH_DIR/regname"
}
@test "an unnamed launch takes the project's name, and a colliding one takes a suffix" {
# Most launches pass no --name, so the default is what this feature mostly
# is. A collision must not stop a launch: several sessions against one
# project is the case the name exists for, not an error.
stub_launch
write_remote_config
start_tmux
stub_window_probe
local proj; proj="$(basename "$PROJ")"
TMUX_PANE="$TARGET_PANE" launch "$PROJ"
grep -qxF "$proj" "$LAUNCH_DIR/winname" \
|| { echo "window: $(cat "$LAUNCH_DIR/winname")"; return 1; }
# The window and the registry carry one value, not two derivations of it.
grep -qxF "$proj" "$LAUNCH_DIR/regname" \
|| { echo "registry: $(cat "$LAUNCH_DIR/regname")"; return 1; }
# A live session already holding the name. $$ is this bats process, which
# is alive by construction.
write_entry 0000000000000001 '%1' 'work:1' '' $$ "$proj"
: > "$LAUNCH_DIR/winname"
TMUX_PANE="$TARGET_PANE" launch "$PROJ"
grep -qxF "$proj-2" "$LAUNCH_DIR/winname" \
|| { echo "window: $(cat "$LAUNCH_DIR/winname")"; return 1; }
write_entry 0000000000000002 '%1' 'work:2' '' $$ "$proj-2"
: > "$LAUNCH_DIR/winname"
TMUX_PANE="$TARGET_PANE" launch "$PROJ"
grep -qxF "$proj-3" "$LAUNCH_DIR/winname" \
|| { echo "window: $(cat "$LAUNCH_DIR/winname")"; return 1; }
# The FIRST free suffix: a number a finished session gives back is used
# again rather than the count climbing for the life of the host.
rm "$RUNTIME_DIR/dejima/sessions/0000000000000001"
: > "$LAUNCH_DIR/winname"
TMUX_PANE="$TARGET_PANE" launch "$PROJ"
grep -qxF "$proj" "$LAUNCH_DIR/winname" \
|| { echo "window: $(cat "$LAUNCH_DIR/winname")"; return 1; }
# An entry whose launcher is gone is holding no name: the exit trap removes
# the entry, but a session killed outright leaves it behind.
write_entry 0000000000000003 '%1' 'work:3' '' "$(dead_pid)" "$proj"
: > "$LAUNCH_DIR/winname"
TMUX_PANE="$TARGET_PANE" launch "$PROJ"
grep -qxF "$proj" "$LAUNCH_DIR/winname" \
|| { echo "window: $(cat "$LAUNCH_DIR/winname")"; return 1; }
# An explicit --name is taken verbatim, collision or not: it was typed for
# this launch, and handing back something else would be worse than two rows
# that agree.
write_entry 0000000000000004 '%1' 'work:4' '' $$ 'mine'
: > "$LAUNCH_DIR/winname"
TMUX_PANE="$TARGET_PANE" launch --name mine "$PROJ"
grep -qxF 'mine' "$LAUNCH_DIR/winname" \
|| { echo "window: $(cat "$LAUNCH_DIR/winname")"; return 1; }
rm -rf "$PROJ"
}
@test "a tmux window already holding the name is a collision too" {
# The other source, and the one that works with remote access off: there is
# no registry then, and two windows wearing one name is exactly what the
# name is meant to prevent.
stub_launch
write_remote_config
start_tmux
stub_window_probe
local proj; proj="$(basename "$PROJ")"
tmux rename-window -t work:0 "$proj"
TMUX_PANE="$TARGET_PANE" launch "$PROJ"
grep -qxF "$proj-2" "$LAUNCH_DIR/winname" \
|| { echo "window: $(cat "$LAUNCH_DIR/winname")"; return 1; }
# But not this launch's own window, which may already carry the name from
# the session before it or from automatic-rename guessing the directory.
#
# Asserted on the registry rather than on the window: renaming a window by
# hand turns automatic-rename off, which is exactly the case where the
# launch leaves the name alone - so the window would read `$proj` here
# whatever label was chosen, and the assertion would hold for the wrong
# reason.
tmux rename-window -t work:0 'other'
tmux rename-window -t "$TARGET_PANE" "$proj"
: > "$LAUNCH_DIR/regname"
TMUX_PANE="$TARGET_PANE" launch "$PROJ"
grep -qxF "$proj" "$LAUNCH_DIR/regname" \
|| { echo "registry: $(cat "$LAUNCH_DIR/regname")"; return 1; }
rm -rf "$PROJ"
}
@test "a project name too long or too odd for a label is cut down, not refused" {
# --name refuses these shapes because the operator typed them. A directory
# name was not chosen for this, so a launch that stopped over one would be
# refusing over something it can fix itself.
stub_launch
write_remote_config
start_tmux
stub_window_probe
local long; long="$(printf 'x%.0s' {1..50})"
local deep="$PROJ/$long"
mkdir -p "$deep"
TMUX_PANE="$TARGET_PANE" launch "$deep"
[ "$(head -1 "$LAUNCH_DIR/winname")" = "${long:0:32}" ] \
|| { echo "window: $(head -1 "$LAUNCH_DIR/winname")"; return 1; }
# And the suffix still fits, which means the base gives room for it rather
# than the label growing past the cap.
write_entry 0000000000000001 '%1' 'work:1' '' $$ "${long:0:32}"
: > "$LAUNCH_DIR/winname"
TMUX_PANE="$TARGET_PANE" launch "$deep"
[ "$(head -1 "$LAUNCH_DIR/winname")" = "${long:0:30}-2" ] \
|| { echo "window: $(head -1 "$LAUNCH_DIR/winname")"; return 1; }
rm -rf "$PROJ"
}
@test "the launching tmux window is named for the session and handed back after" {
# tmux's own navigation - choose-window, the status line, the window list -
# shows a window name, and for a dozen sandboxes it showed a dozen copies
# of whatever automatic-rename made of the container command.
stub_launch
start_tmux
stub_window_probe
TMUX_PANE="$TARGET_PANE" launch --name 'release notes' "$PROJ"
grep -qxF 'release notes' "$LAUNCH_DIR/winname" \
|| { echo "window was named: $(cat "$LAUNCH_DIR/winname")"; return 1; }
# Handed back, so the window does not keep the name of a session that has
# exited. The option is the assertion rather than the name: tmux recomputes
# the name on its own schedule, but turning the option back on is what this
# does and what makes the recompute happen at all.
[ "$(tmux display-message -p -t "$TARGET_PANE" '#{automatic-rename}')" = 1 ] \
|| { echo "automatic-rename was left off"; return 1; }
# Without --name the project stands in, which is still better than the
# container command for finding one window among many.
: > "$LAUNCH_DIR/winname"
TMUX_PANE="$TARGET_PANE" launch "$PROJ"
grep -qxF "$(basename "$PROJ")" "$LAUNCH_DIR/winname" \
|| { echo "window was named: $(cat "$LAUNCH_DIR/winname")"; return 1; }
# A window the operator named by hand keeps that name. tmux turns
# automatic-rename off on rename, so the option is the evidence that the
# current name is tmux's guess rather than a choice to leave alone.
tmux rename-window -t "$TARGET_PANE" 'mine'
: > "$LAUNCH_DIR/winname"
TMUX_PANE="$TARGET_PANE" launch "$PROJ"
grep -qxF 'mine' "$LAUNCH_DIR/winname" \
|| { echo "the operator's own window name was taken: $(cat "$LAUNCH_DIR/winname")"; return 1; }
# But an explicit --name was typed for this launch, so it wins over that.
: > "$LAUNCH_DIR/winname"
TMUX_PANE="$TARGET_PANE" launch --name 'explicit' "$PROJ"
grep -qxF 'explicit' "$LAUNCH_DIR/winname" \
|| { echo "--name lost to a hand-named window: $(cat "$LAUNCH_DIR/winname")"; return 1; }
rm -rf "$PROJ"
}
# The level the hook would have written, for a session this suite registered
# by hand. $STATE_DIR/notify/<run-id>/level is the host's shape, not the
# session's, so writing it here is standing in for the hook and not for a
# sandbox reaching somewhere it should not.
write_level() { # <run-id> <state>
mkdir -p "$RUNTIME_DIR/dejima/notify/$1/level"
: > "$RUNTIME_DIR/dejima/notify/$1/level/$2"
}
@test "docker is asked twice for a whole listing, whatever it holds" {
# It used to run `docker ps` and then inspect containers until it found a
# match, per registry entry: quadratic in the number of sessions, on the
# listing both front doors open on. A dozen parallel sessions is the case
# this tool exists for, so the cost has to be linear in them.
write_remote_config
STUB_DIR="$(mktemp -d)"
cat > "$STUB_DIR/docker" <<EOF
#!/bin/bash
echo "\$1" >> "$BATS_TEST_TMPDIR/docker-calls"
case "\$1" in
ps) ls "$RUNTIME_DIR/dejima/sessions" | sed 's/^/c-/' ;;
# Every name it was handed, one line each: dejima-attach asks about all
# of them in one call, so a stub answering only for the last would
# silently prune every other session.
inspect)
for a in "\$@"; do
case "\$a" in
c-*) printf '"/%s" ["DEJIMA_RUN_ID=%s"]\n' "\$a" "\${a#c-}" ;;
esac
done ;;
esac
exit 0
EOF
chmod +x "$STUB_DIR/docker"
PATH="$STUB_DIR:$PATH"
: > "$BATS_TEST_TMPDIR/docker-calls"
local i
for i in 1 2 3 4; do write_entry "000000000000000$i" '%1' "work:$i"; done
run "$REPO_ROOT/bin/dejima-attach" --list
[ "$status" -eq 0 ]
[ "$(grep -c '^ps$' "$BATS_TEST_TMPDIR/docker-calls")" = 1 ] \
|| { echo "docker ps ran $(grep -c '^ps$' "$BATS_TEST_TMPDIR/docker-calls") times"; return 1; }
# One for all of them, not one each and not one per session: this is two
# round trips to the daemon whatever the host is running, where it was one
# plus one per container, and before that one plus one per container per
# session.
[ "$(grep -c '^inspect$' "$BATS_TEST_TMPDIR/docker-calls")" = 1 ] \
|| { echo "inspect ran $(grep -c '^inspect$' "$BATS_TEST_TMPDIR/docker-calls") times"; return 1; }
# And it still answered: a listing that asks nothing would also be cheap.
[ "$(printf '%s\n' "$output" | wc -l)" = 4 ]
}
@test "the picker's row is narrow enough for the phone that reads it" {
# Five columns made a row about eighty characters wide, which wrapped onto
# a second line on a phone and put the state and the age underneath the
# NEXT session's number. The two that went are in --list, which is where a
# script reads them: the tmux label is a coordinate nobody has to act on
# now that picking a session goes to it, and every container id shares a
# prefix with every other.
write_remote_config
stub_docker_sessions
start_tmux
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1' \
$(( $(date +%s) - 7200 )) $$ 'release-notes'
run script -qec "echo q | '$REPO_ROOT/bin/dejima-attach'" /dev/null
[ "$status" -eq 0 ]
local row
row="$(sed 's/\x1b\[[0-9;]*m//g' <<< "$output" | grep -F release-notes)"
[ -n "$row" ] || { echo "no row for the session"; return 1; }
[ "${#row}" -le 52 ] || { echo "row is ${#row} columns: $row"; return 1; }
[[ "$row" != *"work:1"* ]] || { echo "the tmux label is still in the row: $row"; return 1; }
[[ "$row" != *"c-0123456789"* ]] || { echo "the container id is still in the row: $row"; return 1; }
# Two hours, not a hundred and twenty minutes: three characters is what
# makes the row fit, and the sum is one an operator should not have to do.
[[ "$row" == *"2h"* ]] || { echo "age reads: $row"; return 1; }
# Both are still in the listing a script reads, in the units it wants.
run "$REPO_ROOT/bin/dejima-attach" --list
[[ "$output" == *"work:1"* ]] || { echo "--list lost the label: $output"; return 1; }
[[ "$output" == *"120m"* ]] || { echo "--list lost the minutes: $output"; return 1; }
}
@test "the name column is what the terminal has room for, and says when it cut one" {
# A fixed width is wrong in both directions at once: a desktop has room for
# every name and would cut them anyway, and a narrow phone would still
# overflow and wrap, which is the failure this layout exists to avoid.
write_remote_config
stub_docker_sessions
start_tmux
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1' '' $$ 'flashpoint-ultimate-server'
local row
run script -qec "stty cols 70; echo q | '$REPO_ROOT/bin/dejima-attach'" /dev/null
row="$(sed 's/\x1b\[[0-9;]*m//g' <<< "$output" | grep -F flashpoint)"
[[ "$row" == *"flashpoint-ultimate-server"* ]] \
|| { echo "a terminal with room still cut it: $row"; return 1; }
run script -qec "stty cols 50; echo q | '$REPO_ROOT/bin/dejima-attach'" /dev/null
row="$(sed 's/\x1b\[[0-9;]*m//g' <<< "$output" | grep -F flashpoint | tr -d '\r')"
[[ "$row" != *"flashpoint-ultimate-server"* ]] \
|| { echo "the name was not cut to fit: $row"; return 1; }
# Marked, because a name quietly losing its last characters is worse than a
# short column: two projects can differ only past the cut.
[[ "$row" == *"~"* ]] || { echo "the cut was silent: $row"; return 1; }
# The dot is one column and three bytes, and ${#} counts bytes wherever
# the locale is not UTF-8 - which is this suite. Swapped for one byte so
# the measurement is of columns, which is what has to fit.
row="${row//●/*}"
[ "${#row}" -le 50 ] || { echo "row is ${#row} columns on a 50-column terminal: $row"; return 1; }
}
@test "a session still building reaches the picker as starting, not as attachable" {
# It was the container column that said so, and that column is gone. A
# session whose image is still being built is not one to attach to yet.
write_remote_config
stub_docker_run_id ffffffffffffffff
start_tmux
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1' '' $$ 'building'
run script -qec "echo q | '$REPO_ROOT/bin/dejima-attach'" /dev/null
local row
row="$(sed 's/\x1b\[[0-9;]*m//g' <<< "$output" | grep -F building)"
[[ "$row" == *"starting"* ]] || { echo "row reads: $row"; return 1; }
[[ "$row" != *"attachable"* ]] || { echo "row reads: $row"; return 1; }
}
@test "an empty field does not shift the picker's columns" {
# A tab is IFS whitespace, so a run of them collapses into one delimiter.
# An entry written before the name field existed has an empty one, and the
# picker then read the tmux label as the name, the state as the age and the
# container id as the state - so a stale session was neither coloured as
# one nor labelled stale where it was being read.
write_remote_config
stub_docker_sessions
start_tmux
write_entry 0123456789abcdef '%99999' 'work:1'
run script -qec "echo q | '$REPO_ROOT/bin/dejima-attach'" /dev/null
[ "$status" -eq 0 ]
local plain; plain="$(sed 's/\x1b\[[0-9;]*m//g' <<< "$output")"
# The project is the first thing after the index and the dot, where the
# label used to land.
# [^[:space:]]+ for the dot rather than `.`, which matches one BYTE of a
# three-byte glyph outside a UTF-8 locale.
grep -qE '^[[:space:]]*1[[:space:]]+[^[:space:]]+[[:space:]]+myproject' <<< "$plain" \
|| { echo "row: $(grep -F myproject <<< "$plain")"; return 1; }
# And the state is in the state column, which is what colours it: under the
# shift this was the age column's muted grey.
[[ "$output" == *$'\033[91m'"stale"* ]] \
|| { echo "stale was not coloured as a failure state"; return 1; }
}
@test "the listing says which sessions want attention and which are working" {
# The bouncing-between-windows problem: every row said "attachable",
# which is true of all of them and the answer to a question nobody asked.
write_remote_config
stub_docker_sessions
start_tmux
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1' $(( $(date +%s) - 300 ))
write_entry fedcba9876543210 "$TARGET_PANE" 'work:2' $(( $(date +%s) - 200 ))
write_entry 00112233445566aa "$TARGET_PANE" 'work:3' $(( $(date +%s) - 100 ))
write_level 0123456789abcdef done
write_level fedcba9876543210 running
run "$REPO_ROOT/bin/dejima-attach" --list
[ "$status" -eq 0 ]
[[ "$output" == *"0123456789abcdef"*$'\t'"waiting"$'\t'* ]] \
|| { echo "stopped session: $output"; return 1; }
[[ "$output" == *"fedcba9876543210"*$'\t'"working"$'\t'* ]] \
|| { echo "running session: $output"; return 1; }
# Said nothing yet, so the plain reachability answer stands. A harness that
# reports no state at all has to keep working, and so does a session that
# started before its first prompt.
[[ "$output" == *"00112233445566aa"*$'\t'"attachable"$'\t'* ]] \
|| { echo "silent session: $output"; return 1; }
# waiting is the other half of the same question, for a harness that can
# say it: both render as one word, because an operator acts on them alike.
rm "$RUNTIME_DIR/dejima/notify/0123456789abcdef/level/done"
write_level 0123456789abcdef waiting
run "$REPO_ROOT/bin/dejima-attach" --list
[[ "$output" == *"waiting"* ]] || { echo "$output"; return 1; }
# The hook clears the siblings before writing, so holding two levels at
# once means a session has contradicted itself - which it can, the
# directory being its own. The answer that sends an operator to look is the
# one to be wrong with.
write_level 00112233445566aa running
write_level 00112233445566aa done
run "$REPO_ROOT/bin/dejima-attach" --list
[[ "$output" == *"00112233445566aa"*$'\t'"waiting"$'\t'* ]] \
|| { echo "contradicting session: $output"; return 1; }
rm "$RUNTIME_DIR/dejima/notify/00112233445566aa/level/done" \
"$RUNTIME_DIR/dejima/notify/00112233445566aa/level/running"
# A session that cannot be attached to has nothing useful to say about
# whether it wants attention, so reachability still wins the column.
write_entry ffeeddccbbaa9988 '%99999' 'work:9' $(( $(date +%s) - 50 ))
write_level ffeeddccbbaa9988 done
run "$REPO_ROOT/bin/dejima-attach" --list
[[ "$output" == *"ffeeddccbbaa9988"*$'\t'"stale"$'\t'* ]] \
|| { echo "stale session: $output"; return 1; }
# Oldest first, and by start time rather than by state: the numbers are how
# a session is picked, so an order that followed the state would renumber
# the rows between an operator reading one and typing it.
run bash -c "'$REPO_ROOT/bin/dejima-attach' --list | cut -f1 | tr '\n' ' '"
[ "$output" = "0123456789abcdef fedcba9876543210 00112233445566aa ffeeddccbbaa9988 " ] \
|| { echo "order was: $output"; return 1; }
}
@test "--count-waiting counts the sessions an operator has to go to, and asks docker nothing" {
# For a tmux status line, which re-runs it every few seconds: the point of
# the number is to not have to open anything, so it must not cost a round
# trip per container to produce.
write_remote_config
STUB_DIR="$(mktemp -d)"
cat > "$STUB_DIR/docker" <<EOF
#!/bin/bash
echo "\$1" >> "$BATS_TEST_TMPDIR/docker-calls"
exit 1
EOF
chmod +x "$STUB_DIR/docker"
PATH="$STUB_DIR:$PATH"
: > "$BATS_TEST_TMPDIR/docker-calls"
start_tmux
# Two waiting, one working, one waiting but unreachable: the last is a
# session nothing can be done about, and counting it sends an operator
# looking for a window that is not there.
write_entry 0000000000000001 "$TARGET_PANE" 'work:1' '' $$
write_entry 0000000000000002 "$TARGET_PANE" 'work:2' '' $$
write_entry 0000000000000003 "$TARGET_PANE" 'work:3' '' $$
write_entry 0000000000000004 '%99999' 'work:4' '' $$
write_level 0000000000000001 done
write_level 0000000000000002 waiting
write_level 0000000000000003 running
write_level 0000000000000004 done
run "$REPO_ROOT/bin/dejima-attach" --count-waiting
[ "$status" -eq 0 ]
[ "$output" = 2 ] || { echo "counted $output"; return 1; }
[ ! -s "$BATS_TEST_TMPDIR/docker-calls" ] \
|| { echo "docker was asked: $(cat "$BATS_TEST_TMPDIR/docker-calls")"; return 1; }
# And a dead launcher is not waiting for anyone.
write_entry 0000000000000005 "$TARGET_PANE" 'work:5' '' "$(dead_pid)"
write_level 0000000000000005 done
run "$REPO_ROOT/bin/dejima-attach" --count-waiting
[ "$output" = 2 ] || { echo "counted $output with a dead launcher"; return 1; }
}
@test "--counts answers for every state in one pass, and asks docker nothing" {
# Two numbers on a status bar cost two invocations otherwise, and this is
# the one thing here that runs on a timer.
write_remote_config
STUB_DIR="$(mktemp -d)"
cat > "$STUB_DIR/docker" <<EOF
#!/bin/bash
echo "\$1" >> "$BATS_TEST_TMPDIR/docker-calls"
exit 1
EOF
chmod +x "$STUB_DIR/docker"
PATH="$STUB_DIR:$PATH"
: > "$BATS_TEST_TMPDIR/docker-calls"
start_tmux
write_entry 0000000000000001 "$TARGET_PANE" 'work:1' '' $$
write_entry 0000000000000002 "$TARGET_PANE" 'work:2' '' $$
write_entry 0000000000000003 "$TARGET_PANE" 'work:3' '' $$
write_entry 0000000000000004 '%99999' 'work:4' '' $$
write_entry 0000000000000005 '' '' '' $$
write_level 0000000000000001 done
write_level 0000000000000002 running
run "$REPO_ROOT/bin/dejima-attach" --counts
[ "$status" -eq 0 ]
[ "$output" = "waiting=1 working=1 attachable=1 stale=1 no-tmux=1 total=5" ] \
|| { echo "counts read: $output"; return 1; }
[ ! -s "$BATS_TEST_TMPDIR/docker-calls" ] \
|| { echo "docker was asked: $(cat "$BATS_TEST_TMPDIR/docker-calls")"; return 1; }
# The words are the picker's, so a number on a status bar and a row on a
# screen say the same thing about the same session.
run "$REPO_ROOT/bin/dejima-attach" --list
[[ "$output" == *"waiting"* ]] && [[ "$output" == *"no-tmux"* ]] \
|| { echo "the listing uses different words: $output"; return 1; }
# A session whose launcher is gone is not counted at all: the listing is
# where those are found, and a status bar that never reaches zero stops
# being read.
write_entry 0000000000000006 "$TARGET_PANE" 'work:6' '' "$(dead_pid)"
write_level 0000000000000006 done
run "$REPO_ROOT/bin/dejima-attach" --counts
[ "$output" = "waiting=1 working=1 attachable=1 stale=1 no-tmux=1 total=5" ] \
|| { echo "a dead launcher was counted: $output"; return 1; }
}
@test "--state-dir names the directory the registry is actually in" {
# For anything on this host that wants to read the states without
# re-deriving which of XDG_RUNTIME_DIR and /tmp this user got.
write_remote_config
start_tmux
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1'
run "$REPO_ROOT/bin/dejima-attach" --state-dir
[ "$status" -eq 0 ]
[ -d "$output" ] || { echo "not a directory: $output"; return 1; }
[ -f "$output/sessions/0123456789abcdef" ] \
|| { echo "the registry is not under $output"; return 1; }
# And the levels, which is the half an outside reader is actually after.
write_level 0123456789abcdef running
[ -e "$output/notify/0123456789abcdef/level/running" ] \
|| { echo "the levels are not under $output"; return 1; }
}
@test "--watch redraws the list until something is typed" {
write_remote_config
stub_docker_sessions
start_tmux
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1'
# Nothing typed for longer than one interval, then a quit. Two drawings of
# the heading is the assertion: the one-shot picker would print it once and
# then sit on a read that never returns.
run script -qec "{ sleep 8; echo q; } | '$REPO_ROOT/bin/dejima-attach' --watch" /dev/null
[ "$status" -eq 0 ]
[ "$(grep -c 'live sessions' <<< "$output")" -ge 2 ] \
|| { echo "drew $(grep -c 'live sessions' <<< "$output") times"; return 1; }
# And it is still the picker: a number attaches rather than being read as
# part of the next redraw. Nothing here can attach - there is no terminal
# for it - but reaching the attempt at all is what distinguishes picking
# from the prompt timing out again.
run script -qec "echo 1 | '$REPO_ROOT/bin/dejima-attach' --watch" /dev/null
[[ "$output" == *"live sessions"* ]]
[ "$(grep -c 'live sessions' <<< "$output")" = 1 ] \
|| { echo "a typed choice did not end the loop"; return 1; }
}
@test "a session's name reaches both the listing and the picker" {
# The case the name exists for: two sessions, one workspace, so the
# project column answers nothing. --list carries both fields; the picker
# has one column for the two and spends it on whichever is more specific.
write_remote_config
stub_docker_sessions
start_tmux
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1' '' '' 'release notes'
write_entry fedcba9876543210 "$TARGET_PANE" 'work:2'
run "$REPO_ROOT/bin/dejima-attach" --list
[ "$status" -eq 0 ]
[[ "$output" == *"0123456789abcdef"$'\t'"release notes"$'\t'"work:1"$'\t'"myproject"$'\t'* ]] \
|| { echo "named row: $output"; return 1; }
# An entry from before the field existed: present and empty rather than
# absent, so a reader splitting on tabs does not find the project where
# the name should be. A launch always writes one now.
[[ "$output" == *"fedcba9876543210"$'\t'$'\t'"work:2"$'\t'"myproject"$'\t'* ]] \
|| { echo "unnamed row: $output"; return 1; }
run script -qec "echo q | '$REPO_ROOT/bin/dejima-attach'" /dev/null
[ "$status" -eq 0 ]
[[ "$output" == *"release notes"* ]] \
|| { echo "the picker dropped the name: $output"; return 1; }
[[ "$output" == *"myproject"* ]] \
|| { echo "the picker dropped the unnamed row's project: $output"; return 1; }
}
@test "--list marks a session whose pane is gone as stale" {
write_remote_config
stub_docker_run_id 0123456789abcdef
@@ -691,7 +1299,15 @@ stub_docker_sessions() {
#!/bin/bash
case "\$1" in
ps) ls "$RUNTIME_DIR/dejima/sessions" | sed 's/^/c-/' ;;
inspect) name="\${!#}"; echo "DEJIMA_RUN_ID=\${name#c-}" ;;
# Every name it was handed, one line each: dejima-attach asks about all
# of them in one call, so a stub answering only for the last would
# silently prune every other session.
inspect)
for a in "\$@"; do
case "\$a" in
c-*) printf '"/%s" ["DEJIMA_RUN_ID=%s"]\n' "\$a" "\${a#c-}" ;;
esac
done ;;
esac
exit 0
EOF
@@ -821,6 +1437,46 @@ start_tmux() {
done
}
@test "inside tmux the picker switches the client instead of nesting a session" {
# tmux refuses to attach a session from inside one of its own panes, so on
# the operator's own machine the picker could list every session and then
# reach none of them. Switching is also what makes it a window switcher,
# which is the thing a dozen parallel sessions actually need.
write_remote_config
stub_docker_run_id 0123456789abcdef
start_tmux
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1'
# A real client, because switch-client has nothing to move without one,
# and it starts on window 0 while the session being picked is window 1.
script -qec "tmux attach -t work" /dev/null >/dev/null 2>&1 &
client_pid=$!
for _ in $(seq 100); do
[ -n "$(tmux list-clients -F '#{client_session}' 2>/dev/null)" ] && break
sleep 0.1
done
tmux select-window -t work:0
[ "$(tmux display-message -p -t work '#{window_index}')" = "0" ]
# Run from inside a pane of that session, which is how an operator reaches
# it and the only way $TMUX is set for the right server.
tmux new-window -t work -d \
"'$REPO_ROOT/bin/dejima-attach' 0123456789abcdef; touch '$BATS_TEST_TMPDIR/switched'"
for _ in $(seq 100); do
[ -e "$BATS_TEST_TMPDIR/switched" ] && break
sleep 0.1
done
[ -e "$BATS_TEST_TMPDIR/switched" ] || { echo "dejima-attach never returned"; return 1; }
# The client moved to the sandbox's own window, and no throwaway session
# was created to do it.
[ "$(tmux display-message -p -t work '#{window_index}')" = "1" ] \
|| { echo "client is on window $(tmux display-message -p -t work '#{window_index}')"; return 1; }
run tmux has-session -t '=dejima-0123456789abcdef'
[ "$status" -ne 0 ] || { echo "a grouped session was created anyway"; return 1; }
kill "$client_pid" 2>/dev/null || true
}
@test "attaching creates a grouped session on the requested window and disposes of it on detach" {
# The positive direction, which is where every interesting tmux mistake
# lives: an invalid set-option target, or setting destroy-unattached while
@@ -1327,6 +1983,46 @@ EOF
rm -rf "$NOTIFY" "$payload_file"
}
@test "the notify hook keeps a level beside the edge, and only the edge is pushable" {
# The edge markers are consumed by the watcher, so after a push there is
# nothing left to read - and with no watcher running, nothing has read them
# at all. The level answers "which state is this session in now" for the
# picker and the status line, which is a different question from "it just
# changed" and needs an answer that survives being read.
NOTIFY="$(mktemp -d)"
mkdir "$NOTIFY/level"
# `running` is level-only: the watcher iterates waiting and done, so an
# edge marker it never looks for would be a file nothing ever removes.
DEJIMA_NOTIFY_DIR="$NOTIFY" run "$REPO_ROOT/hooks/dejima-notify.sh" running
[ "$status" -eq 0 ]
[ "$(ls -A "$NOTIFY" | sort | tr '\n' ' ')" = "level " ] \
|| { echo "running left an edge marker: $(ls -A "$NOTIFY")"; return 1; }
[ "$(ls -A "$NOTIFY/level")" = running ]
[ ! -s "$NOTIFY/level/running" ]
# Exactly one level at a time, so a reader never has to choose.
DEJIMA_NOTIFY_DIR="$NOTIFY" run "$REPO_ROOT/hooks/dejima-notify.sh" done
[ "$status" -eq 0 ]
[ "$(ls -A "$NOTIFY/level")" = done ] \
|| { echo "level holds: $(ls -A "$NOTIFY/level")"; return 1; }
[ -f "$NOTIFY/done" ] || { echo "done left no edge marker"; return 1; }
# The same whitelist covers the level: a name outside the enum writes
# neither, and leaves the state that was there.
DEJIMA_NOTIFY_DIR="$NOTIFY" run "$REPO_ROOT/hooks/dejima-notify.sh" 'running;done'
[ "$status" -eq 0 ]
[ "$(ls -A "$NOTIFY/level")" = done ]
# A run directory with no level/ is a launch from before it existed. The
# edge still works there, which is what keeps notifications independent.
OLD="$(mktemp -d)"
DEJIMA_NOTIFY_DIR="$OLD" run "$REPO_ROOT/hooks/dejima-notify.sh" waiting
[ "$status" -eq 0 ]
[ -f "$OLD/waiting" ]
rm -rf "$NOTIFY" "$OLD"
}
@test "the notify hook refuses any event name outside the enum" {
NOTIFY="$(mktemp -d)"
for bad in 'error' '../escape' 'waiting done' '' '$(id)' 'waiting;done' '.'; do
@@ -1375,6 +2071,21 @@ EOF
run jq -r '[.hooks[][].hooks[].command] | join(" ")' "$LAUNCH_DIR/settings-copy.json"
[[ "$output" != *operator-desktop-notify.sh* ]]
[[ "$output" != *operator-tts.sh* ]]
# The complement of Stop, for the level and not for the phone: without it
# a picker cannot tell "this session has stopped" from "nothing has been
# heard from this session". It pushes nothing, because the hook writes no
# edge marker for `running`.
grep -q 'dejima-notify.sh running' "$LAUNCH_DIR/settings-copy.json" \
|| { echo "nothing marks the session as working again"; return 1; }
[ "$(jq -r '.hooks.UserPromptSubmit[0].hooks[0].command' "$LAUNCH_DIR/settings-copy.json")" \
= "/usr/local/lib/dejima/hooks/dejima-notify.sh running" ]
# And the directory the hook writes that level into is the host's to
# create, like the marker directory it sits in. Observed from inside the
# run, because the EXIT trap takes the whole tree with it.
grep -qxF level "$LAUNCH_DIR/level-probe" \
|| { echo "no level directory was created inside the marker directory"; return 1; }
rm -rf "$PROJ"
}
@@ -2003,15 +2714,16 @@ EOF
}
ttyd_log() { cat "$STUB_DIR/ttyd-argv" 2>/dev/null; }
# Reports no listener, whatever the host is actually running. Opt-in, and
# deliberately not part of stub_ttyd: the port-in-use refusal has its own test
# below, which binds a real socket and needs the real ss to see it.
# Reports no listener, whatever the host is actually running. Called beside
# stub_ttyd by every ttyd test but one, and deliberately not folded into it:
# the port-in-use refusal binds a real socket and needs the real ss to see it.
#
# For every OTHER ttyd test the port being free is a precondition, not the
# subject - and one that was being inherited from the machine rather than
# stated. The default-port case asserts against 7681, which is exactly the
# port a real dejima-ttyd front door holds, so it failed on any host running
# the feature: the machine a developer is most likely to run this suite on.
# The rest name 12345, which is free on most machines and on none reliably.
# Same shape as #56 - a test reading its environment instead of setting it.
stub_ss_no_listeners() {
STUB_DIR="${STUB_DIR:-$(mktemp -d)}"
@@ -2087,6 +2799,8 @@ unit_exec_start() {
@test "dejima-ttyd refuses to start when something else already holds its port" {
command -v ss >/dev/null 2>&1 || skip "ss is not available"
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
# The one ttyd test with no stub_ss_no_listeners beside stub_ttyd: ss
# seeing the socket bound below is the subject here, not a precondition.
stub_ttyd
# The distro's ttyd package ships an enabled service that binds every
@@ -2143,6 +2857,7 @@ PY
@test "dejima-ttyd refuses to start when remote access is unconfigured, before ttyd is reached" {
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
stub_ttyd
stub_ss_no_listeners
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -ne 0 ]
[[ "$output" == *"not configured"* ]]
@@ -2254,6 +2969,7 @@ PY
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
write_remote_config 'ttyd_port = 99999'
stub_ttyd
stub_ss_no_listeners
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -ne 0 ]
# Named, so this cannot pass because ttyd was missing or the config was.
@@ -2270,6 +2986,7 @@ PY
# currently happens to agree with it.
write_remote_config 'ttyd_port = 12345'
stub_ttyd
stub_ss_no_listeners
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -eq 0 ]
assert_ttyd_argv 12345
@@ -2299,6 +3016,7 @@ PY
local bad
for bad in 0 7 73 999 abc 1e2 -20; do
stub_ttyd
stub_ss_no_listeners
: > "$STUB_DIR/ttyd-argv"
write_remote_config 'ttyd_port = 12345' "ttyd_font_size = $bad"
run "$REPO_ROOT/bin/dejima-ttyd"
@@ -2319,6 +3037,7 @@ PY
# moment either default moves.
write_remote_config 'ttyd_port = 12345'
stub_ttyd
stub_ss_no_listeners
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -eq 0 ]
assert_ttyd_argv 12345
@@ -2347,6 +3066,7 @@ PY
# a host-wide "off" written here would be a different thing from silence.
write_remote_config 'ttyd_port = 12345'
stub_ttyd
stub_ss_no_listeners
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -eq 0 ]
run grep -c 'buttonColors' "$STUB_DIR/ttyd-argv"
@@ -2378,6 +3098,7 @@ PY
# would be wrong for whichever half of the clients it did not match.
write_remote_config 'ttyd_port = 12345'
stub_ttyd
stub_ss_no_listeners
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -eq 0 ]
run grep -c 'focusOnTap' "$STUB_DIR/ttyd-argv"
@@ -2413,6 +3134,7 @@ PY
local bad
for bad in b M-b C-ab C- ctrl-a 2 'C-\' ; do
stub_ttyd
stub_ss_no_listeners
: > "$STUB_DIR/ttyd-argv"
write_remote_config 'ttyd_port = 12345' "ttyd_tmux_prefix = $bad"
run "$REPO_ROOT/bin/dejima-ttyd"
@@ -2442,6 +3164,7 @@ PY
"$REPO_ROOT/bin/dejima-provider-lib.sh" "$fake/bin/"
write_remote_config 'ttyd_port = 12345'
stub_ttyd
stub_ss_no_listeners
run "$fake/bin/dejima-ttyd"
[ "$status" -ne 0 ]
[[ "$output" == *"$fake/web/index.html"* ]]
@@ -2459,7 +3182,7 @@ PY
local web="$REPO_ROOT/web"
[ -f "$web/index.html" ]
[ -f "$web/sources.sha256" ]
run bash -c "cd '$web' && sha256sum src/* package-lock.json | LC_ALL=C sort -k2"
run bash -c "cd '$web' && sha256sum build.mjs src/* package-lock.json | LC_ALL=C sort -k2"
[ "$status" -eq 0 ]
[ "$output" = "$(cat "$web/sources.sha256")" ] || {
echo "web/sources.sha256 is stale. Rebuild it:" >&2
@@ -2497,10 +3220,26 @@ PY
# restricts outbound traffic to an allowlist, so a CDN reference would not
# resolve even from a browser outside it if the operator is on the same
# restricted network.
#
# A `data:` URI is exempt and only that: it carries its bytes in the
# attribute, so it fetches nothing and neither reason above applies to it.
# The favicon is one. Matched per attribute rather than per line, so a data
# URI on a line cannot carry a real reference past this beside it.
local page="$REPO_ROOT/web/index.html"
[ -f "$page" ]
run grep -nEi '<(script|link|img|iframe)[^>]+(src|href)=' "$page"
run bash -c "grep -oEi '<(script|link|img|iframe)[^>]+(src|href)=\"[^\"]*\"' '$page' \
| grep -vEi '(src|href)=\"data:'"
[ "$status" -ne 0 ] || { echo "the page references an external asset: $output" >&2; false; }
# And the exemption is not a hole: anything that is not a data URI still
# fails, checked against a copy rather than trusted.
local probe="$BATS_TEST_TMPDIR/probe.html"
sed 's|<link rel="icon"|<link rel="stylesheet" href="https://cdn.example.com/x.css"><link rel="icon"|' \
"$page" > "$probe"
run bash -c "grep -oEi '<(script|link|img|iframe)[^>]+(src|href)=\"[^\"]*\"' '$probe' \
| grep -vEi '(src|href)=\"data:'"
[ "$status" -eq 0 ] \
|| { echo "the check no longer catches a real external reference" >&2; false; }
}
@test "the terminal page's own unit tests pass" {
@@ -2525,6 +3264,7 @@ PY
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
write_remote_config 'ttyd_port = 12345'
stub_ttyd
stub_ss_no_listeners
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -eq 0 ]
# Executed, not grepped: the flag has to reach ttyd's argv.
@@ -2928,6 +3668,7 @@ nginx_blocks() {
printf '[remote-access]\nenabled = on\nbase_url = https://d.example\nttyd_port = 12345\n' \
> "$FAKE_HOME/.config/dejima/config"
stub_ttyd
stub_ss_no_listeners
local exec_start
exec_start="$(unit_exec_start dejima-ttyd.service "$FAKE_HOME")"
+87 -26
View File
@@ -37,43 +37,84 @@ PRIVILEGED=" runtime ci-runner "
# is a path nobody has decided about, and guessing "none" there is how a change
# ships having been checked by nothing.
suites_for() {
# Every suite that READS the path, not the one that owns it. static.bats
# asserts invariants over the whole tree and config.bats sources most of
# the libraries, so both appear far more often than a by-area reading
# suggests - and leaving them out is what made the --changed lane report an
# edit as green from a selection that never loaded the file.
#
# The map is checked against the suites mechanically; see the test named
# for it in static.bats. Over-selecting costs a suite run, under-selecting
# costs a missed regression, so a rule here errs wide.
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-audit) echo 'audit config proxy remote static' ;;
bin/dejima-update-check) echo 'config project remote static' ;;
bin/dejima-epitaph) echo 'config static' ;;
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 ;;
bin/dejima-color-lib.sh) echo 'remote project config static' ;;
bin/dejima-tui-lib.sh) echo 'remote project static' ;;
bin/dejima-conversation-ship) echo 'conversation static' ;;
bin/dejima-share) echo 'share static' ;;
# 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 ;;
bin/dejima-slug-lib.sh) echo 'share conversation audit project static' ;;
bin/dejima-credential-proxy|docker/credential-proxy-forwarder.js) echo 'proxy static' ;;
bin/dejima-credential-scan*) echo 'config static' ;;
bin/dejima-project-lib.sh|bin/dejima-project|bin/dejima-dep) \
echo 'project config launch providers remote static' ;;
bin/dejima-allow) echo 'project static config' ;;
bin/dejima-attach) echo 'remote config project static' ;;
bin/dejima-ttyd|bin/dejima-notify-watch) echo 'remote config static' ;;
web/*|systemd/*) echo 'remote static' ;;
bin/dejima-provider-lib.sh) echo 'providers config remote share static' ;;
bin/dejima-harness-lib.sh) echo 'providers config remote static' ;;
bin/harness/*) echo 'providers remote static' ;;
# A manifest is read by more than the suite that owns the schema: the
# audit reads a workflow's briefing, conversation.bats sed-injects a
# harness.conf, and config.bats copies the whole set in.
forges/*|workflows/*|harnesses/*|tools/*) \
echo 'providers static audit config conversation' ;;
install.sh) echo 'config audit providers remote static' ;;
mirror/*) echo 'mirror static' ;;
# 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 ;;
hooks/*) echo 'static config providers project remote' ;;
.gitea/*|.shellcheckrc) echo static ;;
.dejima/*) echo 'static config project providers' ;;
test/*.bats) local n="${1##*/}"; echo "${n%.bats}" ;;
# A suite file is read by static.bats as well as run by bats: it
# asserts invariants over the text of the others, so an edit to one
# that only selected itself was reported green by a lane that never
# loaded the assertions about it.
test/*.bats) local n="${1##*/}"; echo "${n%.bats} static" ;;
# Fixtures are executed by the suites that consume them, not merely
# read: fake-anthropic.py is the upstream in proxy.bats (and in
# runtime.bats, which no confined selection can carry), and
# answer-prompt.py drives config.bats' prompts.
test/fixtures/*) echo 'proxy static config' ;;
test/*) echo static ;;
# Last of the specific rules, not before them: a .md under test/ or
# .dejima/ belongs to whatever reads that directory, and this pattern
# would otherwise answer for it. The docs themselves are asserted
# against by more than static.bats - the README's axis lists, the
# provider tables, the nginx example and the remote-access page each
# have a suite checking the code agrees with them, and mirror.bats runs
# the GitHub rewrite over them.
#
# So a doc-only edit runs more than static alone. That is the cost of
# the lane answering for what reads a file, and it is the safe
# direction: narrowing this back would report a doc change as green
# from suites that assert against it.
docs/*|README.md|*.md) echo 'static config providers remote mirror' ;;
*) echo ALL ;;
esac
}
@@ -208,6 +249,15 @@ fi
bats_flags=()
[ "$jobs" -gt 1 ] && bats_flags=(--jobs "$jobs" --no-parallelize-across-files)
# What this run asks the machine for, for the fixtures that wait on a bounded
# deadline. They multiply their bounds by how oversubscribed that makes it
# rather than carrying a number picked to survive the gate, which is a number
# that is then far too generous anywhere else (#295, test/fixtures/loadscale.py).
#
# Exported rather than passed, because it has to reach a python fixture several
# processes below bats.
export DEJIMA_TEST_CONCURRENCY=$(( ${#selected[@]} * jobs ))
{
if [ "$lane" = changed ]; then
printf 'lane: CHANGED - %d of %d confined suites, from %d changed path(s)\n' \
@@ -246,14 +296,21 @@ wait
# suite that fails to run at all - a syntax error, or duplicate test names,
# which bats refuses outright - exits nonzero having run nothing, and reporting
# that as "0 failures" is how a green board comes to mean nothing.
#
# Skips are counted and shown separately because bats emits one as `ok N ...
# # skip`, so a test that decided not to run is indistinguishable from one that
# passed. Several here skip on host state - an empty /opt/dejima, a missing
# tool - so the column is the only thing that says how much of the suite the
# number above actually covers.
failed=0
printf '%-16s %6s %8s\n' suite ran failed
printf '%-16s %6s %8s %8s\n' suite ran skipped failed
for s in "${selected[@]}"; do
ran="$(grep -cE '^(ok|not ok)' "$out/$s")"
emitted="$(grep -cE '^(ok|not ok)' "$out/$s")"
n_skipped="$(grep -cE '^ok [0-9]+ .* # skip' "$out/$s")"
bad="$(grep -cE '^not ok' "$out/$s")"
status="$(cat "$out/$s.status" 2>/dev/null || echo 1)"
printf '%-16s %6s %8s' "$s" "$ran" "$bad"
if [ "$ran" -eq 0 ]; then
printf '%-16s %6s %8s %8s' "$s" "$(( emitted - n_skipped ))" "$n_skipped" "$bad"
if [ "$emitted" -eq 0 ]; then
printf ' <- ran nothing\n'
failed=1
elif [ "$bad" -gt 0 ] || [ "$status" -ne 0 ]; then
@@ -264,14 +321,18 @@ for s in "${selected[@]}"; do
fi
done
for s in "${selected[@]}"; do
grep -hE '^ok [0-9]+ .* # skip' "$out/$s" | sed -E "s/^ok [0-9]+ / $s: /"
done | { grep . && echo " (skipped, so the counts above do not cover them)"; } || true
if [ "$failed" -ne 0 ]; then
echo
for s in "${selected[@]}"; do
ran="$(grep -cE '^(ok|not ok)' "$out/$s")"
emitted="$(grep -cE '^(ok|not ok)' "$out/$s")"
bad="$(grep -cE '^not ok' "$out/$s")"
{ [ "$bad" -gt 0 ] || [ "$ran" -eq 0 ]; } || continue
{ [ "$bad" -gt 0 ] || [ "$emitted" -eq 0 ]; } || continue
echo "--- $s"
if [ "$ran" -eq 0 ]; then
if [ "$emitted" -eq 0 ]; then
head -20 "$out/$s"
else
grep -E '^not ok' -A 20 "$out/$s"
+206 -29
View File
@@ -476,6 +476,11 @@ teardown() {
--format '{{.Names}}' 2>/dev/null)
docker images -q "localhost/dejima-test-*:$suffix" 2>/dev/null \
| xargs -r docker rmi -f >/dev/null 2>&1 || true
# Volumes too, for the same reason and in the same namespace: a test that
# makes one removes it itself, and this is what covers the run that was
# interrupted between the two.
docker volume ls -q --filter "name=^dejima-test-.*$suffix\$" 2>/dev/null \
| xargs -r docker volume rm >/dev/null 2>&1 || true
}
# The capability list bin/dejima gives the filter container, read out of the
@@ -518,17 +523,39 @@ start_pair() { # <purpose|""> <image> [docker run args...]
filter="$(test_container_name "$purpose-filter")"
fi
# The run's cached GitHub ranges, when the job fetched them. Each filter
# fetched its own, which is twenty-seven requests in about five minutes
# across this suite - inside the hourly ceiling a token buys, and exactly
# the burst GitHub's secondary limit answers with a 403 (#357).
#
# Read-only, and only ever this one file. egress-policy refuses a file://
# source unless SANDBOX_TEST_MODE is set, which start_pair's callers supply
# and no launch does.
#
# Absent when the job could not fetch, or when the suite is run by hand
# outside CI: every container then fetches for itself, which is the
# behaviour this replaces rather than a failure.
local -a meta=()
if [ -r /tmp/dejima-ci/github-meta.json ]; then
meta=(-v /tmp/dejima-ci/github-meta.json:/run/forge-meta-cache.json:ro
-e EGRESS_FORGE_META_URL=file:///run/forge-meta-cache.json
-e SANDBOX_TEST_MODE=1)
fi
local -a fcaps=() scaps=()
while IFS= read -r cap; do fcaps+=(--cap-add="$cap"); done < <(launcher_filter_cap_add)
while IFS= read -r cap; do scaps+=(--cap-add="$cap"); done < <(compose_cap_add)
[ "${#fcaps[@]}" -gt 0 ] || { echo "no --cap-add found in bin/dejima's filter run" >&2; return 1; }
[ "${#scaps[@]}" -gt 0 ] || { echo "no cap_add found in docker-compose.yml" >&2; return 1; }
# The cache goes to the FILTER only: it is the half that fetches the ranges,
# and the session container has no business holding a path that names them.
docker run -d --init --name "$filter" \
--cap-drop=ALL "${fcaps[@]}" \
--security-opt=no-new-privileges:true \
--sysctl net.ipv4.ip_unprivileged_port_start=0 \
--network "$(network_name)" \
${meta[@]+"${meta[@]}"} \
"$@" \
--entrypoint /usr/local/bin/filter-entrypoint.sh \
"$image" >/dev/null
@@ -1457,11 +1484,37 @@ session_pid() { # [container], defaulting to the file-wide one
# running session, and it is the operator's to run from the host. From
# inside the session it must do nothing - it is the same binary in the same
# image, and only the container it runs in differs.
# What the policy would have CHANGED is re-read after each refusal, not
# just the exit status checked: an amendment that landed and then failed on
# something later would exit non-zero with the destination permitted, which
# is the outcome this test is named for and the one a status cannot see.
#
# The entries file and the resolver configuration, because a permit for a
# NAME writes both - the address sets only ever hold addresses, so grepping
# those for a hostname is a check that cannot fail.
local before_entries before_conf after_entries after_conf
before_entries="$(dexec_filter cat /run/egress-policy/entries)"
before_conf="$(dexec_filter cat /run/egress-policy/resolver.conf)"
[ -n "$before_entries" ] && [ -n "$before_conf" ] \
|| { echo "could not read the policy's own record of what is permitted"; return 1; }
run dexec_node /usr/local/sbin/egress-policy permit evil.example.com
[ "$status" -ne 0 ]
run docker exec -u root "$(container_name)" /usr/local/sbin/egress-policy permit evil.example.com
[ "$status" -ne 0 ] \
|| { echo "root in the session container amended the permitted set"; return 1; }
after_entries="$(dexec_filter cat /run/egress-policy/entries)"
after_conf="$(dexec_filter cat /run/egress-policy/resolver.conf)"
[ "$after_entries" = "$before_entries" ] \
|| { echo "the entries file changed despite both refusals"; return 1; }
[ "$after_conf" = "$before_conf" ] \
|| { echo "the resolver configuration changed despite both refusals"; return 1; }
# Named directly as well, so this does not rest on two snapshots matching:
# an entry already present before the test would make them match anyway.
grep -q evil.example <<< "$after_entries$after_conf" \
&& { echo "evil.example.com reached the policy's record of what is permitted"; return 1; }
return 0
}
@test "node has no working sudo" {
@@ -1504,6 +1557,29 @@ session_pid() { # [container], defaulting to the file-wide one
# container.
run dexec /usr/local/sbin/strip-privilege-bits --check
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
# And once more outside those roots. --check walks the directories the
# sweeper walks, which deliberately exclude /tmp, /run, /mnt and /media -
# the sweeper has no reason to visit them, but an image that shipped
# something there would go unnoticed by a check scoped to its own sweep.
# Cheap because those directories are near-empty in an image.
run dexec sh -c 'find /tmp /run /mnt /media -xdev -type f -perm /6000 2>/dev/null; getcap -r /tmp /run /mnt /media 2>/dev/null'
[ -z "$output" ] \
|| { echo "privileged bits outside the swept roots:"; echo "$output"; return 1; }
# File capabilities over the whole image filesystem, not a directory
# union: a capability at a top-level path nobody enumerated - /data/bin
# in some future layer - is asserted by nothing else, while setuid gets
# the same whole-filesystem walk in the derived-image test below. -xdev
# keeps this to the image: /proc, /sys and every bind mount are their own
# filesystems, and a capability in the operator's mounted checkout is not
# the image's to answer for. A missing getcap prints a marker instead of
# nothing, because an empty result has to mean "none found", not "nothing
# asked".
run dexec sh -c 'command -v getcap >/dev/null || { echo getcap-missing; exit 1; }
find / -xdev -type f -print0 2>/dev/null | xargs -0 -r getcap 2>/dev/null || true'
[ -z "$output" ] \
|| { echo "a file capability survived somewhere in the image (or getcap is gone):"; echo "$output"; return 1; }
}
@test "the session shell is bash, configured by the image, with history appended" {
@@ -1795,20 +1871,36 @@ session_pid() { # [container], defaulting to the file-wide one
@test "every connection attempt is recorded with its verdict" {
# A refused destination and a permitted one both have to leave a record, or
# the log a supervisor reads proves nothing about what the session did.
# the log a supervisor reads proves nothing about what the session did. And
# each record's verdict has to be the right one, not merely present: the
# verdict rides on which NFLOG group the rule named, so the two groups
# swapped would log every refusal as "allowed" - and a check that only
# asked for the field would stay green while it did.
# The permitted half needs a destination nothing has to answer on: the allow
# record is written when the first packet is accepted, not when a service
# replies, and a test that waited for a reply would be testing the network.
# A documentation address permitted as a literal is exactly that - in
# @literal_v4, on any port, routed nowhere. Added live rather than declared
# at launch so the file-wide pair keeps the permitted set a real one has.
local allowed=198.51.100.1
run dexec_filter /usr/local/sbin/egress-policy permit "$allowed"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
dexec curl -s -o /dev/null --connect-timeout 5 --max-time 8 https://192.0.2.1/ 2>/dev/null || true
dexec curl -s -o /dev/null --connect-timeout 3 --max-time 5 "http://$allowed:9/" 2>/dev/null || true
local found="" i
for i in 1 2 3 4 5 6; do
if dexec_filter grep -q '192.0.2.1' /var/log/egress-policy/connections.ndjson 2>/dev/null; then
if dexec_filter grep -q '192.0.2.1' /var/log/egress-policy/connections.ndjson 2>/dev/null \
&& dexec_filter grep -F "$allowed" /var/log/egress-policy/connections.ndjson 2>/dev/null | grep -q '"allowed"'; then
found=yes; break
fi
sleep 0.5
done
[ -n "$found" ] \
|| { echo "a refused connection left no record"; dexec_filter tail -5 /var/log/egress-policy/connections.ndjson; return 1; }
|| { echo "a connection left no record with its verdict"; dexec_filter tail -5 /var/log/egress-policy/connections.ndjson; return 1; }
run dexec_filter sh -c "grep -F '192.0.2.1' /var/log/egress-policy/connections.ndjson | tail -1"
[[ "$output" == *'"action"'* ]] \
|| { echo "recorded without a verdict field: $output"; return 1; }
[[ "$output" == *'"blocked"'* ]] \
|| { echo "a refused connection was not recorded as blocked: $output"; return 1; }
}
@@ -2254,49 +2346,69 @@ s.serve_forever()' "$bind_addr" > "$port_file" 2>/dev/null &
timeout 5 bash -c "cat < /dev/null > /dev/tcp/$bind_addr/$HOST_LISTENER_PORT"
}
@test "a host service bound to loopback is unreachable from inside the container, one bound to the bridge gateway is reachable" {
@test "the host is unreachable on every port by default, and on exactly the ports EGRESS_HOST_PORTS names" {
# The regression test for the escape path bin/dejima-ttyd's
# --interface 127.0.0.1 exists to close. The egress policy deliberately
# ACCEPTs OUTPUT to the Docker bridge gateway, and a tmux client attached
# through ttyd is a host shell - the prefix key opens a window outside the
# container - so a ttyd the sandbox could reach would be a full escape.
# Prove the containment property holds rather than trusting the flag.
local container host_ip loopback_port gateway_port
# --interface 127.0.0.1 exists to close, now one layer stronger. The host
# entry used to be accepted on every port, so a ttyd bound to 0.0.0.0 - and
# a tmux client attached through ttyd is a host shell, the prefix key opens
# a window outside the container - was reachable from the session. It now
# opens only the ports it was told to (#363).
local container host_ip loopback_port named_port other_port
local test_container="$(test_container_name hostports)"
container="$(container_name)"
# Derived exactly as the egress policy derives the host address, so this is the one
# host address the container's OUTPUT chain accepts at all.
# Derived exactly as the egress policy derives the host address, so this is
# the one host address the container's OUTPUT chain names at all.
host_ip="$(docker exec "$container" sh -c "ip route | grep default | cut -d' ' -f3")"
[ -n "$host_ip" ]
start_host_listener 127.0.0.1
loopback_port="$HOST_LISTENER_PORT"
start_host_listener "$host_ip"
gateway_port="$HOST_LISTENER_PORT"
named_port="$HOST_LISTENER_PORT"
start_host_listener "$host_ip"
other_port="$HOST_LISTENER_PORT"
# Setup check, from the host's own vantage point: the loopback listener's
# port must answer on 127.0.0.1 only. bind(0) on two different addresses
# can legally return the same port number, and if it had, the container
# probe below would be hitting the gateway listener and failing for a
# reason that has nothing to do with containment.
# probes below would be hitting a gateway listener and failing for a reason
# that has nothing to do with containment.
run timeout 5 bash -c "cat < /dev/null > /dev/tcp/$host_ip/$loopback_port"
[ "$status" -ne 0 ]
# Positive control, and the reason the negative one means anything: same
# container, same docker exec, same timeout/bash /dev/tcp probe, same
# gateway address - only the port differs. Without it a `status -ne 0` is
# equally satisfied by docker exec failing (125), bash or timeout missing
# from the image (127), or a container that can reach nothing at all, none
# of which say anything about loopback.
run docker exec "$container" timeout 5 bash -c "cat < /dev/null > /dev/tcp/$host_ip/$gateway_port"
# The default, on the file-wide pair, which sets no EGRESS_HOST_PORTS: a
# real listener on the gateway address, and the session cannot reach it.
run docker exec "$container" timeout 5 bash -c "cat < /dev/null > /dev/tcp/$host_ip/$named_port"
[ "$status" -ne 0 ]
# A pair told to open that one port, and nothing else about it changed.
start_pair hostports "$SESSION_IMAGE" \
-e SANDBOX_TEST_MODE=1 \
-e EGRESS_HOST_PORTS="$named_port"
# Positive control, and the reason the negatives mean anything: without it
# a `status -ne 0` is equally satisfied by docker exec failing (125), bash
# or timeout missing from the image (127), or a container that can reach
# nothing at all, none of which say anything about the clamp.
run docker exec "$test_container" timeout 5 bash -c "cat < /dev/null > /dev/tcp/$host_ip/$named_port"
[ "$status" -eq 0 ]
# Deliberately not asserting the exact status. 1 (the host RSTs, since
# nothing is bound on the gateway address at this port) is what it should
# be, but a host that DROPs on its own docker bridge instead yields 124
# from timeout, and that is still containment.
run docker exec "$container" timeout 5 bash -c "cat < /dev/null > /dev/tcp/$host_ip/$loopback_port"
# The clamp itself: same container, same address, same kind of listener,
# only the port differs from the one above.
run docker exec "$test_container" timeout 5 bash -c "cat < /dev/null > /dev/tcp/$host_ip/$other_port"
[ "$status" -ne 0 ]
# And the original containment property, which the clamp does not replace:
# a host service bound to loopback is not on the gateway address at all.
#
# Deliberately not asserting the exact status on any of the negatives. 1
# (the host RSTs) is what a refusal should look like, but a drop yields 124
# from timeout, and that is still containment.
run docker exec "$test_container" timeout 5 bash -c "cat < /dev/null > /dev/tcp/$host_ip/$loopback_port"
[ "$status" -ne 0 ]
docker rm -f "$test_container" >/dev/null 2>&1 || true
}
@@ -2588,3 +2700,68 @@ EOF
[[ "$output" != *"dejima-notify"* ]]
[[ "$output" != *"remote access is enabled"* ]]
}
@test "an isolated launch's working directory is a volume the session user owns" {
# The one thing about --isolated that a confined test cannot decide: that
# the directory the launcher points a session at is usable. A volume at a
# path the image created is seeded from it, ownership included, and if
# that did not hold the session would start in a directory root owns and
# fail on its first write.
#
# The volume is named for the run by bin/dejima and removed in its exit
# trap, both of which the confined suite checks against the launcher. So
# this does not re-check the reaping, and deliberately does not count the
# host's volumes before and after: another job on a shared runner creating
# one between the two reads would fail a test about something else
# entirely.
local vol="dejima-test-work-$(basename "$BATS_RUN_TMPDIR")"
docker volume create "$vol" >/dev/null
run docker run --rm -v "$vol:/dejima/work" --entrypoint sh "$SESSION_IMAGE" -c \
'cd /dejima/work && touch marker && stat -c "%U" /dejima/work'
if [ "$status" -ne 0 ] || [ "$output" != node ]; then
docker volume rm "$vol" >/dev/null 2>&1 || true
echo "the working directory is not writable by the session user: $output"
return 1
fi
# And nothing of the host is behind it, which is the claim the mode rests
# on: a second volume at the same path starts empty rather than carrying
# what the first container wrote.
# Purpose before the suffix, like test_container_name: teardown's filter
# is anchored on the suffix, so a name ending in anything else is one it
# cannot sweep if this is interrupted between create and remove.
local other="dejima-test-work2-$(basename "$BATS_RUN_TMPDIR")"
docker volume create "$other" >/dev/null
run docker run --rm -v "$other:/dejima/work" --entrypoint sh "$SESSION_IMAGE" -c \
'ls -A /dejima/work'
local rc="$status" out="$output"
docker volume rm "$vol" "$other" >/dev/null 2>&1 || true
[ "$rc" -eq 0 ] || { echo "$out"; return 1; }
[ -z "$out" ] \
|| { echo "a second session's working directory was not empty: $out"; return 1; }
}
@test "the git shadow loads its policy in the image, and leaves other git commands alone" {
# The decision lives in a file the Dockerfile copies in
# (docker/guard-lib.sh), and every git command in a session goes through
# the shadow that sources it - so a COPY line that stopped matching the
# path would break git outright, in every session, and nothing in the
# confined suites would notice: they point the guards at the checkout.
#
# The file-wide pair sets no DEJIMA_PUSH_HOSTS, which fails closed, so any
# push is refused. Which refusal does not matter; that the guard is the one
# speaking does.
run dexec bash -c 'cd /tmp && git init -q -b feature r && cd r && git push origin feature'
[ "$status" -ne 0 ]
[[ "$output" == *"Blocked by dejima's git guard"* ]] \
|| { echo "the shadow did not reach its policy: $output"; return 1; }
[[ "$output" != *"No such file"* ]] \
|| { echo "the shadow could not load the policy file: $output"; return 1; }
# And the pass-through path: everything that is not a push has to reach
# real git, or the shadow is a broken git rather than a guarded one.
run dexec git --version
[ "$status" -eq 0 ]
[[ "$output" == git\ version* ]] || { echo "$output"; return 1; }
}
+36
View File
@@ -150,6 +150,42 @@ mounted_share() { sort -u "$SHARE_LOG" | grep -v '^$' | head -1; }
|| { echo "a clean share_dir warned about its own projects dir: $output"; return 1; }
}
@test "an isolated launch's share is its own, and the root is not reported as loose" {
write_config
# launch() takes a workspace, which --isolated refuses - so this one
# drives the launcher directly with the same stub.
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" --isolated
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
case "$(mounted_share)" in
"$SHARE"/isolated/*) ;;
*) echo "mounted '$(mounted_share)', not a directory under the isolated root"
return 1 ;;
esac
# Not asserted to still exist: an isolated share that was never written
# to is removed in the exit trap, so a stub that runs no session leaves
# none. That it was created and mounted is project.bats' test, which has
# a stub that writes into it.
[ ! -d "$(mounted_share)" ] \
|| { echo "an empty isolated share was left behind"; return 1; }
# The root holding them is dejima's, like projects/ - so a launch must
# not go on telling the operator about it every time as something that
# was left behind by hand.
[[ "$output" != *"no session can see"* ]] \
|| { echo "the isolated root was reported as left behind: $output"; return 1; }
}
@test "dejima-share list names each project and flags what is loose" {
write_config
share path "$WS" > /dev/null
+11 -4
View File
@@ -30,6 +30,7 @@ list_scripts() {
# these the last scripts that should go unchecked, not the first -
# and leaving them out hid a real fault in the release gate itself.
ls test/*.sh 2>/dev/null || true
ls mirror/*.sh 2>/dev/null || true
} | sort -u | while read -r f; do
[ -f "$f" ] || continue
# Sourced fragments are not executed, so they carry no shebang and the
@@ -40,10 +41,16 @@ list_scripts() {
# keeping the permitted set's classes from drifting between the
# container that enforces them and the host command that adds to them.
#
# Matched by directory rather than by name, because a list is a thing
# to forget to add to. Everything under docker/ is in the trusted
# computing base; the -s bash below tells shellcheck what these are.
case "$f" in bin/harness/*.sh|docker/*.sh) printf '%s\n' "$f"; continue ;; esac
# Matched by pattern rather than by name, because a list is a thing to
# forget to add to - and this one was forgotten anyway: bin/*-lib.sh
# went unchecked, which is dejima-harness-lib.sh, the code deciding
# which host paths may be mounted where. Everything under docker/ is in
# the trusted computing base; the -s bash below tells shellcheck what
# these are.
case "$f" in
bin/harness/*.sh|docker/*.sh|bin/*-lib.sh)
printf '%s\n' "$f"; continue ;;
esac
head -1 "$f" | grep -qE '^#!.*(bash|sh)' && printf '%s\n' "$f"
done
}
+2336 -151
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -48,6 +48,24 @@ so they cannot quietly stop running.
The rest — gestures, the viewport manager, the toolbar — needs a real browser
and a real phone. `docs/remote-access.md` has the manual checklist.
One of them is automated, against a real browser and a mock ttyd:
```sh
node test/browser/sess-button.mjs
```
It drives the built page in Chromium and proves the thing the unit tests
cannot: that SESS reaches the picker on a new WebSocket, without the page
being fetched or run a second time. Three seconds, and it needs Playwright,
which a dejima sandbox has and this package deliberately does not depend on -
a browser is a strange thing to install to inline two files.
Deliberately not part of `npm test`, which CI runs in a `node:20-bookworm`
container with no browser in it — a test that cannot run there would have to
be skipped, and a skipped test is one nobody is reading. What keeps it from
rotting instead is [docs/release.md](../docs/release.md), whose manual list
names it for any release that touches `web/`.
## Layout
| File | Role |
+21 -2
View File
@@ -10,7 +10,7 @@
//
// Usage: npm ci && node build.mjs
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync } from 'node:fs';
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -41,8 +41,15 @@ const CLIENT_SOURCES = [
// needed, since the first alone passes on a commit that rebuilds the manifest
// and leaves the page behind.
const MANIFEST_SOURCES = [
// The builder itself. Without it, editing this file - the escaping, the fill
// order, which sources get inlined - produces a different page with a
// byte-identical manifest and stamp, so the staleness check passes on a page
// nobody rebuilt. web/README.md defends committing the artifact precisely so
// nobody has to run the build, which is what makes that check load-bearing.
'build.mjs',
'package-lock.json',
'src/config.js',
'src/favicon.png',
'src/gestures.js',
'src/keyboard.js',
'src/keys.js',
@@ -58,7 +65,18 @@ const MANIFEST_SOURCES = [
'src/viewport.js'
];
// The order above is hand-written because it is load-bearing, so the list
// cannot be a glob. A src/*.js omitted from it is still hashed into the
// manifest and still fails no test - it is simply never inlined, and the page
// is missing a module. Reading the directory catches that at build time.
const uninlined = readdirSync(join(here, 'src'))
.filter((f) => f.endsWith('.js') && !CLIENT_SOURCES.includes(`src/${f}`));
if (uninlined.length) {
throw new Error(`src/*.js not in CLIENT_SOURCES, so never inlined: ${uninlined.join(', ')}`);
}
const read = (relative) => readFileSync(join(here, relative), 'utf8');
const readBytes = (relative) => readFileSync(join(here, relative));
const sha256 = (text) => createHash('sha256').update(text).digest('hex');
// A literal </script> inside an inlined script would end the element early.
@@ -82,8 +100,9 @@ html = fill(html, 'PAGE_CSS', read('src/page.css'));
html = fill(html, 'XTERM_JS', escapeForScript(read('node_modules/@xterm/xterm/lib/xterm.js')));
html = fill(html, 'ADDON_FIT_JS', escapeForScript(read('node_modules/@xterm/addon-fit/lib/addon-fit.js')));
html = fill(html, 'CLIENT_JS', escapeForScript(client));
html = fill(html, 'FAVICON_B64', readBytes('src/favicon.png').toString('base64'));
const manifest = MANIFEST_SOURCES.map((file) => `${sha256(read(file))} ${file}\n`).join('');
const manifest = MANIFEST_SOURCES.map((file) => `${sha256(readBytes(file))} ${file}\n`).join('');
html = html.replace(
'<!--BUILD_STAMP-->',
() =>
+69 -3
View File
@@ -10,7 +10,11 @@
<meta name="color-scheme" content="dark">
<meta name="mobile-web-app-capable" content="yes">
<title>dejima</title>
<!-- Generated by web/build.mjs - do not edit. Sources: f8a5205278ef77485e319cdefff6cbeeb027f201ab27266b02703a9eb570c608 -->
<!-- Inlined like everything else on this page: ttyd serves one file and answers
no path a /favicon.ico could be fetched from, so a link to one would 404 and
the tab would fall back to the generic icon. -->
<link rel="icon" type="image/png" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGUAAABlCAYAAABUfC3PAAAACXBIWXMAAAsSAAALEgHS3X78AAAK9UlEQVR4nO2db0wb5x3Hv6BAa2w0ggcjJlHsOHYkYgs8oIFIThMsUTlCJJsEFW/CgjJpaFsi7VWFtBfTVNZXUYJU5Q1K11ckIK0KQ0JDcrTWVU0FFCMcpJxLMQoYhOuEyf+K3XV74dxxZ5/xnX1nH+E+b+D85+6xPzzP7+653++hBAACxNRlyEgBd42xa7ckQEx9BuB6sVsjAwDYBaArhSxESlQBaCotditk0pGlSBBZigSRpUgQWYoEkaVIEFmKBJGlSBBZigSRpUgQWYoEkaVIkGPFbgBXvGt+hCMxuJdXAQBbO6+wvfOS9bUqpQIGnQYAcFangUqlgMWkL1hb80WSUrxrfriXV+Fd8+Pb7zbhXfPz3odz1pP2WF1tNQxnNDDoNGgy6yUrqiRATP2v2I0gJSx6Vlm/TDEx6DSwtpmkJOlK0aR41/yYdszBOfss4zBEp1KtRKW6AupTVShXlFHbbOzFEgi+2AUAbBEBxvZBqJQKWNtMsF44D4tZD5VSwe9DCUNhpXAVUV5RBo2xBuqTVcmfr0XkyxYRwPcbuwi++A+2iABCwciBr7fbWmG9cB7WNlPex+aB+FLCkRimHfMYn3QeKELbpMEJYw0loxCEglFsEQH4iQB8S5uIRxOsr1MpFbDbWtDbfQl1tcfFbpZ4UhY9q5h2zGPaMcf6fHlFGbSN9dA2aaAx1gjSE/JliwjAt+SHz+3P2IssZj3sHa2w21rEaobwUqYd85iY/CLjGZO2SQNtYz2M7aeFPKzgbBEBPHetZ+xBdbXVuGprQU+3VejYI5yUacc8Ho7NsA5RlWolTLazONeulUSP4AvhWgfh8sFPBNKeUykV6O22wm5rFWpoy1/KxKQTD8dmEI7E0p7TNmlg7jDghLEmn0NIhlAwioWplYy9x25rxe1b3fn2nNylZOoZZKxo7mrIeMp62InHElh2eEG41tNiD9lz8hjW+EtZ9Kxi+N5jVhnmDgPMNsOhHKJyhXCtY2FqhVXOQF8nerqtfHfJXcr2ziuMjD5Ju+I+qjJSySTHoNPgj7+9xme2gJuUTHHDbDOguavhSMtIZfmpFwtTK2kxp6fbioG+Ti5DWnYp0455DN9/xHhMY6zBu/2tb2zMyJd4LIGFqRUsO7yMx+22VgzdeT/b269knSVO7R2dgxehbdTwbeeRolxRhvaeRhjbT2PmgYsa0tjOUNnIepMrdd5H7h3cUZ+swl4sTm1bL3CbQ8sqpa72OEPMsuPbHJp3NCFc61RsIefPuMDpdrC9Y39nhMuHeIx94k6GCeHyUb/zmSvjJMXaZkJdbTW1/Zx2MBl2QsEoY1qmt/sS5/dyTpy4SjPtkYewrHie7p95Wcx6XvNinKXYba3U76FgBFssk3MySeKxBGM0sXe0ZnwtG5yl1NUeZ4h57lrndaCjhM/tzynAk/DK+6LvXA74mVmmDV0qpQLTjnle7+clxWLSywE/C8GNXUaSxvbOSwzffwR73585Z+rwzpDspc16ygE/nUzXceFIDCOjk5z2wVsKfQiTAz6TeCwB39Imtf3BL3/AjXN71Pb2zktOQxlvKcnAJQd8NugBvl75E24Y9/CB5Qdc1+1PtTwcm8m6n5wSvOWAzw49wF/X7X8nv6L9ziXxMCcpqQE/dYr6KJIa4Om947O1/ftNXBL7ci6FoAd8Qh7CGAHedjKBeuVPAIBQogSOzX0p9HnETOQsJTXg+5b4Z8a/KaQGePrQ5dgoQyheAiCZK8alp+RcCkEGfDIDknD5CnLzK7ixi3g0gVAwmjGLsbyiDD9/nfpaiPSm5y4f4/bvZqQUm5FSzO0cw8eet6jHr3K8ss+rPqWn20pJSaZ6RgW9CUbP9U0ds/lQqVZCfepnouUqp16vffTN2/jom7fTXkc/az2IvKQYdMkCHDJFlXD50NzVkM8uEdzYBeFaPzCfly+hYCQ5xLqT7aTnMefbu7lk7wPJP2CuM8V5V3L1dF+iEisI13pOUshZVcK1zrk3vFP7I1pr/8v63GYkGVzJsTzteNEECJcPhMuH8ooynGvXwtRhyKmX06/TLGY9rBdMVIVBMoac552tn7cUa9t5qEYVCEdiVMDn+teXLQ0USH5Qi0mPJrMet4ceUI//3rSH1tof2febcsZz+9Y1AMlEwsXlVUYCQzyazHZcdnh5p9nGYwnG3cWbfZ2wmPS5JOAxyFsKWf3EJ+BvEQEsP/VSw0kqyWoqU1I4LU/K2maiJvX+8GUF/vFemDr1JAklSjD0tYLqJSqlgvqSyJ/OWQ+cXz+Dc9bDEORzJ8sg1KeqYO4wZK0MoE/I1tVWC1aeJ0ghKteAv0UEsDC1wpq9TpYWHJS9frOvk5ISipfg1/9S4YYxjnde95jNSCk+9ryFzcj+mf5AX2fafqxtJljbTAjf6oZz9lla6UbwxS7+/ekcFqZW0NzVkFEOPcD35tk76AhWCjFw5y71wZq7Ghix5SAZfItw2JIDM8Ex+Q3AwUVOlWplmpwtIoB/3v18v11jfxWqTkXY+hTyyyqvKMNv7l7LKoMcg/ninPVg+P7jA5PbBvo6cZOll2Rje+cVHo7NHChH26TBV+NLVDzhI58DwkkJR2LouTVMfVHlFWUZazjstpa8x1+ylnLaMUf1UCFrE7d3XmF88gtMO+bT5Kd+tpHhQSHLvYUtrxu+/zhjjaPd1oqBvk7BCzkXPavUWZnFrMfIh4OC7j8ciWFi0onxSSdrz6yrrcbE6JCQh8yeS8wHesAnEUtGoVApFbj5us6ETY6QAZ5EUCkGnQY93Va4l1dx9kz9oZaRSqoc56wHdb+oFqVKWPC1WcgLtTcVUk4uJxFckZeWkiCyFAkiS5EgshQJIon1vrgQjsSodcHoq+J5v/MzTlEt5uRFHLk63lmdBoYz9YfpLLB4631xwbvmx5ezHjhnPTmtjkeHvLdhMekLvVQUX6QnJRyJsc7cCkmuN58KhHSkZJvOAJITgieMNahUV0Dz+kaUirZCXpy2At73G7sIB6MIvthlnRAlkeCMgzSkZFt0R9tYT8nIhXgskVxoze3PeJeTx+IDYlNcKd41P/5271HaMEUuLWJs1wpeIh6PJeBz+zOupzJ05/1ix5ziSZl2zGNk9AmjdxR6nRffkh+u8aU0OUXuNcWRwjbFX8x1XtjWUzHoNBgZHiyGmMJKCUdiuD30gDFclVeU4b3fXSz6Qm2hYBSffzrHOClQKRUYGR6kVgMvEIWTwiZEY6xB5+BFSa2C5JpYYlQRFEHMlYJMs7AJMbZr0fWndyUlBADaexpxuX8/vZSt7WJTECnD9x+nCbncL9oSsnljbD+dJmbow79zXoUoX0SX8snYDKMqVupCSFLFbO+8ZGRoiomoUhY9q4wav8MihCRVjHfNj5HRJ6IfVzQpZJcnUZ+qwsXeRrEOJxrG9tMwtmup7YlJJxY9q6IeUzQpI6OTjBywy/0tkgvqXLnc3wL1qf2aluF7j0U9nihSkimg+xeHzV0NBfunAmJBH3a3d17iEw6l17kiihR6gzXGGpg7DGIcpqCoT1Yx8qMPms3OF8FTjMgaEJJQMIopWiL0YWaPtl4AeatBjFQjwaVMTDoZ22Rp25vIuEhSBB++CjxPVFQMZ8T5rKLMfYl9yigVDDqNGLPIwiZ4k0jkv8AdWuS8LwkiS5EgshQJIkuRILIUCSJLkSCyFAkiS5EgshQJIkuRILIUCVIKwFfsRsgw2D0G4C8A+ovdEhkAgLvG2OX+Pzk3fgySn1OHAAAAAElFTkSuQmCC">
<!-- Generated by web/build.mjs - do not edit. Sources: c43f6331189abbbe08955f8548a631132008ba6051a6274088be6f85c0f7d2a4 -->
<style>
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
@@ -1007,6 +1011,7 @@ body.mono-keys {
// onStatus(string), size() -> {cols, rows}}
function createClient(opts) {
var urls = endpoints(opts.location);
var location = opts.location;
var encoder = new TextEncoder();
var decoder = new TextDecoder();
var socket = null;
@@ -1104,9 +1109,45 @@ body.mono-keys {
};
}
// Point the connection at a different ttyd argument. ttyd reads --url-arg
// off the WebSocket request rather than off the page load, so a different
// command is a different socket and nothing else: the half a megabyte of
// terminal this page is does not have to be fetched, parsed and run again
// to reach one.
//
// The old socket's handlers are detached before it is closed, rather than
// a flag being read inside them. onclose is delivered asynchronously, so a
// handler left attached can arrive after the replacement socket exists and
// null the field holding it - and the reconnect it would schedule is for
// the url this call just replaced.
function switchTo(search) {
urls = endpoints({
protocol: location.protocol,
host: location.host,
pathname: location.pathname,
search: search
});
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
backoff = RECONNECT_MIN_MS;
if (socket) {
socket.onclose = null;
socket.onmessage = null;
socket.onerror = null;
socket.close();
socket = null;
}
connect();
}
return {
connect: connect,
urls: urls,
switchTo: switchTo,
// A function, not the object: switchTo replaces it, and a copy taken
// here would answer for the connection this client used to have.
urls: function () { return urls; },
isOpen: open,
sendInput: sendInput,
sendBinary: sendBinary,
@@ -1875,6 +1916,31 @@ body.mono-keys {
}
});
// The SESS button: back to dejima-attach with no run id, which is the
// picker. A new socket rather than a new page - ttyd takes the run id as a
// WebSocket argument, so the command changes without the half a megabyte of
// inlined terminal being fetched, parsed and run a second time. On a phone
// that was most of the wait.
//
// The terminal is reset first, and the mode tracker with it. What the socket
// carries away is the process, not the state it left in here: an alternate
// screen, mouse reporting, a cursor key mode. The picker would otherwise
// open into whatever the session it replaced had switched on, and the
// gestures would still be sending mouse reports to something that is not
// asking for them.
//
// replaceState rather than a history entry: the address bar has to stop
// saying ?arg=<run-id> or a reload would reopen the session just left, and
// going "back" to a session that ended is not a thing to offer.
function goToPicker(search) {
term.reset();
mouse.modes.clear();
if (window.history && window.history.replaceState) {
window.history.replaceState(null, '', window.location.pathname + search);
}
client.switchTo(search);
}
// Sticky Ctrl is applied here rather than in attachCustomKeyEventHandler
// because the target is a phone: soft keyboards overwhelmingly report
// keydown as "Unidentified" and deliver the real character through an input
@@ -1934,7 +2000,7 @@ body.mono-keys {
prefix: function () { return prefixByte; },
notify: notify,
search: function () { return window.location.search; },
navigate: function (search) { window.location.search = search; },
navigate: goToPicker,
allowKeyboard: keyboard.allow
});
toolbar.attach();
+5 -3
View File
@@ -1,15 +1,17 @@
63b33d784dd94f6df18f63043a5f3734a306e1ea01d7ddbc285d53c1b7435cf2 build.mjs
f6f6611d0b3eacc39d10add9c4bbc56d4b3a70a47f022351a1c6eab68cd66e71 package-lock.json
613d28096b1bed66f347590663f5f0136fb7f61a4578066fa2ed9e788ff9567a src/config.js
18a303a9b2bb95504e25e954c82e542da96702cf39a3499e9c9fd50e5160f39f src/favicon.png
f690fd9d35975bc977e3d323595a2a4aa1fca6ed50f213602f08adec3f5b2c92 src/gestures.js
e75dc9d5e311a3d9bf9dc373b651ad506afbd7a7cc7ad54aac6fdad94276b0a4 src/keyboard.js
2a070f63702b95b0c62963ec6b9ec4fa2b0b89b7553a8a889d68cdf3506f9ce3 src/keys.js
e5888ba8413fc2b442b44cade378f7393fd231dd5f79d44c58d1bc217ed7c807 src/main.js
05bc2e446c3baa57422c8fa036939a0df8cf35ac0cf02bb35233befafae5667f src/main.js
e3a550e2249b536c9bc3d90ee2735a9a2d8e6f772a6a631f724d289b96b1f557 src/modes.js
8446be09a1eca041a5a2e2068fdace32596713aef1d1fb74b522ea4eacc50ec3 src/mouse.js
cb5e33c0fa117176d9073bd9019f78b65bfe5535833bf1765ee542c700d67f1f src/osc52.js
94229c981f6287162d6ef774f71ef41d941494595f76f16bb742811ca32a767f src/page.css
e02823ce9d5d57615a0a8c0c7b91611932202758048b7eadabe4f1054aaf1a9f src/protocol.js
2490ec55a050d993364a62f590b51e63945ea5b1276832821d6d1d0854fd5c81 src/protocol.js
aac82e88c6a65617818c6589091cbb8c3bab50e82e98aabf8b583cbb3dfda7e7 src/sgr.js
dab361cf748e6d530099f79ccd3f407fcc493f81dd4afeda227376a8c9e9b81f src/template.html
34e34a8d258b8a7d2afde03287681b99d839994936c398ba5c7165a3ed7aec93 src/template.html
3c8e5520b694693126097d9fe17f89ff23ba15adb8532521c647907edba20215 src/toolbar.js
864405fe11e5456bdb98ea8c841cb04346d5a38aabec897101defa9612d89443 src/viewport.js
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

+26 -1
View File
@@ -139,6 +139,31 @@
}
});
// The SESS button: back to dejima-attach with no run id, which is the
// picker. A new socket rather than a new page - ttyd takes the run id as a
// WebSocket argument, so the command changes without the half a megabyte of
// inlined terminal being fetched, parsed and run a second time. On a phone
// that was most of the wait.
//
// The terminal is reset first, and the mode tracker with it. What the socket
// carries away is the process, not the state it left in here: an alternate
// screen, mouse reporting, a cursor key mode. The picker would otherwise
// open into whatever the session it replaced had switched on, and the
// gestures would still be sending mouse reports to something that is not
// asking for them.
//
// replaceState rather than a history entry: the address bar has to stop
// saying ?arg=<run-id> or a reload would reopen the session just left, and
// going "back" to a session that ended is not a thing to offer.
function goToPicker(search) {
term.reset();
mouse.modes.clear();
if (window.history && window.history.replaceState) {
window.history.replaceState(null, '', window.location.pathname + search);
}
client.switchTo(search);
}
// Sticky Ctrl is applied here rather than in attachCustomKeyEventHandler
// because the target is a phone: soft keyboards overwhelmingly report
// keydown as "Unidentified" and deliver the real character through an input
@@ -198,7 +223,7 @@
prefix: function () { return prefixByte; },
notify: notify,
search: function () { return window.location.search; },
navigate: function (search) { window.location.search = search; },
navigate: goToPicker,
allowKeyboard: keyboard.allow
});
toolbar.attach();
+38 -1
View File
@@ -41,6 +41,7 @@
// onStatus(string), size() -> {cols, rows}}
function createClient(opts) {
var urls = endpoints(opts.location);
var location = opts.location;
var encoder = new TextEncoder();
var decoder = new TextDecoder();
var socket = null;
@@ -138,9 +139,45 @@
};
}
// Point the connection at a different ttyd argument. ttyd reads --url-arg
// off the WebSocket request rather than off the page load, so a different
// command is a different socket and nothing else: the half a megabyte of
// terminal this page is does not have to be fetched, parsed and run again
// to reach one.
//
// The old socket's handlers are detached before it is closed, rather than
// a flag being read inside them. onclose is delivered asynchronously, so a
// handler left attached can arrive after the replacement socket exists and
// null the field holding it - and the reconnect it would schedule is for
// the url this call just replaced.
function switchTo(search) {
urls = endpoints({
protocol: location.protocol,
host: location.host,
pathname: location.pathname,
search: search
});
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
backoff = RECONNECT_MIN_MS;
if (socket) {
socket.onclose = null;
socket.onmessage = null;
socket.onerror = null;
socket.close();
socket = null;
}
connect();
}
return {
connect: connect,
urls: urls,
switchTo: switchTo,
// A function, not the object: switchTo replaces it, and a copy taken
// here would answer for the connection this client used to have.
urls: function () { return urls; },
isOpen: open,
sendInput: sendInput,
sendBinary: sendBinary,
+4
View File
@@ -10,6 +10,10 @@
<meta name="color-scheme" content="dark">
<meta name="mobile-web-app-capable" content="yes">
<title>dejima</title>
<!-- Inlined like everything else on this page: ttyd serves one file and answers
no path a /favicon.ico could be fetched from, so a link to one would 404 and
the tab would fall back to the generic icon. -->
<link rel="icon" type="image/png" href="data:image/png;base64,/*FAVICON_B64*/">
<!--BUILD_STAMP-->
<style>
/*XTERM_CSS*/
+115
View File
@@ -0,0 +1,115 @@
// Enough of ttyd to drive the real page: the three paths its HTTP handler
// answers, and a WebSocket that spawns nothing but speaks the frame protocol
// src/protocol.js was written against.
//
// Hand-rolled rather than a dependency, because web/ ships with two and both
// are the terminal itself. The frames here are the ones this page sends and
// receives and no others: a text handshake, masked client frames under 64KB,
// and unmasked server frames.
import http from 'node:http';
import crypto from 'node:crypto';
import fs from 'node:fs';
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
function accept(key) {
return crypto.createHash('sha1').update(key + GUID).digest('base64');
}
// A server frame: FIN set, binary opcode, no mask, two length forms.
function frame(payload) {
const len = payload.length;
const head = len < 126 ? Buffer.from([0x82, len])
: Buffer.concat([Buffer.from([0x82, 126]), Buffer.from([len >> 8, len & 0xff])]);
return Buffer.concat([head, payload]);
}
// One client frame off the front of a buffer, or null when it is not all here
// yet. Only the opcodes this page sends: text, binary and close.
function unframe(buf) {
if (buf.length < 2) return null;
const masked = (buf[1] & 0x80) !== 0;
let len = buf[1] & 0x7f;
let off = 2;
if (len === 126) {
if (buf.length < 4) return null;
len = buf.readUInt16BE(2);
off = 4;
}
const maskLen = masked ? 4 : 0;
if (buf.length < off + maskLen + len) return null;
const mask = masked ? buf.subarray(off, off + 4) : null;
const body = Buffer.from(buf.subarray(off + maskLen, off + maskLen + len));
if (mask) for (let i = 0; i < body.length; i++) body[i] ^= mask[i % 4];
return { opcode: buf[0] & 0x0f, body, rest: buf.subarray(off + maskLen + len) };
}
// opts.onConnect(url) -> the text this "session" prints. Returning different
// text for the picker and for a run id is what lets a test tell which command
// ttyd would have run.
export async function startMockTtyd(indexPath, opts) {
const index = fs.readFileSync(indexPath);
const pageLoads = [];
const sockets = [];
const server = http.createServer((req, res) => {
const path = req.url.split('?')[0];
if (path === '/token') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ token: '' }));
return;
}
if (path === '/' || path === '/index.html') {
pageLoads.push(req.url);
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(index);
return;
}
res.writeHead(404);
res.end();
});
server.on('upgrade', (req, socket) => {
sockets.push({ url: req.url, closed: false, input: '' });
const mine = sockets[sockets.length - 1];
socket.write(
'HTTP/1.1 101 Switching Protocols\r\n' +
'Upgrade: websocket\r\nConnection: Upgrade\r\n' +
'Sec-WebSocket-Protocol: tty\r\n' +
'Sec-WebSocket-Accept: ' + accept(req.headers['sec-websocket-key']) + '\r\n\r\n');
let buf = Buffer.alloc(0);
let spawned = false;
socket.on('data', (chunk) => {
buf = Buffer.concat([buf, chunk]);
for (;;) {
const f = unframe(buf);
if (!f) break;
buf = f.rest;
if (f.opcode === 0x8) { socket.end(); return; }
// An input frame is '0' and then the bytes. Recorded so a test can see
// that what is typed after a switch reaches the new command rather
// than the socket that was replaced.
if (spawned && f.body.length > 1 && f.body[0] === 0x30) {
mine.input += f.body.subarray(1).toString();
}
// ttyd spawns the command when the first message - the one carrying
// the token and the size - arrives, and not before.
if (!spawned) {
spawned = true;
socket.write(frame(Buffer.concat([
Buffer.from('0'), Buffer.from(opts.onConnect(mine.url))
])));
}
}
});
socket.on('close', () => { mine.closed = true; });
});
await new Promise((r) => server.listen(0, '127.0.0.1', r));
return {
url: `http://127.0.0.1:${server.address().port}/`,
pageLoads,
sockets,
stop: () => new Promise((r) => server.close(r))
};
}
+94
View File
@@ -0,0 +1,94 @@
// What the SESS button does, against the real page in a real browser.
//
// Not part of `npm test`: it needs Chromium, which the CI image does not have
// and which `node --test` would fail on rather than skip. Run it by hand, on a
// machine with Playwright:
//
// node test/browser/sess-button.mjs
//
// It proves the one thing the unit tests cannot: that pressing SESS reaches
// the picker without the page being fetched and parsed a second time.
import { startMockTtyd } from './mock-ttyd.mjs';
// Playwright is not a dependency of this package and is deliberately not
// added as one: it would pull a browser into an install that exists to inline
// two files. Resolved where it is rather than where this happens to have been
// run - a dejima sandbox carries it at a fixed path, a host that has it has it
// on the module path.
async function loadChromium() {
for (const spec of ['playwright', '/opt/node-tools/lib/node_modules/playwright/index.mjs']) {
try {
return (await import(spec)).chromium;
} catch (e) { /* the next one, or the message below */ }
}
console.error('This check needs Playwright, which is not a dependency here.');
console.error('Run it inside a dejima sandbox, which has one, or install it:');
console.error(' npm i -g playwright && npx playwright install chromium');
process.exit(2);
}
const chromium = await loadChromium();
const RUN_ID = 'deadbeefdeadbeef';
const checks = [];
function check(name, ok, detail) {
checks.push({ name, ok, detail });
console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${ok || !detail ? '' : ' <- ' + detail}`);
}
const ttyd = await startMockTtyd(new URL('../../index.html', import.meta.url).pathname, {
onConnect: (url) => (url.includes('arg=' + RUN_ID)
? 'SESSION ' + RUN_ID + ' attached\r\n'
: 'dejima | live sessions\r\n')
});
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(`${ttyd.url}?toolbar=1&arg=${RUN_ID}`);
await page.waitForFunction((id) => document.body.innerText.includes('SESSION ' + id), RUN_ID);
check('the run id reaches ttyd as a WebSocket argument',
ttyd.sockets.length === 1 && ttyd.sockets[0].url.includes('arg=' + RUN_ID),
JSON.stringify(ttyd.sockets.map((s) => s.url)));
const loadsBefore = ttyd.pageLoads.length;
// A marker on the window as well as the server's count: a reload served from
// the browser's cache would never reach the server, and this page is one big
// cacheable file.
await page.evaluate(() => { window.__survivedTheSwitch = true; });
await page.click('[data-action="sessions"]');
await page.waitForFunction(() => document.body.innerText.includes('live sessions'));
check('the page itself was never re-run',
await page.evaluate(() => window.__survivedTheSwitch === true));
check('the picker arrives without the page being fetched again',
ttyd.pageLoads.length === loadsBefore,
`${ttyd.pageLoads.length} loads, was ${loadsBefore}`);
check('a second socket is opened, with no arg on it',
ttyd.sockets.length === 2 && !ttyd.sockets[1].url.includes('arg='),
JSON.stringify(ttyd.sockets.map((s) => s.url)));
check('the first socket is closed, which is what ends the session left behind',
ttyd.sockets[0].closed === true);
check('the address bar stops naming the session, so a reload lands on the picker',
!(await page.evaluate(() => window.location.search)).includes('arg='),
await page.evaluate(() => window.location.search));
// The session's own output must not still be on screen under the picker: the
// socket carries away the process, not what it left in the terminal.
// Typing has to reach the command the new socket spawned. The terminal's
// handlers were wired to the client once, at startup, and the client is now
// holding a different socket.
await page.click('#terminal');
await page.keyboard.type('2');
await page.waitForFunction(() => true);
await new Promise((r) => setTimeout(r, 200));
check('what is typed after the switch reaches the new socket',
ttyd.sockets[1].input.includes('2') && !ttyd.sockets[0].input.includes('2'),
JSON.stringify(ttyd.sockets.map((s) => s.input)));
check('the terminal is reset, so the picker is not drawn over the session',
!(await page.evaluate(() => document.body.innerText)).includes('SESSION ' + RUN_ID));
await browser.close();
await ttyd.stop();
const failed = checks.filter((c) => !c.ok).length;
console.log(`\n${checks.length - failed}/${checks.length} checks passed`);
process.exit(failed === 0 ? 0 : 1);
+81
View File
@@ -44,3 +44,84 @@ test("the query string rides along, because ttyd's --url-arg reads it off the We
'wss://h/ws'
);
});
// A WebSocket and a fetch just real enough for createClient: every instance is
// recorded, so a test can see which url was opened and what was done to the
// one before it.
function stubTransport() {
const sockets = [];
globalThis.WebSocket = function (url, protocols) {
this.url = url;
this.protocols = protocols;
this.readyState = 0;
this.closed = false;
this.sent = [];
this.send = function (data) { this.sent.push(data); };
this.close = function () { this.closed = true; if (this.onclose) this.onclose(); };
sockets.push(this);
};
globalThis.WebSocket.OPEN = 1;
globalThis.fetch = () => Promise.resolve({ json: () => ({ token: 't' }) });
return sockets;
}
function client(search, statuses) {
return globalThis.DEJIMA.protocol.createClient({
location: { protocol: 'http:', host: 'h', pathname: '/', search },
size: () => ({ cols: 80, rows: 24 }),
onOutput() {}, onTitle() {}, onPreferences() {},
onStatus: (s) => statuses.push(s)
});
}
test('switchTo opens a new socket for a different arg, and keeps the page', async () => {
const sockets = stubTransport();
const statuses = [];
const c = client('?arg=0123456789abcdef', statuses);
c.connect();
await Promise.resolve(); await Promise.resolve();
assert.equal(sockets.length, 1);
assert.equal(sockets[0].url, 'ws://h/ws?arg=0123456789abcdef');
c.switchTo('');
await Promise.resolve(); await Promise.resolve();
// The old one was closed - ttyd kills the command when the socket goes, so
// this is what ends the session being left - and a second one opened on the
// url with no arg, which is the picker.
assert.equal(sockets[0].closed, true);
assert.equal(sockets.length, 2);
assert.equal(sockets[1].url, 'ws://h/ws');
assert.equal(c.urls().ws, 'ws://h/ws');
});
test('a switch is not reported as the session dropping', async () => {
const sockets = stubTransport();
const statuses = [];
const c = client('?arg=0123456789abcdef', statuses);
c.connect();
await Promise.resolve(); await Promise.resolve();
statuses.length = 0;
c.switchTo('');
await Promise.resolve(); await Promise.resolve();
// "closed" is what puts "Disconnected. Reconnecting..." on the screen and
// starts the backoff. A switch is neither.
assert.deepEqual(statuses, ['connecting']);
});
test('a switch while disconnected drops the pending reconnect', async () => {
const sockets = stubTransport();
const statuses = [];
const c = client('?arg=0123456789abcdef', statuses);
c.connect();
await Promise.resolve(); await Promise.resolve();
sockets[0].onclose(); // the session ended; a retry is now queued
const queued = sockets.length;
c.switchTo('');
await Promise.resolve(); await Promise.resolve();
assert.equal(sockets.length, queued + 1);
assert.equal(sockets[sockets.length - 1].url, 'ws://h/ws');
// And the queued retry must not arrive afterwards and reopen the session
// that was just left.
await new Promise((r) => setTimeout(r, 700));
assert.equal(sockets.length, queued + 1);
});
-4
View File
@@ -1,8 +1,4 @@
Git workflow - do this even though nothing stops you from doing otherwise:
- Do new work in a git worktree for the feature branch, not by switching
branches in place in the mounted checkout - that's the user's actual
working copy, and changing its branch out from under them is disruptive
even if you switch it back.
<!-- core: worktree -->
- Push that branch to the remote when the work is complete and its tests
pass. Do that WITHOUT asking first: this workflow's destination is known,
-4
View File
@@ -1,8 +1,4 @@
Git workflow - do this even though nothing stops you from doing otherwise:
- Do new work in a git worktree for the feature branch, not by switching
branches in place in the mounted checkout - that's the user's actual
working copy, and changing its branch out from under them is disruptive
even if you switch it back.
<!-- core: worktree -->
- Push that branch to the remote, then run `{{pr_command}} create` from
the worktree. Never push directly to {{blocked_branches}}, on any remote,