Compare commits

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

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

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

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

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

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

static 408.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

remote 113, project 146, static 407.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Four tests, all mutation-checked: no tty means no prompt, the number typed is
the row acted on, q allows nothing, and the name starts at one column on
every row. static 393, project 145, remote 109, config 172, providers 94.
2026-08-26 21:51:46 -07:00
branderson baff452a22 Merge pull request 'Send one notification per stop, not a second one a minute later' (#238) from notify-once into development
Reviewed-on: #238
Reviewed-by: branderson <brad@branderson.io>
2026-08-26 21:49:51 -07:00
branderson d5551a5231 Stop the launch fixtures reaching the network to check for updates
tests / confined (proxy) (pull_request_target) Successful in 1m15s
tests / confined (conversation) (pull_request_target) Successful in 1m26s
tests / privileged (pull_request_target) Successful in 3m51s
tests / runner-env (confined) (pull_request_target) Successful in 39s
tests / confined (shellcheck) (pull_request_target) Successful in 59s
tests / confined (providers) (pull_request_target) Successful in 2m45s
tests / confined (remote) (pull_request_target) Successful in 3m59s
tests / runner-env (privileged) (pull_request_target) Successful in 40s
tests / confined (static) (pull_request_target) Successful in 5m26s
tests / confined (project) (pull_request_target) Successful in 5m37s
tests / confined (config) (pull_request_target) Successful in 8m55s
tests / confined (audit) (pull_request_target) Successful in 8m58s
core.update_check defaults on and bin/dejima runs the check on every launch,
so every test that drives a launch with a stubbed docker was doing a real
ls-remote against this checkout's own origin. Each launch gets its own HOME
and so its own empty cache, so nothing was throttled: it is one call per test,
stalling for the timeout wherever that origin is unreachable, and the notice
could land in $output depending on how far behind the checkout happened to be.

CI never saw it. A pull_request checkout is a detached HEAD and the tool exits
on that before it looks at a remote - which is exactly the shape .dejima.md
records as having shipped twice already: a precondition stated by leaving
something unset, true only where nothing else set it.

Turned off in the launch helper rather than in each fixture's config, because
the launch is what triggers it. Four suites have one, not just config.bats:
project.bats, remote.bats and static.bats drive launches too.

XDG_STATE_HOME is pinned in the same place, for the mirror of the same reason.
Unset, the cache lands under the test's own HOME and every test misses; set by
whoever ran the suite, the tests write into their real state directory.

_upd_fixture's git is now hermetic too - GIT_CONFIG_GLOBAL and _SYSTEM
neutralised alongside the identity it already pinned, so a host with
commit.gpgsign on cannot reach into it.

A test drives a launch with git wrapped on PATH and asserts ls-remote is never
called. Removing the fixture's update_check line fails it and prints the call.

config 173, project 141, remote 109, static 391.
2026-08-26 20:55:37 -07:00
branderson aa78ff896f Send one notification per stop, not a second one a minute later
tests / confined (proxy) (pull_request_target) Successful in 1m14s
tests / runner-env (confined) (pull_request_target) Successful in 41s
tests / confined (conversation) (pull_request_target) Successful in 1m27s
tests / confined (shellcheck) (pull_request_target) Successful in 1m0s
tests / confined (providers) (pull_request_target) Successful in 2m48s
tests / confined (project) (pull_request_target) Successful in 5m59s
tests / confined (config) (pull_request_target) Successful in 9m58s
tests / privileged (pull_request_target) Successful in 4m0s
tests / confined (remote) (pull_request_target) Successful in 4m16s
tests / runner-env (privileged) (pull_request_target) Successful in 41s
tests / confined (static) (pull_request_target) Successful in 5m41s
tests / confined (audit) (pull_request_target) Successful in 10m5s
Claude Code raises Notification when it needs permission to run something and
when the prompt has sat idle. A dejima session runs with permission prompts
off, so the first never happens - what is left is a timer that expires
shortly after the Stop it follows. Registering both handlers put two pushes
on the operator's phone for one event, about a minute apart, the second
saying nothing the first had not.

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

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

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

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

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

Suites: remote 111, providers 94, static 387, config 162.
2026-08-26 20:54:46 -07:00
branderson 588b4cac51 Merge pull request 'Require the whole first line to be the disclosure marker' (#252) from marker-boundary into development
Reviewed-on: #252
Reviewed-by: branderson <brad@branderson.io>
2026-08-26 20:32:43 -07:00
branderson dd7fd109a7 Merge pull request 'Collapse six doubled @test headers' (#241) from doubled-headers into development
Reviewed-on: #241
Reviewed-by: branderson <brad@branderson.io>
2026-08-26 20:20:32 -07:00
branderson 844ff1c312 Merge pull request 'Strip session-authored escapes before they reach the terminal' (#240) from review-fixes into development
Reviewed-on: #240
Reviewed-by: branderson <brad@branderson.io>
2026-08-26 20:19:40 -07:00
branderson a5d7782610 Require the whole first line to be the disclosure marker
tests / confined (providers) (pull_request_target) Successful in 2m40s
tests / confined (shellcheck) (pull_request_target) Successful in 59s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (config) (pull_request_target) Successful in 8m48s
tests / confined (conversation) (pull_request_target) Successful in 1m27s
tests / confined (project) (pull_request_target) Successful in 5m23s
tests / confined (proxy) (pull_request_target) Successful in 1m14s
tests / confined (remote) (pull_request_target) Successful in 3m49s
tests / confined (static) (pull_request_target) Successful in 5m6s
tests / privileged (pull_request_target) Successful in 3m52s
tests / runner-env (confined) (pull_request_target) Successful in 40s
tests / confined (audit) (pull_request_target) Successful in 10m4s
The already-disclosed check was a prefix match, so a body opening
`**Filed by LLM agent** - reviewed and confirmed by a person` counted as
disclosed and no clean marker was prepended. That makes the marker line
itself agent-authorable: the one line dejima writes to say an agent wrote
this is the one the session gets to finish.

The first line now has to be the marker and nothing else. Trailing whitespace
is allowed, being invisible and changing nothing about what the line says.
The idempotence this replaced still holds - a body whose first line is exactly
the marker passes through untouched, which is what stopped the doubling.

Riding along, from the same review: bin/dejima's comment said an empty
DEJIMA_FORGE_REPO leaves the guard doing nothing. The guard refuses any
--repo or GH_REPO outright in that case, which is the opposite and the point -
a workspace with no origin has nothing such a flag could legitimately name.

Tests: a welded suffix gets a clean marker prepended, and trailing whitespace
still counts as disclosed. Mutation-checked against the prefix match.

static 392, providers 94, config 172, project 141, audit 164, remote 109,
proxy 32, conversation 12.
2026-08-26 20:09:28 -07:00
branderson 829537c17a Collapse six doubled @test headers
tests / confined (proxy) (pull_request_target) Successful in 1m13s
tests / confined (conversation) (pull_request_target) Successful in 1m27s
tests / runner-env (confined) (pull_request_target) Successful in 38s
tests / confined (shellcheck) (pull_request_target) Successful in 56s
tests / privileged (pull_request_target) Successful in 3m37s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (remote) (pull_request_target) Successful in 3m57s
tests / confined (project) (pull_request_target) Successful in 5m32s
tests / confined (static) (pull_request_target) Successful in 5m27s
tests / confined (providers) (pull_request_target) Successful in 2m37s
tests / confined (config) (pull_request_target) Successful in 9m11s
tests / confined (audit) (pull_request_target) Successful in 9m27s
`@test "name" {@test "name" {` on one line, in five static.bats tests and one
in providers.bats. A paste artifact, and mine - three of the five sit on
security tests.

Current bats tolerates it: each registers as one test and the body runs. That
is undocumented parser behaviour to be relying on for a test that asserts a
boundary holds, and it reads as a typo that might mean something.

Mechanical, and checked as such. The rewrite asserts the two names in a pair
are identical before collapsing, so a pair that had somehow diverged would
stop it rather than lose one silently. Registered test counts are unchanged
either side of the change - `bats --count` gives 391 and 94 before and after -
and both suites pass with the same numbers.
2026-08-26 19:32:21 -07:00
branderson 46329034e1 Strip session-authored escapes before they reach the terminal
tests / confined (proxy) (pull_request_target) Successful in 1m16s
tests / confined (conversation) (pull_request_target) Successful in 1m28s
tests / confined (shellcheck) (pull_request_target) Successful in 1m0s
tests / confined (providers) (pull_request_target) Successful in 2m55s
tests / runner-env (confined) (pull_request_target) Successful in 40s
tests / privileged (pull_request_target) Successful in 3m54s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (static) (pull_request_target) Successful in 5m42s
tests / confined (remote) (pull_request_target) Successful in 4m12s
tests / confined (project) (pull_request_target) Successful in 5m58s
tests / confined (config) (pull_request_target) Successful in 9m27s
tests / confined (audit) (pull_request_target) Successful in 9m40s
An epitaph is a session's own bytes, and printing them to the operator is
what the tool is for - its header comment says so. `show` cat'd the body,
`search` printed matched lines and the store filename, and `list` truncated
the `at` and `trigger` fields without stripping anything. All of it comes out
of the transcript, and the filename is built from the transcript's own
timestamp and uuid. A planted C0/C1/OSC sequence fires when the operator
reads it later.

dejima-audit already had the fix for exactly this class, so the rule moves to
dejima-color-lib.sh - sourced by all three - rather than being copied. Audit's
behaviour is unchanged; it now calls the shared one.

Two forms, because one rule does not fit both. A field is a line, so every C0
byte goes including LF, which is what stops a planted newline forging a second
row. A body is many lines, and the field rule applied to one would fold a whole
epitaph onto a single line - so LF and TAB survive there and nothing else does.

`search` sanitizes before grep rather than after: grep colours its own output
when writing to a terminal, and cleaning afterwards would strip the highlight
along with the planted bytes.

`list` is a python renderer that parses the fields itself, so the rule is
written twice. A test reads the regex out of the script and compares it against
the shell function on a string carrying one of each class, so the second copy
cannot drift.

Same class, smaller surface, same PR: dejima-update-check printed the remote
name and the branch it tracks raw. Both are read from the checkout's
.git/config, which is session-writable when dejima's own checkout is the
workspace - the reason that tool already reaches the remote by URL rather than
by name.

Tests: four in config.bats, all mutation-checked, plus the drift pin in
static.bats. audit 164, config 176, static 392, providers 94, project 141,
remote 109, proxy 32, conversation 12.
2026-08-26 19:24:43 -07:00
branderson d973ba3cd7 Merge pull request 'Stop the declaration-swap test wedging its own race window' (#239) from swap-flake into development
Reviewed-on: #239
Reviewed-by: branderson <brad@branderson.io>
2026-08-26 18:42:26 -07:00
branderson edbb0a4913 Stop the declaration-swap test wedging its own race window
tests / confined (proxy) (pull_request_target) Successful in 1m13s
tests / confined (conversation) (pull_request_target) Successful in 1m25s
tests / confined (project) (pull_request_target) Successful in 5m44s
tests / runner-env (confined) (pull_request_target) Successful in 38s
tests / confined (shellcheck) (pull_request_target) Successful in 58s
tests / confined (providers) (pull_request_target) Successful in 2m42s
tests / privileged (pull_request_target) Successful in 3m47s
tests / confined (remote) (pull_request_target) Successful in 3m59s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (static) (pull_request_target) Successful in 5m26s
tests / confined (config) (pull_request_target) Successful in 9m27s
tests / confined (audit) (pull_request_target) Successful in 9m40s
`confined (project)` fails intermittently on test 140, and the guard it
covers is fine: the assertion that fails is `refused -gt 0`, the vacuity
check, while the one that matters - the victim file still reads `original` -
passes. The window the test needs simply never opened.

Measured rather than inferred. The swapper renames `.dejima` aside and plants
a symlink; `declare_entry` calls os.makedirs the moment the directory is
missing, so the writer puts a real directory back where the symlink is meant
to be. The swapper's `unlink` then fails with "Is a directory", `held` is left
stranded, and every later cycle fails with "Directory not empty". In one
instrumented run 822 of 917 cycles were dead: it goes through the motions for
the rest of the run without planting anything. The window closes after about
a second, and a run where it closes before the writer's first attempt scores
zero. Locally that was 2 failures in 12 runs.

Each half now clears whatever is at the path before swapping, so a collision
costs one cycle instead of the rest of the run. Discarding what the writer put
there is what the session being modelled would do anyway - it owns that
directory.

The vacuity check is kept and given something to stand on: the swapper records
plants and the longest run of cycles that planted nothing, and both tests
assert on it. A plant count alone does not separate the cases, since a wedged
swapper still plants about a hundred times before it sticks. Health is written
through a rename because the reader is a shell that may look mid-write, and
truncate-then-write hands it an empty file often enough to matter - which
reads as "never planted anything" and fails for the wrong reason.

Verified: 0 failures in 30 runs, against 2 in 12 before. With the old swapper
restored the stall check fires and names it - "stalled for 229 consecutive
cycles" - rather than failing as an unexplained zero.

project 141.
2026-08-26 18:29:36 -07:00
branderson 919e723de8 Merge pull request 'Stop stating one harness's specifics as universal' (#236) from harness-neutral-docs into development
Reviewed-on: #236
Reviewed-by: branderson <brad@branderson.io>
2026-08-26 17:56:06 -07:00
branderson be1d4521ec Merge pull request 'Say at startup when this checkout is behind its remote' (#237) from update-check into development
Reviewed-on: #237
Reviewed-by: branderson <brad@branderson.io>
2026-08-26 17:54:38 -07:00
branderson 37c86e844d Merge pull request 'Name both properties the port-22 claim rests on' (#235) from port22-rests-on-two into development
Reviewed-on: #235
Reviewed-by: branderson <brad@branderson.io>
2026-08-26 17:53:22 -07:00
branderson 831221d930 Say at startup when this checkout is behind its remote
tests / confined (proxy) (pull_request_target) Successful in 1m14s
tests / confined (conversation) (pull_request_target) Successful in 1m26s
tests / privileged (pull_request_target) Successful in 3m55s
tests / confined (static) (pull_request_target) Successful in 5m31s
tests / confined (config) (pull_request_target) Successful in 9m37s
tests / runner-env (confined) (pull_request_target) Successful in 37s
tests / confined (shellcheck) (pull_request_target) Successful in 59s
tests / confined (providers) (pull_request_target) Successful in 2m49s
tests / confined (remote) (pull_request_target) Successful in 4m9s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
tests / confined (project) (pull_request_target) Successful in 5m54s
tests / confined (audit) (pull_request_target) Successful in 9m49s
dejima updates by `git pull`, so a launch can tell you there is a newer one
by asking the remote your checkout already tracks for the tip of the branch
it already follows. One line, before the image build rather than after it,
and advisory: a check that cannot answer does not stop a launch.

The remote is reached by URL, from outside the repository, and never by
remote name. `git ls-remote origin` reads the checkout's own config for how
to get there, and core.sshCommand, credential.helper and url.<base>.insteadOf
each name a command git then runs on the host - measured, not assumed. dejima
mounts its own checkout writable when that checkout is the workspace, so a
session writing .git/config there would otherwise get host execution out of
the next launch. Same class as the core.hooksPath route docs/security.md
already describes, with one difference: hooksPath waits for the operator to
run a git command, and a startup check waits for nothing. protocol.allow
holds the transports to ones that cannot name a command, and a URL that
could be read as an option is refused rather than passed on.

What still crosses is the URL, which the same write can repoint. That buys
one outbound request and no execution, and the audit reports the config
change it took. Stated in docs/security.md under its own heading, because a
request dejima makes on its own account is the one an operator is least
expecting.

The remote is asked at most hourly; the answer it gave is compared against
HEAD on every launch, so the notice appears each time until you pull and then
stops immediately rather than an hour later. A failed lookup is recorded too,
or a checkout with no route to its remote pays the timeout every launch.

[core] update_check = off stops it entirely, and then dejima contacts nothing
of its own accord.

Tests: ten in config.bats against a real checkout and a real remote, neither
of them this repository and no push anywhere - dejima's own forge guard
refuses a push to a path, so a fixture built that way would pass on a host
and fail inside a sandbox.

Four are mutation-checked. The security one observes a filesystem side effect
rather than a message, because the tool sends git's stderr to /dev/null and an
output assertion held whether the command ran or not; it also asserts the
control, so it cannot pass by the threat quietly going away.
2026-08-26 14:56:09 -07:00
branderson f100b0b915 Stop stating one harness's specifics as universal
tests / confined (proxy) (pull_request_target) Successful in 1m17s
tests / runner-env (confined) (pull_request_target) Successful in 38s
tests / confined (providers) (pull_request_target) Successful in 2m54s
tests / privileged (pull_request_target) Successful in 3m54s
tests / runner-env (privileged) (pull_request_target) Successful in 41s
tests / confined (static) (pull_request_target) Successful in 5m41s
tests / confined (conversation) (pull_request_target) Successful in 1m26s
tests / confined (shellcheck) (pull_request_target) Successful in 57s
tests / confined (remote) (pull_request_target) Successful in 4m7s
tests / confined (project) (pull_request_target) Successful in 5m54s
tests / confined (config) (pull_request_target) Successful in 9m14s
tests / confined (audit) (pull_request_target) Successful in 9m46s
Cline ships and the README named claude-code as the only harness, told
every reader to sign in on the host, and described the entry point as bare
`claude`. None of those hold for a harness pointed at a model you serve
yourself, and the last one has not held for anything since `[core] harness`
became required.

Two of the corrected claims were factual errors rather than framing:
`[core] command` has no `claude` default - it falls back to the selected
harness's declared `command` - and `install.sh` writes no hooks itself; the
selected harness's plugin registers whatever guards it can host, into its
own config directory. docs/configuration.md and docs/security.md both
documented the old default, and bin/dejima's comment still described an
entrypoint expansion that no longer exists.

Where the flag name was doing the work of "with permission prompts off",
the meaning replaces it. Where a section is genuinely about Claude Code -
the ~/.claude mount inventory, the throwaway ~/.claude.json - it keeps
naming it.

Tests: two new, both mutation-checked. The provider lists are asserted
against the list line rather than the whole file, because a name appearing
elsewhere in the README makes a whole-file grep pass while the list is
wrong - the first version did that and survived deleting cline.

Prose greps now go through a flat_prose helper. Three existing assertions
built the same normalisation inline and none squeezed whitespace, so a
phrase spanning an indented wrap matched nothing; reflowing a paragraph in
this change broke one of them without altering a word.
2026-08-26 12:26:09 -07:00
branderson c94447f97e Document that the marker is added once
tests / confined (proxy) (pull_request_target) Successful in 1m13s
tests / confined (providers) (pull_request_target) Successful in 2m40s
tests / confined (shellcheck) (pull_request_target) Successful in 58s
tests / runner-env (confined) (pull_request_target) Successful in 37s
tests / confined (conversation) (pull_request_target) Successful in 1m26s
tests / privileged (pull_request_target) Successful in 3m47s
tests / runner-env (privileged) (pull_request_target) Successful in 40s
tests / confined (remote) (pull_request_target) Successful in 3m58s
tests / confined (project) (pull_request_target) Successful in 5m34s
tests / confined (config) (pull_request_target) Successful in 8m58s
tests / confined (static) (pull_request_target) Successful in 5m29s
tests / confined (audit) (pull_request_target) Successful in 9m34s
Documentation review, in the release pass. #233 changed the behaviour -
a body that already opens with the marker gets no second one, and the
session is told the marker is added for it - and providers.md still
described only the prepending.

A document that describes half of a behaviour is the kind of thing the
release's documentation review exists to catch, and this is the one it
found.
2026-08-26 03:21:43 -07:00
branderson aa3e7090a1 Name both properties the port-22 claim rests on
tests / confined (config) (pull_request_target) Successful in 9m8s
tests / confined (audit) (pull_request_target) Successful in 9m37s
tests / confined (proxy) (pull_request_target) Successful in 1m14s
tests / confined (conversation) (pull_request_target) Successful in 1m26s
tests / runner-env (confined) (pull_request_target) Successful in 39s
tests / privileged (pull_request_target) Successful in 3m55s
tests / confined (remote) (pull_request_target) Successful in 4m10s
tests / confined (project) (pull_request_target) Successful in 5m57s
tests / confined (shellcheck) (pull_request_target) Successful in 58s
tests / confined (providers) (pull_request_target) Successful in 2m53s
tests / runner-env (privileged) (pull_request_target) Successful in 40s
tests / confined (static) (pull_request_target) Successful in 5m38s
"GitHub's IP ranges are only allowlisted on port 443, so port 22 is
physically unreachable, not merely discouraged by a hook."

True, and an IPv4 statement. The allowlist decides what is reachable
over v4. What makes port 22 unreachable AT ALL is that the container
blocks IPv6 as well - an allowlist naming one address family says
nothing about the other, and a session with working IPv6 would reach
GitHub's v6 ranges on whatever port it liked.

Both halves named now, in the README and in providers.md, along with the
part that holds the second one up: init-firewall.sh refuses to start
rather than run with IPv6 live and unfilterable.

Found by reciprocity rather than by review. dejima-ci-hardener makes the
same argument about dejima's behaviour and named one half; they fixed
theirs after this side pointed them at the wrong document, and checking
whether dejima had the mirror of it turned out to be the useful part.
Standing question 8 again, this time pointed inward.

The risk being closed is not that the claim is false. It is that it
stays readable if either property quietly stops holding, which is
exactly what a reader cannot check and what a future change would not
trip over.

The test flattens newlines and strips emphasis before every match,
without exception - including in this test, whose first version
flattened inside its loop and not in the check after it, and failed on a
line break for the fourth time in this suite.
2026-08-26 03:14:25 -07:00
branderson 2f7663b03f Merge pull request 'Say that the push has to leave the host, and where the guarantee stops' (#234) from remote-access-egress into development
Reviewed-on: #234
Reviewed-by: branderson <brad@branderson.io>
2026-08-26 02:57:23 -07:00
branderson 4da489879f Say that the push has to leave the host, and where the guarantee stops
tests / confined (proxy) (pull_request_target) Successful in 1m14s
tests / confined (conversation) (pull_request_target) Successful in 1m26s
tests / runner-env (confined) (pull_request_target) Successful in 37s
tests / confined (shellcheck) (pull_request_target) Successful in 55s
tests / privileged (pull_request_target) Successful in 3m50s
tests / confined (project) (pull_request_target) Successful in 5m50s
tests / confined (config) (pull_request_target) Successful in 9m20s
tests / confined (audit) (pull_request_target) Successful in 9m51s
tests / confined (providers) (pull_request_target) Successful in 2m51s
tests / confined (remote) (pull_request_target) Successful in 4m3s
tests / runner-env (privileged) (pull_request_target) Successful in 40s
tests / confined (static) (pull_request_target) Successful in 5m32s
From the devbox-hardener side, where an operator hit the first of these
and fixed it by hand before either project documented it.

dejima-notify-watch runs on the host, so the container's allowlist has
nothing to do with whether it can reach an ntfy server. On a machine
behind an egress filter it does not control, the push is dropped and
every symptom points somewhere else: the config is correct, the unit is
healthy, nothing errors anywhere, and the phone is silent. Someone in
that position re-reads their config rather than suspecting a boundary
they cannot see.

Stated in general terms rather than naming a sibling's runbook. The same
is true of any host behind a filter, and a rule that belongs to another
repository would go stale here. The troubleshooting walk gains a step
too, since that is where somebody with a silent phone actually is.

And the notification guarantee now says where it stops. The page argues
carefully that no sandbox-controlled bytes reach the phone, and that
argument is about the CONTAINER boundary - ntfy_url is a credential for
a channel the phone trusts, in a file on the host, and anyone with the
operator's account can publish to that topic, drown it, or read it. None
of that passes through the sandbox, so none of the reasoning applies.

That matters downstream rather than being a caveat for its own sake: a
monitoring design for these machines uses a separate topic whose
credential is deliberately not on the box, so a compromised host cannot
forge alerts about itself. It only works if the reader knows the box's
own topic is not privileged, and nothing said so.

The walk counts itself in prose, so the test checks the count against
the steps - a document that says five and lists six is arguing with
itself in front of the reader.

Both tests flatten newlines and strip emphasis before matching. Two
assertions failed on the first run for that reason and neither had
anything to do with what the document said.
2026-08-26 01:15:15 -07:00
branderson 569d40513a Merge pull request 'Add the authorship marker once, and tell the session it is added' (#233) from idempotent-disclosure into development
Reviewed-on: #233
Reviewed-by: branderson <brad@branderson.io>
2026-08-26 01:02:21 -07:00
branderson ac4b09843b Merge pull request 'Lead with what is different, not with the premise everyone shares' (#232) from readme-positioning into development
Reviewed-on: #232
Reviewed-by: branderson <brad@branderson.io>
2026-08-26 01:01:54 -07:00
branderson a918047f93 Review: drop the claim we have not earned, and stop writing in blocks
tests / confined (proxy) (pull_request_target) Successful in 1m14s
tests / confined (conversation) (pull_request_target) Successful in 1m27s
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / confined (shellcheck) (pull_request_target) Successful in 58s
tests / confined (providers) (pull_request_target) Successful in 2m48s
tests / privileged (pull_request_target) Successful in 3m53s
tests / confined (remote) (pull_request_target) Successful in 4m6s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
tests / confined (project) (pull_request_target) Successful in 5m50s
tests / confined (audit) (pull_request_target) Successful in 9m52s
tests / confined (static) (pull_request_target) Successful in 5m26s
tests / confined (config) (pull_request_target) Successful in 9m18s
Two things.

"The next session reads what this one left behind" was in the list of
layers dejima puts a control on, and it should not have been. What is
actually hardened there is the epitaph store and the audit store, which
are never mounted - one narrow policy, not a boundary the project is
built around. A session's own project transcripts ARE mounted, so the
sentence claimed more than the code does. The two layers left are ones
with a whole sister project each.

And the opening had become four paragraphs of near-identical length,
each a self-contained pitch, which reads as machine-written because it
is the shape a machine reaches for. Two of them were making the same
argument: one said an agent with prompts off can be turned against you,
the next said the container is what makes that trade reasonable.

Merged into one, and the rhythm varied deliberately: four sentences,
then two, then two, with the last paragraph opening on a short one. The
concrete threat - a prompt injection or a poisoned dependency, which
will not announce itself - survives the merge, because it is the reason
the trade needs making at all.

Seventy-three words to the definition, against a hundred-word budget.
2026-08-26 00:48:57 -07:00
branderson 68aedd157a Review: the opening is a pitch, not a history lesson
tests / confined (proxy) (pull_request_target) Successful in 1m14s
tests / confined (conversation) (pull_request_target) Successful in 1m27s
tests / runner-env (confined) (pull_request_target) Successful in 37s
tests / confined (shellcheck) (pull_request_target) Successful in 57s
tests / confined (providers) (pull_request_target) Successful in 2m50s
tests / confined (audit) (pull_request_target) Successful in 10m1s
tests / privileged (pull_request_target) Successful in 3m50s
tests / confined (remote) (pull_request_target) Successful in 4m8s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
tests / confined (static) (pull_request_target) Successful in 5m31s
tests / confined (project) (pull_request_target) Successful in 5m54s
tests / confined (config) (pull_request_target) Successful in 9m26s
The differentiator paragraph spent a reader's attention on how it was
arrived at - a survey, a date, what the field believes as of 2026 -
before saying the thing itself. A reader meeting dejima has a short
budget and none of that is for them.

Cut to the claim:

  Most sandboxes stop at the container. The blast radius does not: a
  contained session still opens pull requests that CI executes, an
  escape lands on a machine with its own reach across your network, and
  the next session reads what this one left behind. dejima puts a
  control on every layer it passes through.

Fifty words rather than a hundred and twenty, and the survey does not
appear at all - it is what convinced us the claim is true, which is a
different thing from what makes it worth reading.

The test now holds a word budget on the opening rather than a phrase
from the version being replaced. It was asserting "field's default
position" appeared, which pinned the history lesson in place: the
sentence I was asked to remove was the one the test required. A budget
expresses the constraint that actually applies, and it catches the
failure mode that reads as thorough rather than the one that reads as
sloppy.
2026-08-25 23:38:00 -07:00
branderson f84e47c7c7 Add the authorship marker once, and tell the session it is added
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 39s
tests / confined (shellcheck) (pull_request_target) Successful in 55s
tests / confined (providers) (pull_request_target) Successful in 2m54s
tests / privileged (pull_request_target) Successful in 4m1s
tests / runner-env (privileged) (pull_request_target) Successful in 40s
tests / confined (audit) (pull_request_target) Successful in 9m53s
tests / confined (remote) (pull_request_target) Successful in 4m6s
tests / confined (static) (pull_request_target) Successful in 5m36s
tests / confined (project) (pull_request_target) Successful in 5m55s
tests / confined (config) (pull_request_target) Successful in 9m20s
Issues and pull requests opened from a session carried the marker twice.

The cause is a loop rather than a bug in one place. docker/forge-guard.sh
prepends it; an agent reading the project's existing issues sees every
one of them open with that line, concludes it is the house style, and
writes it into the body; the shadow adds a second. The doubled artifact
then teaches the next agent the same thing, so it spread instead of
settling. Nothing in the briefing said the marker was automatic, so an
agent had no way to know otherwise.

Two halves, and the first is the one that holds regardless of what a
session does.

The guard is idempotent now: a body that already opens with the marker
gets no second one. Safe because the marker is a statement and not a
count - a body that already says an agent wrote it has disclosed exactly
what the mechanism exists to disclose. Anchored after leading whitespace,
so the same words further down stay prose about disclosure and still get
one prepended.

That fix needed a refactor to be true at all. Only --body-file and
--body-file= went through the shared function; --body and --body= built
the marker inline at their own call sites. So a rule added in one place
was not in force in the other, which is how the first version of this
fix passed a test and left two of the four spellings doubling. All four
go through one function now, and the test exercises all four.

The second half stops it being written in the first place: the briefing
says the marker is added and asks the session not to write one, and says
why the existing issues all open with it. Emitted only when something
actually discloses - a session told about a marker nothing adds would be
told a falsehood, and would then reasonably write one itself, which is
the behaviour being prevented.
2026-08-25 23:11:18 -07:00
branderson e8de5edbf0 Lead with what is different, not with the premise everyone shares
tests / confined (proxy) (pull_request_target) Successful in 1m10s
tests / runner-env (confined) (pull_request_target) Successful in 39s
tests / confined (conversation) (pull_request_target) Successful in 1m23s
tests / confined (shellcheck) (pull_request_target) Successful in 59s
tests / confined (providers) (pull_request_target) Successful in 2m39s
tests / runner-env (privileged) (pull_request_target) Successful in 40s
tests / privileged (pull_request_target) Successful in 3m46s
tests / confined (remote) (pull_request_target) Successful in 3m57s
tests / confined (static) (pull_request_target) Successful in 5m24s
tests / confined (project) (pull_request_target) Successful in 5m42s
tests / confined (config) (pull_request_target) Successful in 9m20s
tests / confined (audit) (pull_request_target) Successful in 9m55s
The README opened on "assume they are hostile", which is the field's
default position as of 2026 - a survey of comparable projects found the
premise everywhere and nobody claiming it as a distinction. Opening on it
claims novelty dejima does not have, and buries the thing it does have.

What the survey did not find anywhere was a project that follows the
blast radius past the container. That is now the second paragraph: a
contained session can still open a pull request and have a runner build
that commit elsewhere; an escape arrives somewhere with its own reach
across a network; the next session reads what this one left behind. Each
is a layer, each has a control, and some of the controls are separate
tools because they run where dejima does not.

Written as layers rather than as a list of the tools, so a fourth does
not need the paragraph rewritten.

And the threat model gains the route in that its ranked list did not
name: the operator running something the session asked them to run.
dejima tells agents to hand host-side work over rather than attempt it,
which makes that a designed, frequent, legitimate workflow - and exactly
therefore a delivery mechanism, since a script that does what it says and
one thing more is indistinguishable at the moment of running it. Nothing
inspects what crosses that handoff and nothing can, because the point of
it is that a person decides.

Ranked fifth rather than lower because it needs no exploit at all, which
is the property the ordering is built on.

Stated with what it is not: an argument for making the handoff harder. A
session that cannot ask is a session that works around the operator
instead. It is an argument for being able to answer afterwards what a
command actually did, which is the layer below this repository's.

Both pinned, because positioning claims drift back toward whatever is
easiest to write. The README test flattens newlines before matching -
the first version failed on a sentence that happened to wrap, for
reasons that had nothing to do with what it said.
2026-08-25 23:00:22 -07:00
branderson deabec04d4 Merge pull request 'Hold the resume resize long enough for a harness to see it' (#230) from resize-hold into development
Reviewed-on: #230
Reviewed-by: branderson <brad@branderson.io>
2026-08-25 22:51:51 -07:00
branderson 7c1e95ab97 Hold the resume resize long enough for a harness to see it
tests / confined (audit) (pull_request_target) Successful in 10m8s
tests / confined (config) (pull_request_target) Successful in 9m32s
tests / confined (conversation) (pull_request_target) Successful in 1m27s
tests / confined (project) (pull_request_target) Successful in 6m2s
tests / confined (proxy) (pull_request_target) Successful in 1m17s
tests / confined (static) (pull_request_target) Successful in 5m40s
tests / confined (shellcheck) (pull_request_target) Successful in 55s
tests / privileged (pull_request_target) Successful in 4m5s
tests / runner-env (confined) (pull_request_target) Successful in 40s
tests / runner-env (privileged) (pull_request_target) Successful in 40s
tests / confined (providers) (pull_request_target) Successful in 3m5s
tests / confined (remote) (pull_request_target) Successful in 4m21s
#229 changed the resume poke from a same-size SIGWINCH to a real resize,
because neither harness answers the former. It still did not work, and
the reason is that the shipped code was not what I measured.

Both probes that validated the resize held the changed size for 0.4
seconds between the two ioctls. The code did the two calls back to back.
Two ioctls with nothing between them are not a resize a program can
observe: the signal arrives, the handler reads the size as it is NOW,
and by then it is back where it started.

Measured on a pty, bytes written after the poke:

                 no hold   0.1s   0.2s
   Cline               0      0  17810
   Claude Code         0   3328   3328

Cline sets the floor between 0.1 and 0.2 seconds. This holds for half a
second - 2.5x that, as headroom for the hop nothing here can measure,
since a real session's resize travels from the host tty through the
container client and the daemon to the container's own tty before the
harness sees it. The cost is a terminal one row short for half a second
while the harness is redrawing anyway.

One hypothesis died on the way, and it is worth recording because it was
the obvious one: that the resize was lost because the client was stopped
while it happened. Measured, and no - a resize applied to a stopped
program IS observed when it continues. Ordering was never the variable.
The hold was.

The test asserts on the source rather than on behaviour, which is a
weaker test and the honest one available. The fixture's stand-in handles
the signal faster than either real harness, so it observes an
intermediate size that both of them miss - it passed while both real
programs failed. A test that cannot see the failure cannot guard the fix,
and pretending otherwise by tuning the stand-in until it agrees would be
inventing the evidence.

Refs #228.
2026-08-25 22:35:34 -07:00
branderson 5a4a72ffb8 Merge pull request 'Make the resume poke a real resize, because neither harness answers a same-size SIGWINCH' (#229) from real-resize-on-resume into development
Reviewed-on: #229
Reviewed-by: branderson <brad@branderson.io>
2026-08-25 22:12:40 -07:00
branderson 77f41fb60e Merge pull request 'Stop putting forge credentials in the docker command line' (#227) from credential-argv into development
Reviewed-on: #227
Reviewed-by: branderson <brad@branderson.io>
2026-08-25 22:12:12 -07:00
branderson ddedcf0c15 Make the resume poke a real resize, because neither harness answers a
tests / confined (proxy) (pull_request_target) Successful in 1m13s
tests / confined (conversation) (pull_request_target) Successful in 1m26s
tests / runner-env (confined) (pull_request_target) Successful in 39s
tests / confined (providers) (pull_request_target) Successful in 2m49s
tests / privileged (pull_request_target) Successful in 3m49s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (static) (pull_request_target) Successful in 5m19s
tests / confined (project) (pull_request_target) Successful in 5m40s
tests / confined (shellcheck) (pull_request_target) Successful in 1m1s
tests / confined (remote) (pull_request_target) Successful in 4m0s
tests / confined (config) (pull_request_target) Successful in 8m59s
tests / confined (audit) (pull_request_target) Successful in 9m45s
same-size SIGWINCH

The resume path re-applied the terminal modes and then poked the process
group with SIGWINCH, on the reasoning that a resize is what makes a
full-screen program repaint. The reasoning was right. The signal was not
enough.

Measured against the real programs on a pty, counting the bytes each
wrote in a fixed window after each kind of poke:

                     idle   same-size SIGWINCH   size changed and back
  Claude Code           0                    0                    3337
  Cline                 0                    0                   17810
  nvim (control)        0                 3356                    6724

Both shipped harnesses ignore the bare signal outright: they answer it by
re-reading the size, seeing the same numbers, and doing nothing. So the
poke has never worked for either program dejima actually runs. nvim is
the control that shows the measurement is real rather than a broken
probe.

That is the reported symptom on Cline exactly - resume leaves the shell's
job-control lines and the typed `fg` on screen, with the harness's UI
drawn around them, because the harness never redrew.

So the size is changed and changed straight back. TIOCSWINSZ raises
SIGWINCH on the foreground process group by itself, the container client
answers a resize by resizing the container's TTY, and the harness sees
dimensions that actually moved. Only the kernel's idea of the size
changes; the terminal emulator is not told and does not need to be, since
what a program reads is the kernel's value.

The bare signal is kept as well, and is not redundant: nvim is the
measured example of a program that repaints on it, and a session started
with --cmd should not be worse off than a harness.

Validated twice, both against the real programs: once with the ioctl
directly, and once through the same `stty rows` from a separate process
that this script uses. Identical byte counts.

The fixture's stand-in redrew on the bare signal, like nvim - the one
behaviour neither real harness has. That is why the suite could not see
this: an invented stand-in modelled the case that does not occur. It now
redraws only when the dimensions moved, and with that change the existing
cycle1/cycle2-redraw-poked assertions fail without the fix and pass with
it. The measurements are in the fixture next to the behaviour they
justify.

Refs #228. This does not address the other half of that issue, the Claude
Code cursor, which is a focus-state bug inside the harness.
2026-08-25 22:01:53 -07:00
branderson c8968a3c80 Stop putting forge credentials in the docker command line
tests / confined (proxy) (pull_request_target) Successful in 1m14s
tests / confined (conversation) (pull_request_target) Successful in 1m28s
tests / runner-env (confined) (pull_request_target) Successful in 38s
tests / confined (shellcheck) (pull_request_target) Successful in 55s
tests / confined (providers) (pull_request_target) Successful in 2m50s
tests / privileged (pull_request_target) Successful in 3m58s
tests / confined (remote) (pull_request_target) Successful in 4m5s
tests / runner-env (privileged) (pull_request_target) Successful in 41s
tests / confined (static) (pull_request_target) Successful in 5m32s
tests / confined (project) (pull_request_target) Successful in 5m55s
tests / confined (config) (pull_request_target) Successful in 9m28s
tests / confined (audit) (pull_request_target) Successful in 10m6s
Fixes #226.

Every forge credential resolved through an `@` reference is the CONTENTS
of a token file, and it was passed to `docker compose run` as
`-e NAME=value`. Command lines are world-readable through
/proc/<pid>/cmdline, so `ps` printed the token to any process on the
host - and from there into whatever captures ps output.

That is how this was found: an operator ran ps while debugging something
unrelated, pasted the result, and the live token was in it.

The bare `-e NAME` form takes the value from the launcher's own
environment instead, where it sits in /proc/<pid>/environ - mode 0400,
owner only. Same reach into the container, different audience outside
it.

This repository already made the argument one layer down. A forge's own
CLI feeds curl its auth header on stdin rather than as an argument, and
says why in almost the same words. The reasoning was right and had not
reached the launcher - the shape the research review's eighth standing
question is about, and the second instance of it this week.

What this does NOT change: `docker inspect` still shows the resolved
value to anyone who can reach the daemon. That is daemon access rather
than any process at all, and it is not something the launcher can fix
by changing how it passes the value. Said out loud in docs/security.md
rather than left for someone to discover.

A sweep for the same class turned up nothing else. The forge CLIs feed
curl on stdin; dejima-audit already redacts secrets from its store, with
a comment recording that a keyword match once put live tokens there in
cleartext; nothing echoes a resolved token, embeds one in a URL, dumps
the environment at startup, or runs under set -x. dejima-attach reads
the whole container environment to match one variable and never prints
it - wider than it needs to be, and not a leak.

The regression test needs no Docker: a stub captures argv and asserts
the secret is absent, the name is present, and the form is bare. It
fails when the change is reverted, naming the token.
2026-08-25 21:37:49 -07:00
branderson 937b697d07 Merge pull request 'Stop calling two closed differences unfixed' (#225) from release-doc-fix into development
Reviewed-on: #225
Reviewed-by: branderson <brad@branderson.io>
2026-08-25 19:50:42 -07:00
branderson a7a4f0b39d Stop calling two closed differences unfixed
tests / confined (proxy) (pull_request_target) Successful in 1m11s
tests / confined (conversation) (pull_request_target) Successful in 1m24s
tests / runner-env (confined) (pull_request_target) Successful in 38s
tests / confined (shellcheck) (pull_request_target) Successful in 59s
tests / confined (remote) (pull_request_target) Successful in 4m3s
tests / confined (static) (pull_request_target) Successful in 5m28s
tests / confined (config) (pull_request_target) Successful in 9m1s
tests / confined (providers) (pull_request_target) Successful in 2m48s
tests / privileged (pull_request_target) Successful in 3m54s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
tests / confined (project) (pull_request_target) Successful in 5m50s
tests / confined (audit) (pull_request_target) Successful in 9m49s
Documentation review, in the release pass. The GitHub maturity section
lists two differences from the gitea path under "both filed rather than
fixed" - and both bullets under it describe the fixes, because #214
shipped github-pr and the repo guard and updated the bullets without
updating the sentence above them.

A reader choosing a forge would have taken the lead-in at its word and
skipped what the bullets actually say.
2026-08-25 17:27:35 -07:00
branderson e3eb54b97a Merge pull request 'Say what the VM layer bounds, not what the field expects of one' (#224) from vm-layer-bounds into development
Reviewed-on: #224
Reviewed-by: branderson <brad@branderson.io>
2026-08-25 16:40:59 -07:00
branderson 0a671a5faa Merge pull request 'Do not read an unusable ip6tables as IPv6 being absent' (#223) from ipv6-no-inference into development
Reviewed-on: #223
Reviewed-by: branderson <brad@branderson.io>
2026-08-25 16:35:26 -07:00
branderson 9998fe2679 Say what the VM layer bounds, not what the field expects of one
tests / confined (proxy) (pull_request_target) Successful in 1m12s
tests / confined (conversation) (pull_request_target) Successful in 1m25s
tests / runner-env (confined) (pull_request_target) Successful in 37s
tests / confined (shellcheck) (pull_request_target) Successful in 55s
tests / confined (providers) (pull_request_target) Successful in 2m45s
tests / privileged (pull_request_target) Successful in 3m45s
tests / confined (remote) (pull_request_target) Successful in 3m57s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
tests / confined (static) (pull_request_target) Successful in 5m21s
tests / confined (project) (pull_request_target) Successful in 5m39s
tests / confined (config) (pull_request_target) Successful in 8m57s
tests / confined (audit) (pull_request_target) Successful in 9m38s
The devbox-hardener review pushed back on the wording this branch
introduced, and it was right to.

"It is the layer that other approaches reach for as the primary
boundary, and in this architecture it is the second one" invites a
reader to expect containment that project has never claimed. Its threat
model says in three places that its firewall bounds where a compromise
can SPREAD, not whether it can phone home - an escape reopens internet
egress, deliberately, because deny-by-default earns its keep where the
protected set is enumerable and the internet is not.

So the section now says what the row buys: LAN reach cut to what is
declared, enforced from outside the guest so root in the VM cannot flush
it, and no restoration of the egress control the container layer
provides. Load-bearing, and load-bearing for a specific thing.

Writing it the other way would have made a promise on another
repository's behalf that they would then have had to walk back, which is
a failure mode worth naming in a project made of three repositories.

Which is also the eighth standing question, added here because the same
review produced the worked example: dejima blocks IPv6 outright and says
why, dejima-ci-hardener makes the same argument for its job network, and
both sibling projects' node-side firewalls were IPv4-only - written
later, by people who had already made that argument twice. Every
project's tests passed, because each tests its own layer against its own
template. Nothing was looking at the seams.
2026-08-25 16:24:53 -07:00
branderson 3b14a4ad17 Merge pull request 'Give the container a capability floor and no path back up' (#222) from capability-floor into development
Reviewed-on: #222
Reviewed-by: branderson <brad@branderson.io>
2026-08-25 16:22:49 -07:00
branderson 7b76de039c Do not read an unusable ip6tables as IPv6 being absent
tests / confined (proxy) (pull_request_target) Successful in 1m13s
tests / confined (conversation) (pull_request_target) Successful in 1m26s
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / confined (shellcheck) (pull_request_target) Successful in 55s
tests / confined (providers) (pull_request_target) Successful in 2m40s
tests / confined (remote) (pull_request_target) Successful in 3m58s
tests / privileged (pull_request_target) Successful in 4m4s
tests / runner-env (privileged) (pull_request_target) Successful in 40s
tests / confined (static) (pull_request_target) Successful in 5m20s
tests / confined (project) (pull_request_target) Successful in 5m40s
tests / confined (config) (pull_request_target) Successful in 8m59s
tests / confined (audit) (pull_request_target) Successful in 9m42s
Raised by the dejima-devbox-hardener review, which had just found the
same shape twice on its own side: an audit reporting PASS from a probe
that only looked at IPv4.

The branch logged that IPv6 was presumably off at the kernel level and
continued. That inferred a fact about the world from a fact about a
tool, and they are not the same condition. A container whose kernel
lacks ip6_tables, on a host with IPv6 enabled, took that branch, printed
a reassuring line, and ran with IPv6 live and unfiltered - and nothing
failed, because all that had happened was a check that could not tell.

It asks the kernel now. /proc/net/if_inet6 is the namespace's own view
of every IPv6 address on every interface. Loopback does not count:
refusing over ::1 would refuse every container that has no IPv6
connectivity at all, which is a false refusal for the common case.

If the family is live it is turned off in this namespace - written to
/proc directly rather than through sysctl(8), which is one more thing
that would have to be in the image for the firewall to hold - and if it
is still up after that, the launch is refused. Every rule below would be
bypassable by an AAAA-resolved address, and refusing beats reporting a
boundary that is not there.

Not a live hole in the tested configuration: the runtime suite asserts
the IPv6 OUTPUT policy is DROP, and it passes, so ip6tables works as
root in CI and the good path is the one taken. What this fixes is the
host that takes the other branch, where nothing failed and nothing
would.

A second runtime test asserts the container is on the good path rather
than on the fallback, because from the outside the two are
indistinguishable - a container with no IPv6 at all also reaches nothing
over IPv6.
2026-08-25 15:38:19 -07:00
branderson 7070ca6f67 Add KILL to the floor: dejima-allow signals dnsmasq from outside
tests / confined (proxy) (pull_request_target) Successful in 1m14s
tests / confined (conversation) (pull_request_target) Successful in 1m27s
tests / runner-env (confined) (pull_request_target) Successful in 38s
tests / confined (shellcheck) (pull_request_target) Successful in 56s
tests / confined (providers) (pull_request_target) Successful in 2m43s
tests / privileged (pull_request_target) Successful in 3m48s
tests / confined (remote) (pull_request_target) Successful in 3m59s
tests / runner-env (privileged) (pull_request_target) Successful in 40s
tests / confined (static) (pull_request_target) Successful in 5m23s
tests / confined (project) (pull_request_target) Successful in 5m39s
tests / confined (config) (pull_request_target) Successful in 9m6s
tests / confined (audit) (pull_request_target) Successful in 9m47s
The privileged run failed on two ipset tests, both on `kill -HUP` of the
dnsmasq pid returning "Operation not permitted". dnsmasq drops to its
own user (`user=dnsmasq`), so root in the container cannot signal it
without CAP_KILL.

Those two tests caught it by accident - they are about ipset syncing and
happen to reload dnsmasq to do it. What they were actually failing is a
production path: `dejima-allow --container` adds a domain to a RUNNING
session by appending a server= line and SIGHUPing dnsmasq over docker
exec, which is a documented feature every session briefing tells the
agent to ask for. Without KILL it fails with that error and the domain
never resolves.

So the capability is added, and a test named for the thing rather than
for what happened to exercise it: root can signal dnsmasq. The next
person pruning this list will see why the line is there.

The reason it was missing is worth recording, because the next
capability will be the same shape. The list was derived by reading
entrypoint.sh for privileged operations, and KILL is needed by nothing
entrypoint.sh does. What needs it reaches into the container later, from
the host. What a container needs is not only what its own entrypoint
does.

FSETID added to the absent list at the same time, since the run
demonstrates nothing here uses it.
2026-08-25 15:29:22 -07:00
branderson 49205f50d4 Merge pull request 'Say what dejima does not sandbox, and which deployment its table describes' (#220) from what-is-not-sandboxed into development
Reviewed-on: #220
Reviewed-by: branderson <brad@branderson.io>
2026-08-25 15:24:50 -07:00
branderson 43973f3c2f Review: name the findings, correct the timeline, keep sweeps out of the repo
tests / confined (proxy) (pull_request_target) Successful in 1m15s
tests / confined (conversation) (pull_request_target) Successful in 1m28s
tests / runner-env (confined) (pull_request_target) Successful in 37s
tests / confined (shellcheck) (pull_request_target) Successful in 56s
tests / confined (providers) (pull_request_target) Successful in 2m46s
tests / privileged (pull_request_target) Successful in 3m46s
tests / confined (remote) (pull_request_target) Successful in 4m1s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
tests / confined (static) (pull_request_target) Successful in 5m23s
tests / confined (project) (pull_request_target) Successful in 5m46s
tests / confined (config) (pull_request_target) Successful in 9m3s
tests / confined (audit) (pull_request_target) Successful in 9m41s
Three points from review, all on docs/research-review.md.

A sweep is not committed here. The page read as though reviews belong
in the repository, and that is not the practice: reviews, specs and
plans are ephemeral and live on the operator's machine until action is
taken. What belongs here is the action - an issue, a pull request, a
paragraph in the documentation section the finding bears on. The page
now says so, and points at itself as the example: the August sweep
produced a matcher fix, a not-sandboxed list, a statement of which
deployment the threat model describes, and a stated non-goal, and the
sweep that produced them stayed where it was written.

Held structurally as well as stated, because a practice that is only
described drifts: the test refuses a findings/, sweeps/, research/ or
reviews/ directory appearing in the repository.

"Its whole life" was misleading. dejima began in late July 2026, so the
period without an outside check was about a month, not the years that
phrasing suggests. Corrected, and the paragraph now says a month is not
long - the argument for a schedule is that finding out was cheap, not
that something was neglected.

And the two findings are named rather than gestured at. A
parser-differential bypass disclosed against another vendor's agent
network sandbox - a null byte in a SOCKS5 hostname, so the policy check
matched a suffix the resolver never saw - which dejima's matcher refused
along with every neighbouring shape, for reasons now recorded beside it.
And Trail of Bits' devcontainer listing network under "not sandboxed",
which is a difference in scope rather than in rigour. Vague made both
sound worse than they were.
2026-08-25 15:12:17 -07:00
branderson 246921ace2 Merge pull request 'Stop a tool's environment reaching the whole container' (#221) from tool-env-namespace into development
Reviewed-on: #221
Reviewed-by: branderson <brad@branderson.io>
2026-08-25 15:07:55 -07:00
branderson 3bc9143918 Give the container a capability floor and no path back up
tests / confined (proxy) (pull_request_target) Successful in 1m14s
tests / confined (conversation) (pull_request_target) Successful in 1m26s
tests / runner-env (confined) (pull_request_target) Successful in 37s
tests / confined (shellcheck) (pull_request_target) Successful in 56s
tests / confined (providers) (pull_request_target) Successful in 2m46s
tests / confined (remote) (pull_request_target) Successful in 4m3s
tests / confined (static) (pull_request_target) Successful in 5m24s
tests / privileged (pull_request_target) Failing after 3m57s
tests / confined (project) (pull_request_target) Successful in 5m46s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (config) (pull_request_target) Successful in 9m1s
tests / confined (audit) (pull_request_target) Successful in 9m46s
The hardened-container tier the field describes means seccomp, AppArmor
and capability dropping. dejima had Docker's defaults plus two added
capabilities and no cap_drop, which is thinner than the tier name
implies - though better than the compose file read, since the session
user's capabilities were already cleared on the gosu drop and
runtime.bats already asserted that by reading CapEff.

Two changes, both about the ROOT phase rather than the session.

cap_drop: ALL with an explicit list. The entrypoint uses six
capabilities - it writes and chowns files, runs the firewall, and drops
privilege twice (gosu for the session, `ulogd -u` for the log daemon).
Docker's default set carries about fourteen. MKNOD, SETFCAP, SETPCAP,
SYS_CHROOT, AUDIT_WRITE and NET_BIND_SERVICE are not used by anything
here, the last because the sysctl beside it is what lets the TLS
terminator bind :443 unprivileged. What this adds over the gosu drop is
that those were never in the bounding set to be regained.

no-new-privileges. gosu drops privilege rather than gaining it, so this
does not interfere with the session starting; what it forbids is a
setuid binary or a file capability raising privileges on exec, which is
the shape any escalation from the session user would have to take. It
makes the gosu claim structural instead of only observed.

Neither can be verified from inside a sandbox, so both get runtime
tests: NoNewPrivs on PID 1 and on the session user's shell, and the
bounding set decoded by name and checked in both directions - every
declared capability present, and six that nothing declares absent.

The runtime suite's session container now derives its capability list
from the compose file instead of passing --cap-add by hand, because a
second copy would drift and the container under test would stop being
the container a session gets. The ad-hoc containers elsewhere in that
suite keep their hand-written flags: they exercise the firewall as a
component and are not standing in for a session.

If the six are wrong, the container does not come up and the whole
privileged suite fails. That is the intended failure mode for a change
that cannot be checked where it was written.
2026-08-25 14:51:31 -07:00
branderson 71593180ad Stop a tool's environment reaching the whole container
tests / confined (proxy) (pull_request_target) Successful in 1m12s
tests / confined (conversation) (pull_request_target) Successful in 1m25s
tests / runner-env (confined) (pull_request_target) Successful in 38s
tests / confined (shellcheck) (pull_request_target) Successful in 59s
tests / confined (providers) (pull_request_target) Successful in 2m46s
tests / privileged (pull_request_target) Successful in 3m45s
tests / confined (remote) (pull_request_target) Successful in 3m59s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
tests / confined (static) (pull_request_target) Successful in 5m21s
tests / confined (project) (pull_request_target) Successful in 5m40s
tests / confined (config) (pull_request_target) Successful in 9m8s
tests / confined (audit) (pull_request_target) Successful in 9m53s
`dejima -i rtk` started a session with rtk available and no colour in
anything: not the harness, not git, not ls.

rtk's manifest set NO_COLOR=1, reasoning that its output lands in a
model's context where an escape sequence is noise. The reasoning was
fine and the scope was wrong. A tool's image_env becomes a Dockerfile
ENV, which every process in the container inherits - so the switch
meant for one program on PATH reached everything.

Removed from rtk, and made unrepeatable rather than only fixed. A
tool's image_env names must now be namespaced to the tool: `rtk` claims
`RTK_*`, and a launch refuses anything else with a message that says
why. A harness manifest may still use a bare name there - DO_NOT_TRACK
and friends - because a harness IS the container's main process. A tool
is one program among many, and the distinction is the whole of the
rule.

Two tests: the rule, against a fixture declaring NO_COLOR, DO_NOT_TRACK,
PATH and a name that merely starts like the tool's; and the specific
regression, refusing the colour variables by name across every shipped
tool. The second one exists because the rule is the general answer and
this was the concrete symptom, and a test named after the symptom is
the one that will be read when it happens again.
2026-08-25 14:28:48 -07:00
branderson f55cc70e62 Say what dejima does not sandbox, and which deployment its table describes
tests / confined (proxy) (pull_request_target) Successful in 1m13s
tests / confined (conversation) (pull_request_target) Successful in 1m25s
tests / runner-env (confined) (pull_request_target) Successful in 38s
tests / confined (shellcheck) (pull_request_target) Successful in 57s
tests / confined (providers) (pull_request_target) Successful in 2m47s
tests / privileged (pull_request_target) Successful in 3m47s
tests / confined (remote) (pull_request_target) Successful in 4m2s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
tests / confined (static) (pull_request_target) Successful in 5m28s
tests / confined (project) (pull_request_target) Successful in 5m50s
tests / confined (config) (pull_request_target) Successful in 9m14s
tests / confined (audit) (pull_request_target) Successful in 9m59s
Out of a prior-art sweep of the agentic sandbox field. Four things it
found that this repository could act on without changing a control.

A "what dejima does not sandbox" list, at the top of docs/security.md.
Every item was already documented somewhere in it; a document organised
by control makes a reader assemble the negative space themselves, and
most will not. Exfiltration through an allowed domain, DNS query names,
anything below the container where nothing is arranged there, anything
finer than a session, resource exhaustion, and the fact that the first
layer's controls are advice. The most useful paragraph in the closest
comparable project's README is exactly this list, which is where the
idea came from.

The absence of timeouts, written where the absence is. Every published
checklist for this lists them and dejima has none, so without a stated
reason it reads as something not yet reached. The reason: a runaway
session costs money and does not cross the boundary - it burns tokens
and holds a CPU, and every guarantee dejima makes is still held while it
happens. What dejima protects is the boundary, not the bill. A test
holds the claim true by refusing a resource limit in the compose file
while the document says there is none.

Which deployment the blast-radius table describes. It describes dejima
on a devbox VM with the sister projects around it. The README also
invites running on a laptop, where the row below the container is the
operator's own machine. The field's own ordering puts a hardened
container below gVisor and below a per-workload VM kernel for untrusted
code; dejima's answer is that escape moves a layer rather than ending
the argument, which is a better answer where the next layer exists and
no answer where it does not. So dejima-devbox-hardener is load-bearing
rather than optional, and the threat model now says so.

And valid_domain accepted an empty label: `a..b.com` matched, because
the repeated group could consume a dot having matched nothing before it.
Harmless - nothing that shape resolves - and fixed on the principle
rather than on a known abuse, because the published bypass in this space
came from an egress control accepting a string its resolver read
differently. Rewritten label by label, which also refuses a leading or
trailing hyphen. Every domain this repository ships still validates.

The hostile-hostname cases now have a test, which they did not before:
null bytes, percent-encoding, trailing and leading dots, embedded
newlines, underscores, empty labels. All were already refused except the
one fixed here - which is the answer worth having recorded, since "the
matcher refused what I could think of" is a weaker claim than it reads
as and the difference disappears within a week.

docs/research-review.md carries the process, so the next sweep has an
agenda rather than a starting point, and a queue of bypasses that have
been read about and not yet run against this implementation.
2026-08-25 14:05:09 -07:00
branderson 37483236fb Merge pull request 'Let a launch add an optional tool, starting with rtk' (#219) from optional-tools into development
Reviewed-on: #219
Reviewed-by: branderson <brad@branderson.io>
2026-08-25 03:24:45 -07:00
branderson a9dafeca41 Let a launch add an optional tool, starting with rtk
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 57s
tests / confined (providers) (pull_request_target) Successful in 2m42s
tests / privileged (pull_request_target) Successful in 3m56s
tests / confined (remote) (pull_request_target) Successful in 4m0s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
tests / confined (static) (pull_request_target) Successful in 5m21s
tests / confined (project) (pull_request_target) Successful in 5m41s
tests / confined (config) (pull_request_target) Successful in 9m3s
tests / confined (audit) (pull_request_target) Successful in 9m46s
Not a dependency. A dependency is something a project says it needs and
an operator agrees to, and nobody claims to have read it. A tool is
something dejima ships, and the bar for adding one is that dejima has
hardened it: pinned by digest, its network behaviour established rather
than assumed, its telemetry off at its own switch as well as at the
firewall, wired up by dejima rather than by its own installer, and the
guards demonstrably still holding with it enabled. docs/tools.md states
that as a rule for every tool, not as a note about this one.

Three ways to ask, and they union rather than override: [core] tools on
this machine, .dejima/tools in a project (declared, then approved, and
read from the store rather than the workspace), and --include/-i per
launch. None is enabled by default.

A tool's manifest is deliberately small. There is no key for a hook, a
mount, a domain or a guard, because a tool needing one of those would be
a change to the boundary rather than a program on PATH - and adding the
key is the moment to argue about it, rather than the moment a manifest
quietly starts carrying one. What it does declare is an instructions
fragment, which core renders into the briefing under its own heading, so
a tool describes itself once and every harness gets the same words.

rtk compresses command output before it reaches the model. It makes no
model calls of its own. Three things came out of checking it:

Its telemetry is not mentioned in its README. An https ping is compiled
into the release binary - read out of the shipped artifact's strings,
not only out of the source - gated on a consent flag that defaults to
unset. Off three ways here.

It resolves wrapped commands through PATH. Measured with a stub git
earlier on PATH, which the wrapped command ran. So the git and forge
shadows still apply to anything run through it, which is the property
that matters most.

Its Claude Code integration installs a PreToolUse hook that rewrites
commands, in the same slot dejima's guards occupy. That hook is NOT
installed. The session is told the tool exists instead - which is what
rtk's own Cline integration does - and docs/tools.md says plainly that
this is weaker, and what measurement would change it.

The tarball emitter grew a layout parameter. rtk's release is a bare
executable with no enclosing directory, and the existing
--strip-components=1 would strip the binary and extract nothing: a layer
that builds cleanly and contains no tool. A parameter rather than a
second emitter, because the digest-before-unpack check is the part that
must not exist twice.
2026-08-25 01:25:19 -07:00
branderson f371c4d173 Merge pull request 'Give a person's credentials a name, so a project can ask for them' (#218) from config-identities into development
Reviewed-on: #218
Reviewed-by: branderson <brad@branderson.io>
2026-08-25 01:19:08 -07:00
branderson 4c7967fbc9 Give a person's credentials a name, so a project can ask for them
tests / confined (proxy) (pull_request_target) Successful in 1m12s
tests / confined (conversation) (pull_request_target) Successful in 1m26s
tests / runner-env (confined) (pull_request_target) Successful in 37s
tests / confined (shellcheck) (pull_request_target) Successful in 57s
tests / confined (providers) (pull_request_target) Successful in 2m35s
tests / privileged (pull_request_target) Successful in 3m57s
tests / confined (remote) (pull_request_target) Successful in 3m56s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
tests / confined (static) (pull_request_target) Successful in 5m15s
tests / confined (project) (pull_request_target) Successful in 5m33s
tests / confined (config) (pull_request_target) Successful in 8m58s
tests / confined (audit) (pull_request_target) Successful in 9m49s
Steps one and two of the per-project config work. A project's .dejima/
is committed and shared with everyone working on it; an ssh key path and
a token path are yours and your machine's. So a project cannot carry
them, and the answer is an indirection: the project names an account,
and you say where that account's key lives.

An [identity.<name>] section groups what belongs to a PERSON - the ssh
key, the forge token, the name on the commits. Select one with [core]
identity, or per launch with --identity.

Which forge keys are a person's is declared by the FORGE, in
identity_keys. One declaration, two rules: a key named there is one an
identity may set, and (when the project layer lands) one a project may
not declare. Core keeps no list of its own, so a forge whose credential
is spelled differently needs no change here, and the identity inherits
that forge's type rather than a second copy that could disagree.

Resolution is one insertion point. bin/dejima already wraps config_get
for flag overrides; the identity sits below a flag and above the file,
so every consumer reaches it - including forge_env_exports in the
library, which is what actually puts the token in the container.

An identity overrides what it names and nothing else. A section that
sets ssh_key and no token falls through to the file for the token,
because a half-filled identity silently unsetting the rest would be the
worse reading.

An identity that names nothing is refused rather than ignored. Pushing
under the wrong key fails loudly; pushing under your default key when
you asked for another one does not fail at all, and that asymmetry is
the whole reason for the check. It runs before --print-config prints as
well as before a launch, since every value there resolves through the
identity and printing your own credentials under someone else's name is
the same confusion in the one command meant to answer what is in effect.

Harness selection is deliberately not part of this and will not be part
of the project layer either: a harness is the operator's decision, the
forge is the project's.
2026-08-25 00:05:39 -07:00
branderson 00f1497cec Merge pull request 'Name the containers by project, and stop telling anyone to run bin/' (#217) from container-tool-ux into development
Reviewed-on: #217
Reviewed-by: branderson <brad@branderson.io>
2026-08-24 23:12:14 -07:00
branderson 423f7103c8 Name the containers by project, and stop telling anyone to run bin/
tests / confined (proxy) (pull_request_target) Successful in 1m11s
tests / confined (conversation) (pull_request_target) Successful in 1m24s
tests / runner-env (confined) (pull_request_target) Successful in 39s
tests / confined (shellcheck) (pull_request_target) Successful in 58s
tests / confined (providers) (pull_request_target) Successful in 2m19s
tests / confined (remote) (pull_request_target) Successful in 3m41s
tests / confined (static) (pull_request_target) Successful in 5m6s
tests / confined (project) (pull_request_target) Successful in 5m13s
tests / confined (config) (pull_request_target) Successful in 7m2s
tests / privileged (pull_request_target) Successful in 9m39s
tests / confined (audit) (pull_request_target) Successful in 9m47s
tests / runner-env (privileged) (pull_request_target) Successful in 42s
Two things that made the container-level tools harder to use than they
needed to be.

`dejima-allow` with several sandboxes running listed their names and
stopped. A name is a run id, which is exactly what someone with three
sessions open does not know; what they do know is which project each one
is working on. The project comes from the container's own working
directory rather than from the session registry, so it is there whether
or not remote access is on, and the number the dejima-attach picker gives
each session is shown alongside where that feature is enabled. Absent, it
is a column missing rather than a failure.

The picker's numbering is taken from `dejima-attach --list` rather than
recomputed, because two lists numbering the same sessions differently
would be worse than one list with no numbers.

And three places told someone to run `bin/dejima-allow`. install.sh links
these into ~/.local/bin, so they are on PATH by name; an operator reading
that is generally not standing in the dejima checkout, so the prefix does
not merely look odd - the command fails, and it fails looking like the
tool is broken. One of the three was in the session briefing, one line
below a `dejima-project approve` with no prefix at all.

A test derives the tool names from install.sh's own link_bin lines and
refuses the prefix in anything shown to a session or an operator, so the
next one cannot be added quietly.
2026-08-24 22:59:45 -07:00
branderson dc9d311b32 Merge pull request 'Block merges on every workflow whose briefing says not to merge' (#216) from branch-push-merge-block into development
Reviewed-on: #216
Reviewed-by: branderson <brad@branderson.io>
2026-08-24 22:57:37 -07:00
branderson c7cb7e1e15 Merge pull request 'Give the per-launch flags the settings that are per-launch decisions' (#215) from per-launch-flags into development
Reviewed-on: #215
Reviewed-by: branderson <brad@branderson.io>
2026-08-24 22:57:06 -07:00
branderson f2c1d79341 Merge pull request 'Bring the github forge to parity with the gitea one' (#214) from github-forge-parity into development
Reviewed-on: #214
Reviewed-by: branderson <brad@branderson.io>
2026-08-24 22:55:27 -07:00
branderson 74a1c8c0d3 Merge pull request 'Give epitaph a way to read what it keeps' (#129) from epitaph-interface into development
Reviewed-on: #129
Reviewed-by: branderson <brad@branderson.io>
2026-08-24 22:54:59 -07:00
branderson 98ca03e3ee Block merges on every workflow whose briefing says not to merge
tests / confined (proxy) (pull_request_target) Successful in 1m15s
tests / confined (conversation) (pull_request_target) Successful in 1m54s
tests / confined (shellcheck) (pull_request_target) Successful in 55s
tests / confined (providers) (pull_request_target) Successful in 2m20s
tests / runner-env (confined) (pull_request_target) Successful in 37s
tests / privileged (pull_request_target) Successful in 3m41s
tests / confined (remote) (pull_request_target) Successful in 3m48s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
tests / confined (static) (pull_request_target) Successful in 5m6s
tests / confined (project) (pull_request_target) Successful in 5m19s
tests / confined (config) (pull_request_target) Successful in 7m3s
tests / confined (audit) (pull_request_target) Successful in 9m47s
branch-push and local-only both tell the agent not to merge and both had
block_merge = off, so the sentence was an assertion nothing held. Only
worktree-pr armed the guard.

branch-push carried a reason: "nothing here ever reaches a forge's PR
interface, so there is no merge command to block". The premise is false.
What the briefing tells the agent to do and what the image puts on its
PATH are different things - the forge CLI is installed by the FORGE, so
`gitea-pr merge` is present whenever a forge is selected, whatever the
workflow says. Both workflows are legal alongside a forge, which is what
made it reachable rather than theoretical.

Turning it on costs nothing where there is no forge: block_merge resolves
the forge's own merge_block, and a forge that names no command still
blocks nothing.

The two tests that pinned "off" asserted it as a bare value under titles
that claim nothing about merging - the shape that lets a wrong value sit
still. Replaced with an invariant over every shipped workflow, so a
fourth cannot get it wrong the same way, plus one holding the pairing
between a workflow's flag and the forge command it resolves.

Both briefings now name the guard, the way worktree-pr's already did.
Being told "nothing stops you from doing otherwise" is no longer true of
merging, and an agent that knows a rule is enforced treats it as the
workflow rather than as something to route around.
2026-08-24 22:16:20 -07:00
branderson 0bd28d6b38 Give the per-launch flags the settings that are per-launch decisions
tests / confined (proxy) (pull_request_target) Successful in 1m14s
tests / confined (conversation) (pull_request_target) Successful in 1m56s
tests / confined (shellcheck) (pull_request_target) Successful in 53s
tests / confined (providers) (pull_request_target) Successful in 2m17s
tests / runner-env (confined) (pull_request_target) Successful in 38s
tests / privileged (pull_request_target) Successful in 3m45s
tests / confined (remote) (pull_request_target) Successful in 3m44s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (static) (pull_request_target) Successful in 5m4s
tests / confined (project) (pull_request_target) Successful in 5m14s
tests / confined (config) (pull_request_target) Successful in 7m25s
tests / confined (audit) (pull_request_target) Successful in 9m42s
The split between what a flag can set and what only the config file can
was never argued for: [core] firewall has a flag and [credentials]
transport does not, and nothing anywhere says why. This does not settle
the whole question, but it moves the settings that are decisions about
one session rather than policy for a machine.

--model, --base-url and --context-window set the SELECTED harness's keys.
Resolved after the whole command line is read, because --harness may come
after --model and binding the flag to whatever the config said would be
wrong. A harness with no such key is refused by name: a --model that
silently did nothing because the harness has none reads as having chosen
one.

--strict-audit, --no-epitaph, --no-conversation and --no-log narrow and
only narrow. There is no flag that turns any of them back on, because a
config that says off is a decision and a flag that could undo it in
passing would make the file's answer the weaker one. --no-log clears the
log endpoint, which silences everything downstream of it, and implies
--no-conversation rather than leaving that key reading as still in force.

The two host-side tools re-read the config for themselves, so an override
held in the launcher's memory does not reach them. They are gated at the
call site instead of being handed an environment: both are also run by
hand, where an environment this launcher happened to have would be the
wrong authority. The one exception is DEJIMA_EPITAPH_NO_SHIP, which
carries the part of --no-log the launcher cannot decide for the epitaph
tool. It is safe to honour from the environment only because it can
suppress and never enable.

Three things found while doing it and fixed here:

--harness was implemented, documented in docs/configuration.md and
covered by three tests, and missing from --help. The help page is the
discovery surface.

--help claimed --print-config reports which flag overrode which key. It
does not, and a test pins its output to one line per key and nothing
else, so the claim is now what the command actually does.

Four schema keys were absent from the reference table: [audit] diffs and
three [remote-access] ttyd_* keys, documented elsewhere or nowhere. A
test now holds every schema key and every provider key to appearing
there, so the next one cannot go missing quietly.

The help budget rises from 30 lines to 36 for bin/dejima alone. One
constant across every tool stopped measuring whether a page had been
allowed to sprawl and started measuring which tool has the most flags -
this one has eighteen options where the next has five. The exception is
a named tool, not a licence.
2026-08-24 22:01:49 -07:00
branderson eb16b39bf5 Bring the github forge to parity with the gitea one
tests / confined (proxy) (pull_request_target) Successful in 1m15s
tests / confined (conversation) (pull_request_target) Successful in 1m26s
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / confined (shellcheck) (pull_request_target) Successful in 53s
tests / confined (providers) (pull_request_target) Successful in 2m23s
tests / confined (static) (pull_request_target) Successful in 5m9s
tests / confined (config) (pull_request_target) Successful in 7m9s
tests / confined (remote) (pull_request_target) Successful in 3m49s
tests / privileged (pull_request_target) Successful in 3m57s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (project) (pull_request_target) Successful in 5m20s
tests / confined (audit) (pull_request_target) Successful in 9m52s
Two gaps from the parity audit, both left open on the judgement that they
might not be dejima's to close. They are, and for the same reason: the
gitea CLIs are dejima's own, so their behaviour is a decision someone
made, while the GitHub path inherits whatever gh happens to do. Where
that difference costs the operator something, the manifest is where to
say so.

github-pr comments (#186). gh pr view --comments prints reviews and
comments with no resolution state anywhere in the output, because the
REST API it uses does not carry any - isResolved exists only on
reviewThreads in GraphQL, and no gh pr subcommand reaches it. So working
through PR feedback there re-answers threads closed a week ago with
nothing saying so. This does that one query and otherwise matches
gitea-pr comments, down to the output layout. It reads and never writes,
so there is no authorship disclosure to apply; a static test holds it to
that.

A command aimed at another repository (#187). gh takes --repo on nearly
every subcommand and reads GH_REPO, and this forge supplies a
write-capable token, so filing an issue on an unrelated repository is the
same length of command as filing one here. The forge declares the
spellings; forge-guard.sh refuses a value that is not the repository the
session was launched against. Fast-fail, not a boundary, exactly as
merge_block is not one - what bounds this is the token's scope, which the
docs now state as the expected shape of push_token_file rather than
leaving it a remark in a known-gaps list.

A launch that resolved no repository refuses rather than waving it
through: a workspace with no origin has nothing --repo could
legitimately name. The comparison is case-insensitive, because forges
preserve the case of a name without distinguishing it and a refusal over
capitalisation would be a false one.

Core stays forge-agnostic: it resolves origin into <owner>/<name> and
passes the declarations through, and the guard compares. A forge
declaring neither key gets no check, which is the gitea case and is not
a gap - those CLIs' own refusal is stricter than this one.
2026-08-24 20:45:56 -07:00
branderson 0f440b8817 Ask the harness which sessions still have a transcript
tests / confined (proxy) (pull_request_target) Successful in 1m14s
tests / confined (conversation) (pull_request_target) Successful in 1m26s
tests / runner-env (confined) (pull_request_target) Successful in 37s
tests / confined (shellcheck) (pull_request_target) Successful in 55s
tests / confined (providers) (pull_request_target) Successful in 2m14s
tests / confined (remote) (pull_request_target) Successful in 3m31s
tests / privileged (pull_request_target) Successful in 3m36s
tests / runner-env (privileged) (pull_request_target) Successful in 40s
tests / confined (static) (pull_request_target) Successful in 4m56s
tests / confined (project) (pull_request_target) Successful in 4m58s
tests / confined (config) (pull_request_target) Successful in 7m10s
tests / confined (audit) (pull_request_target) Successful in 9m33s
The rebase put `config_get harness.claude-code projects_dir` back into
core, which static.bats refuses on its own terms: a fixed [harness.<name>]
at a call site is a declaration only one harness can be read through, and
a second one is selected, validated, then handed the first one's paths
with nothing erroring. `harness_config_get` cleared that grep and still
left the <dir>/<slug>/<session>.jsonl shape in core, which two more tests
refuse - the decoupling is the point, not the key name.

So `list` asks. harness_live_sessions answers "<key>\t<session-id>" per
transcript on disk: the id is in the filename for Claude Code and is the
directory name for the next harness, and core has no way to know which.
With no workspace it answers for every key it holds, which is what --all
needs, since a store key is a slug and a slug does not resolve back to a
path.

A harness that does not implement it leaves the column unknown rather
than saying "aged out" - not knowing where a transcript would be is not
the same as knowing it is gone.
2026-08-24 19:58:05 -07:00
branderson 135d06345a Give epitaph a way to read what it keeps
tests / confined (proxy) (pull_request_target) Successful in 1m13s
tests / confined (conversation) (pull_request_target) Successful in 1m22s
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / confined (shellcheck) (pull_request_target) Successful in 54s
tests / confined (providers) (pull_request_target) Successful in 2m16s
tests / privileged (pull_request_target) Successful in 3m40s
tests / confined (remote) (pull_request_target) Successful in 3m39s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (static) (pull_request_target) Failing after 4m59s
tests / confined (project) (pull_request_target) Successful in 5m6s
tests / confined (config) (pull_request_target) Successful in 7m4s
tests / confined (audit) (pull_request_target) Successful in 9m17s
`save` was the whole interface. Epitaphs are named for a timestamp and a
uuid, so finding the one that mattered meant a fuzzy finder over a
directory of files whose first three lines are identical boilerplate.

`list` says what is kept and whether each session is still resumable -
where it is, `claude --resume` gives the real session rather than a
summary of it, and the epitaph is what remains after the harness ages
that transcript out. `search` is the finder: case-insensitive regex over
the bodies, reporting each hit's position in the same listing. `show`
opens one in $VISUAL or $EDITOR, and writes to stdout when there is no
terminal to open an editor on.

Deliberately no topic column. Every summary opens on the same
boilerplate, the "Primary Request" section that follows is written four
different ways across the summaries here, and what it opens on is
usually scene-setting rather than the request - a column repeating one
sentence down eight rows reads as a broken tool and leaves
discoverability where it was. The listing answers what is here; search
answers which one it was.

Reading is not gated on `[epitaph] enabled`. Turning the feature off is
the point at which reviewing and clearing the store is most likely.

Nothing here hands an epitaph to a session. A session can write its own
transcript, so it can author what its own epitaph says, and delivering
that to a later session would plant text in its premises as established
background - the channel the never-mounted store closes. A static test
holds bin/dejima to `save` and keeps the context hook away entirely.
2026-08-24 19:37:20 -07:00
branderson 791f3bfcff Merge pull request 'Make Ctrl-Z work: never let the container client see SIGTSTP' (#208) from ctrlz-sigstop into development
Reviewed-on: #208
Reviewed-by: branderson <brad@branderson.io>
2026-08-24 17:25:34 -07:00
branderson bc418366a9 Show the cursor again when a session is suspended
tests / confined (proxy) (pull_request_target) Successful in 1m12s
tests / confined (conversation) (pull_request_target) Successful in 1m25s
tests / runner-env (confined) (pull_request_target) Successful in 37s
tests / confined (shellcheck) (pull_request_target) Successful in 50s
tests / confined (providers) (pull_request_target) Successful in 2m17s
tests / privileged (pull_request_target) Successful in 3m46s
tests / confined (remote) (pull_request_target) Successful in 3m48s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (static) (pull_request_target) Successful in 5m1s
tests / confined (project) (pull_request_target) Successful in 5m9s
tests / confined (config) (pull_request_target) Successful in 7m0s
tests / confined (audit) (pull_request_target) Successful in 9m41s
A full-screen harness hides the cursor on startup and puts it back when
it exits. A suspend is not an exit, so the shell prompt inherited an
invisible cursor and the terminal read as a connection that had died -
typing worked, nothing showed where.

Restored on suspend, and in cleanup for the same reason the terminal
modes are restored there: a run that was killed rather than exited put
neither back. On resume the harness hides it again when the SIGWINCH
poke makes it repaint.
2026-08-24 14:04:30 -07:00
branderson bb3c2a16d6 Stop the whole client subtree, not the one pid $! names
tests / confined (proxy) (pull_request_target) Successful in 1m12s
tests / confined (conversation) (pull_request_target) Successful in 1m24s
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / confined (shellcheck) (pull_request_target) Successful in 55s
tests / confined (providers) (pull_request_target) Successful in 2m20s
tests / privileged (pull_request_target) Successful in 3m37s
tests / confined (remote) (pull_request_target) Successful in 3m41s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (static) (pull_request_target) Successful in 5m1s
tests / confined (project) (pull_request_target) Successful in 5m10s
tests / confined (config) (pull_request_target) Successful in 6m59s
tests / confined (audit) (pull_request_target) Successful in 9m41s
Reported: with the fix in place Ctrl-Z suspends and `fg` returns
"continued dejima" and nothing else - no cursor, no way in, and Ctrl-C
does not kill it either.

`docker compose run` is not one process. `docker` is a CLI that runs the
compose plugin as a child, and the child is what holds the terminal. So
stopping the pid $! names stopped the parent and left the child in raw
mode with the shell also on the terminal, which is exactly a cursorless
prompt that resumes to nothing.

The candidate that was measured used `docker run`, which is one process,
and it worked. dejima launches `compose run`. That difference was in
front of me the whole time - the probe had a row for compose and a row
for the candidate, and never one row for both.

Stopped deepest first so nothing is left running long enough to fork
again, continued shallowest first so a parent is ready before its child.
2026-08-24 11:57:31 -07:00
branderson 336f0e0b3c Make Ctrl-Z work: never let the container client see SIGTSTP
tests / confined (proxy) (pull_request_target) Successful in 1m13s
tests / confined (conversation) (pull_request_target) Successful in 1m25s
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / confined (shellcheck) (pull_request_target) Successful in 51s
tests / confined (providers) (pull_request_target) Successful in 2m20s
tests / privileged (pull_request_target) Successful in 3m45s
tests / confined (remote) (pull_request_target) Successful in 3m51s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
tests / confined (static) (pull_request_target) Successful in 5m9s
tests / confined (project) (pull_request_target) Successful in 5m22s
tests / confined (config) (pull_request_target) Successful in 7m9s
tests / confined (audit) (pull_request_target) Successful in 9m46s
Measured against a real container run, holding everything else equal:
the client survives SIGSTOP and does not survive SIGTSTP. A catchable
stop signal interrupts the read it is blocked in, its input forwarding
never recovers, and the session comes back alive but deaf - resumed,
with nothing typed reaching the harness. That is what the first attempt
shipped, and it is why it was taken out again.

So there are two halves. Reclaiming the suspend character makes the
keystroke a signal at all, which was already established and already
worked. The new half is that the client is started with SIGTSTP ignored
- inherited, and kept across exec - and the launcher converts the
keystroke into an explicit SIGSTOP for it before stopping itself. The
job still stops completely, so the prompt comes back, and the one thing
that cannot survive the signal never receives it.

Two smaller things the measurements settled, both of which had been
guesses: the mode restore and the SIGWINCH poke are not implicated, and
`compose run` behaves exactly as `docker run` does. And < /dev/tty,
because a backgrounded command is handed /dev/null for stdin and a
client with no terminal reads EOF and exits at once.

The fixture asserts the finding rather than the mechanism around it: the
stand-in reads back its own inherited disposition and the test fails if
SIGTSTP is not ignored. It reads it rather than catching it, because
installing a handler would replace the SIG_IGN being checked for.

Two existing tests changed. Both had pinned a spelling rather than an
invariant, and a second compose run broke them: one asserted the run
line ends in `|| compose_status=$?`, which the backgrounded run cannot,
and one counted compose invocations and expected exactly two. They now
check that no run can abort the script and that every invocation names
the service.
2026-08-24 11:04:30 -07:00
branderson d3cc1b7d2e Merge pull request 'Revert the Ctrl-Z suspend feature: resume does not survive a real run' (#205) from ctrlz-revert into development
tests / confined (proxy) (pull_request_target) Successful in 1m15s
tests / confined (conversation) (pull_request_target) Successful in 1m25s
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / confined (shellcheck) (pull_request_target) Successful in 52s
tests / confined (providers) (pull_request_target) Successful in 2m26s
tests / privileged (pull_request_target) Successful in 3m50s
tests / confined (remote) (pull_request_target) Successful in 3m54s
tests / runner-env (privileged) (pull_request_target) Successful in 35s
tests / confined (static) (pull_request_target) Successful in 4m38s
tests / confined (project) (pull_request_target) Successful in 5m16s
tests / confined (config) (pull_request_target) Successful in 7m3s
tests / confined (audit) (pull_request_target) Successful in 9m51s
Reviewed-on: #205
Reviewed-by: branderson <brad@branderson.io>
2026-08-24 03:31:03 -07:00
branderson a0d6eb69ff Remove the Ctrl-Z suspend feature: resume does not survive a real run
tests / confined (proxy) (pull_request_target) Successful in 1m11s
tests / confined (conversation) (pull_request_target) Successful in 1m24s
tests / runner-env (confined) (pull_request_target) Successful in 35s
tests / confined (shellcheck) (pull_request_target) Successful in 54s
tests / confined (providers) (pull_request_target) Successful in 2m17s
tests / confined (remote) (pull_request_target) Successful in 3m45s
tests / privileged (pull_request_target) Successful in 3m44s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (static) (pull_request_target) Successful in 4m35s
tests / confined (project) (pull_request_target) Successful in 5m15s
tests / confined (config) (pull_request_target) Successful in 7m3s
tests / confined (audit) (pull_request_target) Successful in 9m39s
Reported from a real session: Ctrl-Z backgrounds the launch correctly,
and `fg` prints "continued dejima" and then hangs - no input reaches the
harness, no output comes back, and no way to interrupt it from that
terminal.

So the feature traded one stranding for another. Before it, Ctrl-Z
reached the container as a keystroke and the harness suspended itself
with nothing able to continue it. After it, the host group suspends
correctly and the container run does not come back. Both leave a session
that has to be killed; the difference is that this one claimed to work.

The tests could not see it and were never going to. They drive a
stand-in for `docker compose run` - a script that raws the terminal and
proxies stdin - and it resumes cleanly because it has no daemon
connection to lose. Every host-side assertion holds. What none of them
touches is the client, which is where this breaks.

Removed rather than left behind a flag: a knob known not to work is worse
than the absence of one. #179 is reopened with what this established,
which is most of the diagnosis - the byte-versus-signal mechanism was
right, and reclaiming the suspend character host-side does work. What is
missing is a resume the container run survives.
2026-08-24 03:20:58 -07:00
branderson fa4cd3cf76 Merge pull request 'Set the release bar where it can actually be met' (#203) from release-policy into development
tests / confined (proxy) (pull_request_target) Successful in 1m15s
tests / confined (conversation) (pull_request_target) Successful in 1m26s
tests / runner-env (confined) (pull_request_target) Successful in 39s
tests / confined (shellcheck) (pull_request_target) Successful in 56s
tests / confined (providers) (pull_request_target) Successful in 2m23s
tests / confined (remote) (pull_request_target) Successful in 3m54s
tests / privileged (pull_request_target) Successful in 4m3s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (static) (pull_request_target) Successful in 5m12s
tests / confined (project) (pull_request_target) Successful in 5m25s
tests / confined (config) (pull_request_target) Successful in 7m11s
tests / confined (audit) (pull_request_target) Successful in 9m56s
Reviewed-on: #203
Reviewed-by: branderson <brad@branderson.io>
2026-08-24 00:43:33 -07:00
branderson fbe7c3a214 Set the release bar where it can actually be met
tests / confined (proxy) (pull_request_target) Successful in 1m14s
tests / confined (conversation) (pull_request_target) Successful in 1m23s
tests / runner-env (confined) (pull_request_target) Successful in 37s
tests / confined (remote) (pull_request_target) Successful in 3m41s
tests / confined (shellcheck) (pull_request_target) Successful in 50s
tests / confined (providers) (pull_request_target) Successful in 2m17s
tests / privileged (pull_request_target) Successful in 3m42s
tests / runner-env (privileged) (pull_request_target) Successful in 34s
tests / confined (static) (pull_request_target) Successful in 4m42s
tests / confined (project) (pull_request_target) Successful in 4m53s
tests / confined (config) (pull_request_target) Successful in 6m28s
tests / confined (audit) (pull_request_target) Successful in 9m7s
The pass as written demanded a manual matrix one maintainer does not have
the bandwidth for, and a gate nobody can complete is one that gets
skipped rather than met. The bar is now the two things that are real: an
agentic security review of the whole diff, and the maintainer running the
build - normal usage for a while, plus a launch on the other configured
harness to confirm it starts.

The manual list stays, relabelled as what it is: an inventory of what
nothing has checked, printed so an unverified path is one somebody can
reach for when a bug turns up. It is not a checklist a release is blocked
on. That changes when there are more users than one person can apologise
to.

Added because this release has one: the config keys and provider sections
a release introduces, because adopting one makes the previous main refuse
to start - an unknown key and an unknown section both fail validation. The
rollback that looks like the safety net is not one, and it is worth
knowing before rather than after.
2026-08-23 23:13:41 -07:00
branderson 96b3912667 Merge pull request 'Say there is a browser, and make the modern import of it work' (#202) from playwright-discoverable into development
tests / confined (conversation) (pull_request_target) Successful in 1m26s
tests / confined (proxy) (pull_request_target) Successful in 1m11s
tests / confined (providers) (pull_request_target) Successful in 2m13s
tests / confined (shellcheck) (pull_request_target) Successful in 56s
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / privileged (pull_request_target) Successful in 3m42s
tests / runner-env (privileged) (pull_request_target) Successful in 36s
tests / confined (config) (pull_request_target) Successful in 6m33s
tests / confined (project) (pull_request_target) Successful in 4m55s
tests / confined (remote) (pull_request_target) Successful in 3m42s
tests / confined (static) (pull_request_target) Successful in 5m6s
tests / confined (audit) (pull_request_target) Successful in 9m16s
Reviewed-on: #202
Reviewed-by: branderson <brad@branderson.io>
2026-08-23 21:05:53 -07:00
branderson 831b346b70 Say there is a browser, and make the modern import of it work
tests / confined (proxy) (pull_request_target) Successful in 1m12s
tests / confined (conversation) (pull_request_target) Successful in 1m25s
tests / runner-env (confined) (pull_request_target) Successful in 37s
tests / confined (shellcheck) (pull_request_target) Successful in 55s
tests / confined (providers) (pull_request_target) Successful in 2m18s
tests / confined (remote) (pull_request_target) Successful in 3m42s
tests / privileged (pull_request_target) Successful in 3m49s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (static) (pull_request_target) Successful in 5m1s
tests / confined (project) (pull_request_target) Successful in 5m5s
tests / confined (config) (pull_request_target) Successful in 6m56s
tests / confined (audit) (pull_request_target) Successful in 9m31s
Agents keep concluding this image has no browser. It has had one all
along, and the reason the conclusion is reasonable is that the obvious
way to reach it fails: NODE_PATH only ever applied to require(), ESM
resolution ignores it and walks up from the importing file, so
`import { chromium } from "playwright"` returns ERR_MODULE_NOT_FOUND from
every directory a script is likely to be written in. That reads as a
missing package rather than a wrong module system.

A node_modules symlink at the root ends that walk somewhere useful, and
the briefing now says the browser is there and what it is for: look at a
user interface you changed instead of describing what it should look
like.

The runtime suite had a Playwright test the whole time and stayed green,
because it used require(). It now covers both forms, from a directory
with no node_modules of its own so neither can pass for the wrong reason.
2026-08-23 20:28:28 -07:00
branderson 3e5c94743f Merge pull request 'Colour the toolbar by what each button is for' (#201) from webui-button-colors into development
Reviewed-on: #201
Reviewed-by: branderson <brad@branderson.io>
2026-08-23 20:05:03 -07:00
branderson 964d27d385 Merge pull request 'Say which branch a pull request is being opened against' (#200) from gitea-pr-base into development
Reviewed-on: #200
Reviewed-by: branderson <brad@branderson.io>
2026-08-23 20:04:26 -07:00
branderson 5b27628fab Colour the toolbar by what each button is for
tests / confined (proxy) (pull_request_target) Successful in 1m10s
tests / confined (conversation) (pull_request_target) Successful in 1m21s
tests / runner-env (confined) (pull_request_target) Successful in 35s
tests / confined (shellcheck) (pull_request_target) Successful in 53s
tests / confined (providers) (pull_request_target) Successful in 2m10s
tests / privileged (pull_request_target) Successful in 3m32s
tests / confined (remote) (pull_request_target) Successful in 3m36s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (static) (pull_request_target) Successful in 4m47s
tests / confined (project) (pull_request_target) Successful in 5m3s
tests / confined (config) (pull_request_target) Successful in 6m48s
tests / confined (audit) (pull_request_target) Successful in 9m30s
On a phone the toolbar is how the software is used, and every key looked
alike - so the one you need most was found by counting positions. The
palette is gruvbox, the same values bin/dejima-attach paints the session
picker with: the two sit either side of one attach and should not look
like different programs.

The order is a decision about what an operator hunts for. KBD is filled
orange, because without it you cannot type at all. Enter is filled green,
being the key a session asks for most. The window and arrow keys are
outlined blue - reached for constantly, never hunted for. Everything else
is furniture.

Outlined rather than filled for that third tier because bright_blue as a
label on dark1 measures 4.31:1, under what an unenlarged label needs, and
the label is the part that has to survive a phone held outdoors. The
border carries the tier instead and the text stays at 8.45:1.

ttyd_button_colors = off restores every colour the toolbar had before, in
one block rather than an override per rule, because an operator turning
this off is asking for what was there and not for a second scheme to keep
in step.
2026-08-23 19:20:30 -07:00
branderson 5e3e2e0fe9 Cut the story out of the base-branch note
tests / confined (conversation) (pull_request_target) Successful in 1m26s
tests / confined (shellcheck) (pull_request_target) Successful in 52s
tests / runner-env (confined) (pull_request_target) Successful in 35s
tests / runner-env (privileged) (pull_request_target) Successful in 35s
tests / confined (providers) (pull_request_target) Successful in 2m20s
tests / confined (proxy) (pull_request_target) Successful in 1m12s
tests / privileged (pull_request_target) Successful in 3m49s
tests / confined (remote) (pull_request_target) Successful in 3m53s
tests / confined (static) (pull_request_target) Successful in 5m7s
tests / confined (project) (pull_request_target) Successful in 5m21s
tests / confined (config) (pull_request_target) Successful in 7m0s
tests / confined (audit) (pull_request_target) Successful in 9m31s
Review: the point is what the tool prints and what to report. How it came
to be printed is the change's own history, not the reader's.
2026-08-23 17:19:24 -07:00
branderson d5dd7357f3 Merge pull request 'Shellcheck the test scripts too, and fix the unguarded cd it found' (#199) from shellcheck-covers-tests into development
Reviewed-on: #199
Reviewed-by: branderson <brad@branderson.io>
2026-08-23 17:09:46 -07:00
branderson dc0841e1c3 Say which branch a pull request is being opened against
tests / confined (proxy) (pull_request_target) Successful in 1m11s
tests / confined (conversation) (pull_request_target) Successful in 1m23s
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / confined (shellcheck) (pull_request_target) Successful in 50s
tests / confined (providers) (pull_request_target) Successful in 2m13s
tests / confined (remote) (pull_request_target) Successful in 3m33s
tests / privileged (pull_request_target) Successful in 3m40s
tests / runner-env (privileged) (pull_request_target) Successful in 36s
tests / confined (static) (pull_request_target) Successful in 4m48s
tests / confined (project) (pull_request_target) Successful in 4m58s
tests / confined (config) (pull_request_target) Successful in 6m36s
tests / confined (audit) (pull_request_target) Successful in 9m6s
Every pull request this project opened for a fortnight went to the
repository's default branch while being reported as targeting
development, and the only reason anyone found out is that the operator
retargeted each one by hand and eventually asked why. Nothing gitea-pr
printed contradicted the claim: a pull request URL looks identical
whichever branch it is against.

So it says so now, every time, whether the base came from --base, from
configuration, or from having nothing else to go on. A wrong base then
costs one line instead of a fortnight.

And it can be set once per repository - git config gitea.prBase - for a
project whose work does not target its default branch, rather than every
caller remembering a flag. Remembering was already documented here, and
documented as the likely thing to forget, which is what it turned out to
be.
2026-08-23 17:07:25 -07:00
branderson e638a5db23 Shellcheck the test scripts too, and fix what that found
tests / confined (proxy) (pull_request_target) Successful in 1m11s
tests / confined (conversation) (pull_request_target) Successful in 1m24s
tests / runner-env (confined) (pull_request_target) Successful in 35s
tests / confined (shellcheck) (pull_request_target) Successful in 52s
tests / confined (providers) (pull_request_target) Successful in 2m22s
tests / privileged (pull_request_target) Successful in 3m43s
tests / confined (remote) (pull_request_target) Successful in 3m49s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (static) (pull_request_target) Successful in 5m3s
tests / confined (project) (pull_request_target) Successful in 5m23s
tests / confined (config) (pull_request_target) Successful in 7m8s
tests / confined (audit) (pull_request_target) Successful in 9m48s
The gate covered bin/, hooks/, docker/, the forge CLIs and install.sh,
and not test/. That was not a considered scope: .dejima.md's own warning
is that pushing runs this commit on a machine outside the sandbox, so a
shell script under test/ is code executing there - which makes them the
last scripts that should go unchecked rather than the first.

It found one straight away, in the release gate added yesterday: an
unguarded cd into the repo root, with everything after it relative to
that. A cd that failed would have had it reporting on some other
directory's suites and diff, which for the thing that gates a release is
the worst available way to be wrong.

Found the day shellcheck became installable in a sandbox, which is the
other half of this: the gate had been CI-only, so nobody running the
suite locally was running it at all.
2026-08-23 16:48:48 -07:00
branderson c722b44ff6 Merge pull request 'Stop dejima-audit re-asking what it has already dealt with, and let it show you the file' (#198) from audit-retire-reports into development
Reviewed-on: #198
Reviewed-by: branderson <brad@branderson.io>
2026-08-23 15:26:04 -07:00
branderson 992d87e06c Stop ack re-asking what it has already dealt with, and let it show you the file
tests / confined (audit) (pull_request_target) Successful in 10m9s
tests / confined (config) (pull_request_target) Successful in 7m23s
tests / confined (conversation) (pull_request_target) Successful in 1m50s
tests / confined (project) (pull_request_target) Successful in 5m42s
tests / confined (providers) (pull_request_target) Successful in 2m43s
tests / confined (proxy) (pull_request_target) Successful in 1m22s
tests / confined (remote) (pull_request_target) Successful in 4m6s
tests / confined (static) (pull_request_target) Successful in 5m17s
tests / confined (shellcheck) (pull_request_target) Successful in 55s
tests / runner-env (confined) (pull_request_target) Successful in 35s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / privileged (pull_request_target) Successful in 7m56s
Three linked changes to one walk, kept together because they interlock:
the second is only observable once the first stops the run ending early,
and the third changes the same prompt.

#195, reported as five reports waiting and "nothing new - 32 finding(s)
already dealt with", every time, with no keystroke able to change it. A
report walked to the end left no record of it unless it happened to
carry something un-ackable, so selection kept choosing it; selection
consulted neither the baseline nor the reviewed markers, so it could not
have skipped it anyway; and the waiting count counted files rather than
outstanding work, so the number never fell. Reports still are not
deleted - they are the record of what a session did, and rotation
removes them - but finishing one stops it being counted or re-opened,
and a run now moves on to reports it has not walked rather than being
handed back the one it just finished.

The marker is written only when nothing was left undecided. Retiring a
report retires every finding in it, so writing it over a skipped finding
would turn "ask again next time" into "never ask again", silently, for a
finding the operator had looked at and declined to approve.

#197: reports overlap, and once a file's current content has been shown,
an older report asking about the same file is asking about a version
that no longer exists. Approving already covered that through the
baseline; skipping did not. Counted as dealt with rather than dropped,
and it does not let a CRITICAL shed its suffix, because nothing was
approved.

#196: [o] opens the file in $VISUAL/$EDITOR and comes back to the same
decision. The report shows what changed, which is not always what is
needed to judge it. It opens the current file rather than the captured
text, for the reason an approval pins current content: approving
something nobody read is the one outcome worse than not asking.
2026-08-23 05:27:01 -07:00
branderson f987286d01 Merge pull request 'Fixes from a review pass, and the release pass that should have caught them' (#194) from forge-guard-body-checks into development
Reviewed-on: #194
Reviewed-by: branderson <brad@branderson.io>
2026-08-23 04:00:48 -07:00
branderson 37cda6ce5c Clear the same variables in providers.bats
tests / confined (proxy) (pull_request_target) Successful in 1m11s
tests / confined (conversation) (pull_request_target) Successful in 1m22s
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / confined (shellcheck) (pull_request_target) Successful in 49s
tests / confined (providers) (pull_request_target) Successful in 2m14s
tests / confined (remote) (pull_request_target) Successful in 3m36s
tests / privileged (pull_request_target) Successful in 3m46s
tests / runner-env (privileged) (pull_request_target) Successful in 36s
tests / confined (static) (pull_request_target) Successful in 4m50s
tests / confined (project) (pull_request_target) Successful in 5m2s
tests / confined (config) (pull_request_target) Successful in 6m41s
tests / confined (audit) (pull_request_target) Successful in 8m40s
Same leak, second suite. One of these failed inside a sandbox and the
one after it passed for the wrong reason: its assertion was satisfied by
the session id the stub context carries anyway, so it would have passed
without the briefing file it exists to test being read at all.
2026-08-23 03:09:19 -07:00
branderson 75519c886e Make the release pass a thing that is run, not a thing that is remembered
Moving development to main is where the checks no test performs are the
only checks left, and until now they lived in whoever happened to
remember them. docs/release.md writes them down and says why each one is
not a test; test/release-pass.sh runs what can be run and prints the
rest, chosen from what the release actually changes.

It reports a gate it could not run as not run, rather than as passing.
That distinction is the whole point of the script: the privileged suite
queues silently when its runner is down, and shellcheck was missing from
this image entirely, and both of those look like green from a distance.

shellcheck is declared in .dejima/dependencies now. CI installs its own,
so a sandbox was the one place the gate could not be run at all - and it
had been passing here only because a base image happened to carry it,
which stopped being true.
2026-08-23 02:58:14 -07:00
branderson 3801d3d359 State what the sandbox-context tests need, instead of inheriting it
Seven of them set DEJIMA=1 and assumed nothing else was set. Inside a
dejima sandbox that is false: the briefing-delivery variables are
exported, because that is how the session running the tests got its own
briefing, and the hook then deliberately emits only the session id. So
the tests passed in CI and failed on the machine this project is worked
on - six of them, on a change that touched none of it.

Third time this has happened (proxy.bats, remote.bats, now these), and
the remedy .dejima.md already names: state the precondition with env -u
rather than leaving it to whatever the environment happens to be. Routed
through one helper so the next test to pipe into this hook inherits the
statement rather than the assumption.
2026-08-23 02:32:20 -07:00
branderson c877b8c92d Refuse a body file the CLI would have refused, rather than sending it empty
The shadow reads --body-file and hands the CLI a temp file, which always
exists - so the CLI's own check on that argument can never fire again,
and whatever the shadow read is what gets published.

Two ways that went wrong, both of them shapes the CLIs already refused
before the shadow stood in front of them. A mistyped path produced a
pull request whose entire body was the disclosure marker, created rather
than refused, exit 0. And --body-file /dev/zero filled a temp file until
the disk did, where gitea-pr rejects a non-regular file outright.

Checked here now, before the CLI runs at all, because here is the only
place left that can.
2026-08-23 02:17:10 -07:00
branderson af0bc2f7d9 Merge pull request 'Make Ctrl-Z background the whole launch instead of stranding the session' (#193) from ctrlz-suspend into development
Reviewed-on: #193
Reviewed-by: branderson <brad@branderson.io>
2026-08-23 01:56:11 -07:00
branderson 28fbc1973c Say what Ctrl-Z does, and what it is not
tests / confined (proxy) (pull_request_target) Successful in 1m13s
tests / confined (conversation) (pull_request_target) Successful in 1m24s
tests / runner-env (confined) (pull_request_target) Successful in 34s
tests / confined (shellcheck) (pull_request_target) Successful in 49s
tests / confined (providers) (pull_request_target) Successful in 2m18s
tests / privileged (pull_request_target) Successful in 3m43s
tests / confined (remote) (pull_request_target) Successful in 3m45s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (static) (pull_request_target) Successful in 4m59s
tests / confined (project) (pull_request_target) Successful in 5m17s
tests / confined (config) (pull_request_target) Successful in 7m0s
tests / confined (audit) (pull_request_target) Successful in 9m0s
Suspending is not detaching: a suspended session makes no progress and
belongs to the terminal it started on. Someone who wants the other thing
should be pointed at tmux and dejima-attach rather than finding out the
difference by leaving a session suspended overnight.
2026-08-23 01:32:56 -07:00
branderson de7f541d00 Test Ctrl-Z through a real terminal, and prove the test can fail
Sending SIGTSTP would test nothing: the question is whether the terminal
turns the keystroke into a signal at all, which is exactly what raw mode
decides. So the harness drives a real pty with a real interactive shell
on it, suspends a real job and brings it back. It needs the shell,
because the kernel discards SIGTSTP sent to an orphaned process group,
which is what a launcher started any other way ends up in.

The container run is stood in for rather than run: Docker is not
available here, and what is under test is the terminal handling on this
side of it.

The second test is the control. This one is made of timing and terminal
state, the two things most likely to quietly stop testing anything, so
it runs the same harness without the fix and fails if the bug does not
reappear - byte for byte, 0x1a arriving in the container.
2026-08-23 01:32:56 -07:00
branderson 0ae85e04ee Run the terminal's suspend handling for the length of a session
Backgrounded into dejima's own process group, so the kernel stops and
continues it with the container run and everything else host-side;
nothing forwards signals. Started immediately before the run and stopped
in cleanup(), which also puts the terminal back - the container run
restores what it saved when it attached, but a run that was killed
rather than exited restores nothing, and what that would leave behind is
a terminal with no interrupt character.
2026-08-23 01:32:56 -07:00
branderson 0503680fb1 Take Ctrl-Z back from the container while a session runs
A container run holds the terminal in raw mode, and raw mode has ISIG
off - so Ctrl-Z is not a signal, it is the byte 0x1a, delivered to
whatever is running inside. A harness that implements Ctrl-Z itself then
restores the terminal and stops, which is why the reported symptom is
the launcher's own output reappearing, and nothing in a container can
continue it: there is no job-control shell in there. The host never saw
a thing, so there was no job to bring back either.

So the one character that means suspend is taken back for the host, and
only that one. intr and quit stay undefined, because Ctrl-C is how a
harness expects to be interrupted and that has to keep reaching it.

On resume the terminal has to be put back by hand. Both bash and zsh
restore their own modes when they take the terminal at the prompt and
put those same modes back on fg, not the ones the job was using, so
without this the session returns line-buffered and echoing. The SIGCONT
trap re-applies the session's modes and pokes the process group with
SIGWINCH, which is what makes a full-screen harness paint over whatever
the shell wrote while it was away.
2026-08-23 01:32:56 -07:00
branderson 9354287b1c Merge pull request 'Give every CLI a --help that explains its arguments' (#178) from cli-help into development
Reviewed-on: #178
Reviewed-by: branderson <brad@branderson.io>
2026-08-23 00:53:48 -07:00
branderson 3e5fc982bb Merge pull request 'Say which forge is the worn path, and which is not' (#192) from forge-maturity-doc into development
Reviewed-on: #192
2026-08-23 00:51:24 -07:00
branderson 106eec8b8b Merge pull request 'Disclose agent authorship for every forge, not just the ones dejima wrote a CLI for' (#191) from forge-disclosure into development
Reviewed-on: #191
Reviewed-by: branderson <brad@branderson.io>
2026-08-23 00:48:56 -07:00
branderson 88a485a51d Merge pull request 'Let the github forge start, and stop a flag walking past the merge block' (#190) from forge-github-shadow into development
Reviewed-on: #190
Reviewed-by: branderson <brad@branderson.io>
2026-08-23 00:48:06 -07:00
branderson 0f38edccb1 Assert -h prints the same page as --help, not merely that it exits 0
tests / confined (proxy) (pull_request_target) Successful in 1m13s
tests / confined (conversation) (pull_request_target) Successful in 1m19s
tests / confined (shellcheck) (pull_request_target) Successful in 51s
tests / confined (providers) (pull_request_target) Successful in 2m14s
tests / runner-env (confined) (pull_request_target) Successful in 37s
tests / confined (remote) (pull_request_target) Successful in 3m36s
tests / privileged (pull_request_target) Successful in 3m38s
tests / runner-env (privileged) (pull_request_target) Successful in 33s
tests / confined (project) (pull_request_target) Successful in 5m1s
tests / confined (static) (pull_request_target) Successful in 3m51s
tests / confined (config) (pull_request_target) Successful in 6m39s
tests / confined (audit) (pull_request_target) Successful in 8m54s
The check was status-only, which a tool that ignored -h and happened to
succeed would pass while printing nothing like help. It now compares the
output of both, so the short form has to reach the same page rather than
merely reach an exit code.

All eleven already agreed; this stops one drifting later.
2026-08-22 15:32:35 -07:00
branderson 535460e337 Keep the new help pages inside two rules they were breaking
tests / confined (proxy) (pull_request_target) Successful in 1m11s
tests / confined (conversation) (pull_request_target) Successful in 1m18s
tests / runner-env (confined) (pull_request_target) Successful in 34s
tests / confined (shellcheck) (pull_request_target) Successful in 49s
tests / confined (providers) (pull_request_target) Successful in 2m16s
tests / confined (remote) (pull_request_target) Successful in 3m37s
tests / privileged (pull_request_target) Successful in 3m44s
tests / confined (static) (pull_request_target) Successful in 3m52s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (project) (pull_request_target) Successful in 5m14s
tests / confined (config) (pull_request_target) Successful in 6m48s
tests / confined (audit) (pull_request_target) Successful in 8m52s
Two suite failures, both real.

bin/dejima's help listed the shipped forges by name. Core is deliberately
forge-agnostic - the set comes from the provider directories, and a static test
holds that core never names one, in prose as much as in code. The flag
description now says what the flag does without enumerating what it accepts,
which is the docs' job anyway.

dejima-notify-watch treated --help as an unrecognized argument on purpose: an
existing test asserts anything it does not know exits 2 with usage rather than
falling into the watch loop, which for an interactive invocation looks like a
hang. --help was named in that test as an example. #64 supersedes that for
--help specifically, so it moves off the bad-argument list; the property the
test is about is untouched and still covered by the typos beside it.
2026-08-22 15:18:13 -07:00
branderson 2c38562d8d Point at --help from the configuration docs
Placed next to the line about --print-config, since the two answer adjacent
questions: --help says what a flag means, --print-config says which one won.
The page itself is about the config file behind them.

Refs #64
2026-08-22 15:10:06 -07:00
branderson dfe1b42678 Hold every CLI to a --help that fits on a screen
Four properties, over a list discovered rather than written down: every
executable under bin/ that is not a library. A tool added later is covered the
day it lands, which is the only version of this that stays true.

Every one supports --help and -h, exits 0, prints something, and opens with its
own name. That last check is on the FIRST line rather than anywhere in the
page, and the difference matters: nearly every help mentions its own name
incidentally further down, in a "started by systemd/..." line, so "appears
somewhere" is satisfied by a page with the wrong title on it. Mutation testing
found that - the first version of the check passed a help page retitled for a
different tool.

The 30-line budget is asserted rather than aspired to, with a floor as well, so
a page cannot come in under budget by saying nothing.

And --help works with no config at all, which is exactly when someone reaches
for it. Every tool dispatches before sourcing a library or reading config, so a
missing or malformed config cannot stop it printing.

Bare invocation is checked too, on the tools that take a required argument: it
still prints a terse usage to stderr and exits nonzero, which is the half of
#64 that asks for nothing to change.

Refs #64
2026-08-22 15:09:51 -07:00
branderson dde767ba0d Say what the service tools are, for anyone who runs one by hand
None of these three is driven by an operator: two are started by systemd units
and one by bin/dejima around a session. Running one by hand mostly produces
silence, and the help now says why - each names what starts it.

Each also states the condition that makes it a no-op, because that is the
likeliest reason someone is reading the help at all: ttyd serves nothing until
remote access is enabled, notify-watch needs a notify URL, and the conversation
shipper needs both loki_url and the separate conversation opt-in. That second
switch exists because session content is a different sensitivity class from DNS
and firewall logs, which is worth one sentence where someone is looking for it.

And each says why it runs where it runs. ttyd binds loopback because attaching
gives a host tmux session; notify-watch is host-side because it is the trust
boundary between a session's markers and a phone outside the sandbox; the
shipper is host-side because a log a session can reach is a log it can silence.

Refs #64
2026-08-22 15:07:23 -07:00
branderson d362e2d5b8 Document dejima-attach's three invocation forms
The picker, a run-id, and --list, which are already described well in the
file's own header; this is compression rather than authorship.

The part worth adding is what attaching gets you: a tmux session on the HOST,
not a shell in the container. That is the point of the tool, and it is also
the reason exposing it is exposing host access - the prefix key opens a window
outside the sandbox. Someone deciding how to expose remote access should meet
that fact in the help rather than in docs/remote-access.md afterwards.

Refs #64
2026-08-22 15:06:39 -07:00
branderson 78f1afadda Document what the audit and epitaph tools do
Both are about what a session left behind, and both are usually run by
bin/dejima rather than by hand - which their help now says, because someone
running 'dejima-audit snapshot' expecting output deserves to know why there
isn't any.

The audit help names its four subcommands and what --full does, and says what
is actually watched: the part a container boundary cannot protect. It also
states the rule that decides severity, since CRITICAL is otherwise just a
colour: a finding is CRITICAL when it names something that runs unsandboxed on
the host.

The epitaph help says why the store is never mounted, which is the same reason
the audit store is not: a session must not be able to read what an earlier one
concluded. It also says that keeping nothing is a normal outcome rather than a
failure, because a harness whose transcript survives compaction has nothing to
preserve.

Refs #64
2026-08-22 15:06:39 -07:00
branderson 55edbadb98 Explain the approval CLIs' scopes in --help
The three share a vocabulary - --global, --project [path], --container - and
now describe it the same way, because an operator who has read one should not
have to re-learn it at the next.

dejima-allow's help says what happens with NO scope flag, which #64 asks for
by name and which is the least obvious part of it: the grant goes to the
running container and lasts as long as that container, found automatically if
exactly one is up. It also says that --project and --global do not touch a
session already running, since that is the mistake the split invites.

dejima-dep and dejima-project both say that declaring is not granting: the
declaration files live in the workspace, which the session can write, so
nothing takes effect until an operator approves it into a store the container
cannot reach. That is the reason the two commands exist rather than one, and
it is not obvious from either usage line.

Refs #64
2026-08-22 15:05:47 -07:00
branderson 08263f101f Give dejima a --help that explains its flags
Every flag, what the positional argument and -- mean, and that each flag
overrides the config file for one launch rather than changing it. The tail
points at --print-config, which answers the question a help page cannot: not
what the flags are, but which one won.

Dispatched before the libraries are sourced and before the config is parsed.
The moment someone reaches for --help is often the moment the config is
missing or wrong, and a help page that needs a working config to print is no
help at all.

stdout and exit 0, where usage() stays on stderr and exits nonzero. That split
is what #64 asks for: bare invocation keeps its terse line, --help explains.

Refs #64
2026-08-22 15:04:54 -07:00
69 changed files with 10571 additions and 1570 deletions
+18 -7
View File
@@ -37,6 +37,10 @@ set before opening a PR, not between edits.
| `bin/dejima-provider-lib.sh`, `forges/`, `workflows/`, `harnesses/` | `test/providers.bats` |
| `bin/dejima-attach`, `-ttyd`, `-notify-watch`, `web/`, `systemd/` | `test/remote.bats` |
| `bin/dejima-project-lib.sh`, `-dep`, `-project`, `-allow` | `test/project.bats` |
| `bin/dejima-update-check` | `test/config.bats` |
| `bin/dejima-tui-lib.sh` | `test/remote.bats` and `test/project.bats` (both tools use it) |
| `bin/dejima-tty-suspend` | `test/static.bats` |
| `tools/` | `test/providers.bats` |
| `bin/dejima-anthropic-proxy`, `docker/anthropic-proxy-forwarder.js` | `test/proxy.bats` |
| `docker/`, `hooks/`, `README.md`, `docs/` | `test/static.bats` |
| anything under `docker/` that has to actually boot | `test/runtime.bats` (needs Docker) |
@@ -214,15 +218,22 @@ Branches here are opened against `development`, which merges to `main`
periodically. `main` stays releasable while work lands quickly; nothing else
about the workflow changes.
`gitea-pr` defaults its base to the repository's default branch, so say so:
Moving `development` to `main` is not an ordinary merge, and is not something
to do as a follow-on to finishing a branch. It has its own pass - suites,
security review, documentation review, and the manual checks nothing here can
run - in [docs/release.md](docs/release.md), and `test/release-pass.sh` runs
what can be run and prints the rest. If you are asked to prepare a release,
start there rather than opening the pull request first.
```sh
gitea-pr create --base development --title ... --body-file -
```
`gitea-pr` prints the base it is opening against, every time, and reads
`git config gitea.prBase` before falling back to the repository's default
branch. On a checkout with that set, `gitea-pr create` targets the right
branch with no flag; on one without it, the line it prints is what tells you
otherwise. Pass `--base development` when you want to be sure.
Forgetting is the likely mistake, and it is recoverable - a pull request's
base can be changed after the fact, in the web UI or through the API. Check
what you opened rather than assuming.
Report the base it printed, not the one you meant. A pull request URL looks
identical whichever branch it is against, so nothing else will contradict a
wrong claim.
Rebase onto `origin/development` too, for the same reason: rebasing onto
`main` puts anything already merged to `development` back in your diff.
+5
View File
@@ -29,3 +29,8 @@ npm:cline
apt:tmux
apt:bats
# The other half of what CI runs. The CI job installs it itself, so without
# this line a sandbox is the one place the gate cannot be run at all - and a
# base image that happens to carry it is not a guarantee, as one that stopped
# carrying it demonstrated.
apt:shellcheck
+11 -1
View File
@@ -425,8 +425,18 @@ jobs:
# SKIP_BUILD below then reuses this image rather than skipping a build.
# The image is still built fresh from this commit, so the property the
# test file defaults to - never assert against a stale image - holds.
# Named under localhost/ so a stray push cannot resolve to Docker Hub,
# like every other image this repo builds (#53).
#
# This step and runtime.bats must agree on that name, and they come from
# different commits: this file is read from the BASE branch (see the
# pull_request_target note at the top) while the checkout above is the
# branch under test. So a rename here lands in two steps - the base
# tagging both names first, then this - and a future one has to do the
# same. A single pull request that changes both sides fails, because the
# half that runs is the half it did not change.
- name: Build the sandbox image
run: docker build -t dejima:latest -f docker/Dockerfile .
run: docker build -t localhost/dejima:latest -f docker/Dockerfile .
- run: bats test/runtime.bats
env:
+490 -565
View File
File diff suppressed because it is too large Load Diff
+505 -18
View File
@@ -9,6 +9,59 @@
# dejima ~/repos/myproject
set -euo pipefail
# --help, before anything else runs.
#
# Deliberately ahead of the library sourcing and the config parse below: the
# moment someone reaches for --help is often the moment the config is missing
# or wrong, and a help page that needs a working config to print is no help.
# Nothing here but `cat`.
#
# stdout and exit 0, unlike usage(), which stays on stderr with a nonzero exit
# so a mistyped invocation still fails a script. That split is the whole of
# what #64 asked for: bare invocation keeps its terse line, --help explains.
case "${1:-}" in
-h|--help)
cat <<'DEJIMA_HELP'
dejima - run an agent harness in a container that cannot reach your machine
Usage: dejima [options] [workspace] [-- args...]
workspace project directory to mount read-write (default: current directory)
-- args... everything after is passed to the harness command untouched
Options (each overrides the config file for one launch only):
--harness <name> which harness to run
--forge <name> which forge integration to use
--workflow <name> what the agent is told to do with its work
--cmd <command> run this instead of the harness's own command
--identity <name> which [identity.<name>] supplies the credentials
-i, --include <tool> add an optional tool to this launch (repeatable);
see docs/tools.md for what dejima ships
--model <id> the selected harness's model, where it has one;
--base-url <url> its endpoint, and how much context it may use.
--context-window <n> Refused for a harness with no such setting
--ro <path> mount read-only; --rw read-write. Repeatable. Long
forms --extra-readonly and --extra-read-write. Both
refuse a path overlapping dejima's own stores
--allow-github-push open GitHub's push path (port 22)
--strict-audit refuse to start over unacknowledged CRITICAL findings
--no-log ship nothing to the log endpoint this launch
--no-conversation keep this session's transcript out of the log
--no-epitaph keep nothing if this session compacts
--no-firewall disable the egress firewall entirely (debugging only)
--print-config print the resolved configuration and exit
--print-briefing print the briefing the agent will get, and exit
-h, --help this message
Every --no-* flag only ever narrows. Nothing here turns a setting back on: a
config that says off is a decision, not a default to override in passing.
Config lives in ~/.config/dejima/config and [core] harness must be set before
a launch starts. --print-config prints every key as it resolves.
DEJIMA_HELP
exit 0 ;;
esac
# readlink -f resolves through the ~/.local/bin/dejima symlink
# install.sh sets up - without it, BASH_SOURCE[0] is the symlink's own path
# when invoked by name via PATH, so dirname finds ~/.local/bin instead of
@@ -66,10 +119,14 @@ fi
FLAG_OVERRIDES=()
_set_override() {
local key="$1" value="$2" type entry
# $3 is the flag to blame, for the flags whose spelling is not their key's:
# --context-window sets context_window, and reporting the key back would
# name an option the operator cannot type.
local flag="${3:---${1##*.}}"
local -a kept=()
type="$(config_type "$key")" || { echo "unknown config key: $key" >&2; exit 1; }
if ! _config_valid_value "$type" "$value"; then
echo "invalid value for --${key##*.}: $value" >&2
echo "invalid value for $flag: $value" >&2
exit 1
fi
# A repeated flag takes the last value, same as a repeated key in the
@@ -145,9 +202,26 @@ _mount_label() {
esac
}
# A flag naming one of the SELECTED harness's keys, deferred because which
# harness is selected is not known until every flag has been read - --harness
# may come after --model. Recorded here, resolved below the loop.
#
# Deferred rather than resolved eagerly so the error can name the harness:
# "claude-code has no model setting" is actionable where "unknown config key"
# is not.
HARNESS_KEY_FLAGS=()
_defer_harness_key() {
HARNESS_KEY_FLAGS+=("$1=$2=$3")
}
PRINT_CONFIG=0
PRINT_BRIEFING=0
ALLOW_GITHUB_PUSH_FLAG=0
# Additive, like --ro and --rw and unlike every other flag here: an optional
# tool asked for on the command line is one MORE than the config and the
# project already agreed on, not a replacement for them. Repeatable.
TOOL_FLAGS=()
NO_LOG=0
while [ $# -gt 0 ]; do
case "$1" in
--) shift; break ;;
@@ -155,8 +229,27 @@ while [ $# -gt 0 ]; do
--print-briefing) PRINT_BRIEFING=1; shift ;;
--no-firewall) _set_override core.firewall off; shift ;;
--allow-github-push) ALLOW_GITHUB_PUSH_FLAG=1; shift ;;
# Narrowing flags. Each has no counterpart that turns the setting back
# on: a config saying off is a decision, and a flag that could undo it
# in passing would make the file's answer the weaker one.
--strict-audit) _set_override audit.strict on; shift ;;
--no-epitaph) _set_override epitaph.enabled off; shift ;;
--no-conversation) _set_override logging.conversation off; shift ;;
--no-log) _set_override logging.loki_url ""
_set_override logging.conversation off
NO_LOG=1; shift ;;
--model) _need_value --model "${2:-}"
_defer_harness_key --model model "$2"; shift 2 ;;
--base-url) _need_value --base-url "${2:-}"
_defer_harness_key --base-url base_url "$2"; shift 2 ;;
--context-window) _need_value --context-window "${2:-}"
_defer_harness_key --context-window context_window "$2"; shift 2 ;;
--cmd) _need_value --cmd "${2:-}"; _set_override core.command "$2"; shift 2 ;;
--harness) _need_value --harness "${2:-}"; _set_override core.harness "$2"; shift 2 ;;
--identity) _need_value --identity "${2:-}"; _set_override core.identity "$2"; shift 2 ;;
--include|-i)
_need_value --include "${2:-}"
TOOL_FLAGS+=("$2"); shift 2 ;;
--forge) _need_value --forge "${2:-}"; _set_override core.forge "$2"; shift 2 ;;
--workflow) _need_value --workflow "${2:-}"; _set_override core.workflow "$2"; shift 2 ;;
--extra-readonly|--ro)
@@ -174,15 +267,52 @@ done
# Defined here rather than in the library because an override is a property
# of one invocation of this launcher, not of the config file.
config_get() {
local key="$1.$2" entry
local key="$1.$2" entry value
for entry in ${FLAG_OVERRIDES[@]+"${FLAG_OVERRIDES[@]}"}; do
[ "${entry%%=*}" = "$key" ] || continue
printf '%s' "${entry#*=}"
return 0
done
# The selected identity, for the handful of keys an identity owns: the
# credentials and the ssh key a PERSON brings, which is the half a shared
# project file cannot carry. Below a flag, because a flag is this launch
# and an identity is this project; above the file, because the file is the
# machine's default and naming an identity is how you say "not that one".
#
# Every consumer goes through config_get, including forge_env_exports in
# the library, so this one wrapper is the whole insertion point.
if value="$(identity_get "$1" "$2")"; then
printf '%s' "$value"
return 0
fi
_config_get_from_file "$1" "$2"
}
# The deferred harness-key flags, now that --harness has been read and
# config_get honours it. A key the selected harness does not declare is
# refused by name rather than ignored: a --model that silently did nothing
# because the harness has no such setting is the failure worth avoiding, and
# it is the whole reason these are not resolved inside the loop above.
if [ "${#HARNESS_KEY_FLAGS[@]}" -gt 0 ]; then
_flag_harness="$(config_get core harness)"
for _entry in "${HARNESS_KEY_FLAGS[@]}"; do
_flag="${_entry%%=*}"
_rest="${_entry#*=}"
_key="${_rest%%=*}"
_value="${_rest#*=}"
if [ -z "$_flag_harness" ]; then
echo "$_flag needs a harness: set [core] harness or pass --harness." >&2
exit 1
fi
if ! config_type "harness.$_flag_harness.$_key" >/dev/null 2>&1; then
echo "the $_flag_harness harness has no '$_key' setting, so $_flag does not apply to it." >&2
exit 1
fi
_set_override "harness.$_flag_harness.$_key" "$_value" "$_flag"
done
unset _flag_harness _entry _flag _rest _key _value
fi
# Retired environment variables. Named, never honoured: silently ignoring an
# exported LOKI_URL means logs stop shipping with nothing saying why, and
# silently ignoring SANDBOX_ANTHROPIC_TRANSPORT means a session quietly gets
@@ -311,6 +441,14 @@ fi
# testable without booting Docker.
if [ "$PRINT_CONFIG" = 1 ]; then
config_validate || exit 1
# Before printing, not only before launching. Every value below resolves
# THROUGH the identity, so printing them under a name that resolves to
# nothing would show this machine's own credentials as though they were
# the identity's - the exact confusion the refusal exists to prevent, in
# the one command an operator runs to check what they configured.
# The forge is read here rather than taken from DEJIMA_FORGE_NAME, which
# is resolved further down, past this early exit.
identity_validate "$(config_get core forge)" || exit 1
while IFS= read -r _key; do
_section="${_key%.*}"
_leaf="${_key##*.}"
@@ -412,6 +550,44 @@ case " $_corpus_approved " in
esac
unset _corpus_approved
# The optional tools this launch has: [core] tools, plus whatever the project
# declared AND the operator approved, plus --include. A union rather than a
# precedence chain, because each of the three answers a different question -
# what this machine always wants, what this project needs, what this run is
# for - and none of them is a correction of another.
#
# The project's half comes from the APPROVAL STORE and never from the
# declaration in the workspace, for the reason the corpus block above gives:
# that file is session-writable, and a session that could add a tool would be
# choosing what the next session's image contains. This file may not name that
# directory at all, and a static test holds it to that - which is why the path
# is not spelled out here.
#
# Every name is checked against a manifest that exists before it reaches the
# image build. approved_entries already refuses an entry its validator does
# not accept, so the only unchecked source is the flag - which is the
# operator's own typing, and gets the same answer.
DEJIMA_TOOLS=""
_tools_wanted="$(config_get core tools | tr ':' ' ')"
_tools_wanted="$_tools_wanted $(approved_entries "$(config_root)" tools | tr '\n' ' ')"
if [ -n "$PROJECT_STORE" ]; then
_tools_wanted="$_tools_wanted $(approved_entries "$PROJECT_STORE" tools | tr '\n' ' ')"
fi
_tools_wanted="$_tools_wanted ${TOOL_FLAGS[*]-}"
for _tool in $_tools_wanted; do
[ -n "$_tool" ] || continue
case " $DEJIMA_TOOLS " in *" $_tool "*) continue ;; esac
if ! valid_tool_name "$_tool"; then
echo "no optional tool named '$_tool'." >&2
echo "Available: $(provider_list tool | tr '\n' ' ')" >&2
echo "See docs/tools.md." >&2
exit 1
fi
tool_validate "$_tool" || exit 1
DEJIMA_TOOLS="${DEJIMA_TOOLS:+$DEJIMA_TOOLS }$_tool"
done
unset _tools_wanted _tool
AUDIT_DIR="$(config_get audit dir)"
BASELINE_DIR="$(config_get audit baselines_dir)"
@@ -626,6 +802,43 @@ if [ -n "$_disclose_spec" ]; then
IFS="$_old_ifs"
fi
# How this forge's CLI can be aimed somewhere other than the repository the
# session was launched against, and what that repository is (#187).
#
# A CLI that resolves the repo from origin and refuses another host cannot be
# aimed anywhere and declares nothing here. One that takes the repository as
# an option makes acting on an unrelated repository the same length of command
# as acting on this one. Which is which is the forge's to declare; core only
# resolves the repository the declaration is compared against, and
# docker/forge-guard.sh does the comparing.
#
# The last two path components of origin's URL, which is the same
# "<owner>/<name>" both forges use and covers ssh, scp-style and https
# remotes without parsing any of them as a URL. No origin, or a workspace
# that is not a checkout, leaves this empty - and the guard then refuses any
# --repo or GH_REPO outright rather than waving it through. A workspace with
# no origin has nothing such a flag could legitimately name, so "cannot
# confirm" is not a reason to allow it.
DEJIMA_FORGE_REPO_FLAGS="$(manifest_get forge "$DEJIMA_FORGE_NAME" repo_flags)"
DEJIMA_FORGE_REPO_ENV="$(manifest_get forge "$DEJIMA_FORGE_NAME" repo_env)"
DEJIMA_FORGE_REPO_PATHS="$(manifest_get forge "$DEJIMA_FORGE_NAME" repo_paths)"
DEJIMA_FORGE_REPO=""
if [ -n "$DEJIMA_FORGE_REPO_FLAGS" ] || [ -n "$DEJIMA_FORGE_REPO_ENV" ] ||
[ -n "$DEJIMA_FORGE_REPO_PATHS" ]; then
_origin_url="$(git -C "$WORKSPACE_DIR" remote get-url origin 2>/dev/null)" || _origin_url=""
if [ -n "$_origin_url" ]; then
_origin_url="${_origin_url%/}"
_origin_url="${_origin_url%.git}"
_origin_name="${_origin_url##*/}"
_origin_rest="${_origin_url%/*}"
_origin_owner="${_origin_rest##*[:/]}"
if [ -n "$_origin_owner" ] && [ -n "$_origin_name" ] &&
[ "$_origin_owner" != "$_origin_url" ]; then
DEJIMA_FORGE_REPO="$_origin_owner/$_origin_name"
fi
fi
fi
# WORKSPACE_DIR is resolved above, ahead of --print-briefing, since
# briefing_compose needs DEJIMA_RW_MOUNTS (which needs WORKSPACE_DIR for its
# own overlap guard) before that point.
@@ -831,6 +1044,9 @@ SANDBOX_NVIM_LOCKFILE=""
SANDBOX_DERIVED_DOCKERFILE=""
SANDBOX_DERIVED_CONTEXT=""
SANDBOX_CONVERSATION_SHIP_PID=""
SANDBOX_TTY_SUSPEND_PID=""
SANDBOX_TTY_MODES=""
SANDBOX_CLIENT_PID=""
# Names each host-side task in cleanup() as it starts. Between the container
# exiting and the operator's shell prompt returning, dejima audits every
@@ -877,6 +1093,20 @@ cleanup() {
# rather than dying with the batch. Reaped for the same reason the proxy
# is: an unreaped child reparents to PID 1 and stays a zombie wherever
# PID 1 does not reap.
# The terminal first, and its settings before their watcher: the
# container run restores what it saved when it attached, but a run that
# was killed rather than exited restores nothing, and what that leaves
# behind is a terminal with no interrupt character.
if [ -n "${SANDBOX_TTY_MODES:-}" ]; then
stty "$SANDBOX_TTY_MODES" < /dev/tty 2>/dev/null || true
# And the cursor, for the same reason the modes are restored here: a
# run that was killed rather than exited put neither back.
printf '\033[?25h' > /dev/tty 2>/dev/null || true
fi
if [ -n "${SANDBOX_TTY_SUSPEND_PID:-}" ]; then
kill "$SANDBOX_TTY_SUSPEND_PID" 2>/dev/null || true
wait "$SANDBOX_TTY_SUSPEND_PID" 2>/dev/null || true
fi
if [ -n "${SANDBOX_CONVERSATION_SHIP_PID:-}" ]; then
kill "$SANDBOX_CONVERSATION_SHIP_PID" 2>/dev/null || true
wait "$SANDBOX_CONVERSATION_SHIP_PID" 2>/dev/null || true
@@ -925,7 +1155,20 @@ cleanup() {
# Ahead of the audit rather than after it, so the audit keeps the last
# word (see below): this prints a line of its own whenever it keeps
# something, and that line was landing under the verdict.
"$SCRIPT_DIR/dejima-epitaph" save "$WORKSPACE_DIR" || true
# Gated here as well as inside the tool, because a flag override lives in
# this process and the tool re-reads the config for itself - it is also
# run by hand, where an environment this launcher happened to have would
# be the wrong authority. Same reason the shipper is gated at its start.
#
# DEJIMA_EPITAPH_NO_SHIP carries the one part of --no-log that this
# process cannot decide for the tool: whether to push what it kept. It
# only ever suppresses, so a stale export makes epitaph quieter and never
# louder, which is what makes it safe to honour from the environment at
# all.
if [ "$(config_get_bool epitaph enabled)" = "1" ]; then
DEJIMA_EPITAPH_NO_SHIP="$NO_LOG" \
"$SCRIPT_DIR/dejima-epitaph" save "$WORKSPACE_DIR" || true
fi
# Last writer, structurally: the operator reads bottom-up from their
# shell prompt, so anything printed after the verdict pushes it out of
# view. The marker-directory warning above is the one line a session can
@@ -960,6 +1203,23 @@ if [ -f "$HOME/.gitconfig" ]; then
else
touch "$SANDBOX_GITCONFIG_DIR/.gitconfig"
fi
# The selected identity's git name, appended to the COPY. Appended rather than
# rewritten because git takes the last value for a key, so this needs no
# knowledge of what the operator's own file already says - and because the
# copy is the only thing being edited, the real ~/.gitconfig is untouched
# whatever happens here.
#
# `git config -f` rather than a printf of "[user]\n\tname = ...": the value is
# the operator's own text, and git quotes and escapes its own file format
# correctly where a printf would produce something git reads differently the
# moment a name contains a quote or a backslash.
for _git_field in name email; do
_git_value="$(identity_git_field "user_$_git_field")" || _git_value=""
[ -n "$_git_value" ] || continue
git config -f "$SANDBOX_GITCONFIG_DIR/.gitconfig" "user.$_git_field" "$_git_value" \
|| { echo "could not set user.$_git_field from [identity] - is git installed?" >&2; exit 1; }
done
unset _git_field _git_value
export WORKSPACE_DIR
# The image's user is built to match this uid/gid/username exactly (see
@@ -1003,8 +1263,8 @@ export TZ="${TZ:-$(readlink -f /etc/localtime 2>/dev/null | sed 's#.*/zoneinfo/#
# core.command permits a leading "-" (it is validated as a generic identifier,
# shared with other keys that have no such concern) but entrypoint.sh expands
# it unquoted into `gosu "$SANDBOX_USER" ${SANDBOX_CMD:-claude} "$@"` - a
# leading dash there would be parsed as an option to gosu, not as the command.
# it unquoted into `gosu "$SANDBOX_USER" $SANDBOX_CMD "$@"` - a leading dash
# there would be parsed as an option to gosu, not as the command.
# Refuse here, at the point this value is wired in, rather than relax the
# schema type for every other identifier-typed key.
SANDBOX_CMD="$(config_get core command)"
@@ -1110,8 +1370,26 @@ LOKI_URL="$(config_get logging loki_url)"; export LOKI_URL
extra_mounts=()
# The forge's own env vars, generated from its manifest's env_export list
# rather than hardcoded - see forge_env_exports. -e here: passed straight
# through to `docker compose run`, which takes -e alongside -v.
# rather than hardcoded - see forge_env_exports.
#
# `-e NAME`, never `-e NAME=value`, for the ones that carry a secret - which
# is every `@` reference, since those resolve to a credential FILE'S CONTENTS
# rather than its path. A value in the argument list lands in
# /proc/<pid>/cmdline, which is world-readable: `ps` prints the token to any
# process on the host, and from there into whatever captures ps output. That
# is not a hypothetical - it is how #226 was found, in a paste an operator
# made while debugging something unrelated.
#
# The bare form takes the value from THIS process's environment instead,
# where it sits in /proc/<pid>/environ - mode 0400, owner only.
#
# This repository already made the argument one layer down: a forge's own CLI
# feeds curl its auth header on stdin rather than as an argument, and says why
# in almost these words. The reasoning was right and it had not reached here.
#
# Docker still shows the resolved value to anyone who can `docker inspect`
# the container, which the bare form does not change and cannot. What it
# changes is the audience: daemon access rather than any process at all.
_forge_env_lines=""
if ! _forge_env_lines="$(forge_env_exports "$DEJIMA_FORGE_NAME")"; then
# forge_env_exports has already explained why on stderr. It can fail
@@ -1121,8 +1399,15 @@ if ! _forge_env_lines="$(forge_env_exports "$DEJIMA_FORGE_NAME")"; then
fi
while IFS= read -r _forge_env; do
[ -n "$_forge_env" ] || continue
extra_mounts+=(-e "$_forge_env")
# forge_env_exports emits nothing at all for a value that resolved empty,
# deliberately - see its own comment on why an empty -e for a variable
# compose already declares is not harmless. So every name reaching here
# has a value, and exporting it is what makes the bare -e below carry one.
_forge_env_name="${_forge_env%%=*}"
export "${_forge_env_name}=${_forge_env#*=}"
extra_mounts+=(-e "$_forge_env_name")
done <<< "$_forge_env_lines"
unset _forge_env_name
# The resolved combination, for the briefing and the hooks that read it
# (git-push-guard.sh's host allowlist and no-direct-push branches,
@@ -1136,6 +1421,10 @@ extra_mounts+=(-e "DEJIMA_PUSH_HOSTS=$DEJIMA_PUSH_HOSTS")
extra_mounts+=(-e "DEJIMA_BLOCKED_BRANCHES=$DEJIMA_BLOCKED_BRANCHES")
extra_mounts+=(-e "DEJIMA_MERGE_BLOCK=$DEJIMA_MERGE_BLOCK")
extra_mounts+=(-e "DEJIMA_DISCLOSE=$DEJIMA_DISCLOSE")
extra_mounts+=(-e "DEJIMA_FORGE_REPO=$DEJIMA_FORGE_REPO")
extra_mounts+=(-e "DEJIMA_FORGE_REPO_FLAGS=$DEJIMA_FORGE_REPO_FLAGS")
extra_mounts+=(-e "DEJIMA_FORGE_REPO_PATHS=$DEJIMA_FORGE_REPO_PATHS")
extra_mounts+=(-e "DEJIMA_FORGE_REPO_ENV=$DEJIMA_FORGE_REPO_ENV")
# The harness's own configuration, mounted at the mode it declared, and -
# for the directories it marked scannable - the symlink targets that have to
@@ -1580,6 +1869,59 @@ fi
# or one after a Dockerfile change, builds the image and takes minutes rather
# than seconds - the line names that, since it is the case where someone is
# most likely to give up and interrupt.
# What this launch is, before the long silent part. The startup output is
# otherwise a list of checks with no point at which it says what it is, and
# the facts below are the ones that differ between one launch and the next -
# the ones an operator otherwise has to remember or go and read (#211).
#
# Deliberately not a config dump. The value is seeing at a glance that this
# launch is not configured like the last one, so what is here is the selection
# plus the switches whose state changes what a session can do.
#
# In bin/dejima's own palette rather than the pickers': dejima-color-lib.sh
# and dejima-tui-lib.sh both define C_OK and C_WARN, with different values, so
# sourcing the second here would silently recolour every warning this script
# already prints. Unifying the two palettes is worth doing and is not this.
startup_banner() {
[ "$(config_get_bool ui banner)" = "1" ] || return 0
# Nothing at all when stderr is not a terminal: a redirected launch is a
# log, and this is decoration in it.
[ -t 2 ] || return 0
local branch model domains firewall remote epitaph
branch="$(git -C "$WORKSPACE_DIR" symbolic-ref --quiet --short HEAD 2>/dev/null)" || branch=""
model="$(harness_config_get model 2>/dev/null)" || model=""
# The count, not the list: the list is long and the number is what changes.
domains="$(printf '%s' "${ALLOWED_DOMAINS:-}" | wc -w)"
if [ "${ENABLE_FIREWALL:-1}" = "1" ]; then
firewall="${C_OK}on${C_RESET} ($domains domains)"
else
# The one switch worth a colour of its own: everything else here is a
# choice, and this one is a boundary that is not in force.
firewall="${C_WARN}off${C_RESET}"
fi
remote="$(remote_enabled && echo on || echo off)"
epitaph="$([ "$(config_get_bool epitaph enabled)" = 1 ] && echo on || echo off)"
# Labels dim, values not. The labels are how you find the value you came
# for; the values are what differs between one launch and the next.
# C_BADGE, the same name the pickers set the mark in. Two surfaces
# spelling the wordmark two different colours is the drift a shared
# palette exists to stop.
printf '\n %sdejima%s %s%s%s' \
"$C_BADGE" "$C_RESET" "$C_KEY" "$(basename -- "$WORKSPACE_DIR")" "$C_RESET" >&2
[ -z "$branch" ] || printf ' %s%s%s %s' "$C_DIM" "$GLYPH_SEP" "$C_RESET" "$branch" >&2
printf '\n %sharness%s %s %sforge%s %s %sworkflow%s %s\n' \
"$C_DIM" "$C_RESET" "$DEJIMA_HARNESS_NAME${model:+ ($model)}" \
"$C_DIM" "$C_RESET" "$DEJIMA_FORGE_NAME" \
"$C_DIM" "$C_RESET" "$DEJIMA_WORKFLOW_NAME" >&2
printf ' %sfirewall%s %s %sremote%s %s %sepitaph%s %s\n\n' \
"$C_DIM" "$C_RESET" "$firewall" \
"$C_DIM" "$C_RESET" "$remote" \
"$C_DIM" "$C_RESET" "$epitaph" >&2
}
startup_banner
printf '%sdejima%s is starting, please wait...\n' "$C_KEY" "$C_RESET" >&2
printf '%sA first run builds the container image, which takes several minutes.%s\n' \
"$C_DIM" "$C_RESET" >&2
@@ -1604,6 +1946,12 @@ if ! _unwired="$(harness_guards_wired)"; then
printf ' Run %s./install.sh%s to register them.\n' "$C_KEY" "$C_RESET" >&2
fi
# Before the image build, which is the long part: an operator who wants the
# newer dejima would rather know now than after waiting for a build of the one
# they have. Advisory and best-effort - see bin/dejima-update-check for why it
# never reaches the remote through this checkout's own git config.
"$SCRIPT_DIR/dejima-update-check" || true
# Snapshot host-persistence surfaces so the post-run audit can report
# anything that changed while this session ran (advisory - see bin/dejima-audit).
"$SCRIPT_DIR/dejima-audit" snapshot "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
@@ -1613,17 +1961,29 @@ fi
# below will report any change to it.
if [ "$(readlink -f "$WORKSPACE_DIR")" = "$(readlink -f "$REPO_ROOT")" ]; then
echo "Warning: sandbox's own repo is the mounted workspace - its trusted" >&2
echo "computing base (bin/, hooks/, docker/, install.sh) is writable this" >&2
echo "session; the exit audit will report any changes to review." >&2
echo "computing base (bin/, hooks/, docker/, test/, install.sh, and the" >&2
echo "provider and tool manifests) is writable this session; the exit audit" >&2
echo "will report any changes to review." >&2
fi
# Every image this builds is named under localhost/, which Docker reads as a
# registry host rather than as the first path component of a Docker Hub name
# (#53). A bare `dejima:node` resolves to `docker.io/library/dejima:node` the
# moment anyone pushes it, and none of these images is meant to leave the host
# that built them. Pulling the base still goes to Hub, deliberately: that is
# where `node:22` is.
#
# One prefix, used by every tag below and by the compose file's default, so
# there is no second place for a bare name to reappear.
SANDBOX_IMAGE_REPO="localhost/dejima"
# Compose builds the shared base image under its own tag, explicitly - with
# image: now honoring $SANDBOX_IMAGE, letting the derived tag leak into this
# build would rebuild the base Dockerfile under the derived name.
SANDBOX_IMAGE="dejima:${SANDBOX_USER}" \
SANDBOX_IMAGE="${SANDBOX_IMAGE_REPO}:${SANDBOX_USER}" \
docker compose -f "$COMPOSE_FILE" build sandbox
SANDBOX_IMAGE="dejima:${SANDBOX_USER}"
SANDBOX_IMAGE="${SANDBOX_IMAGE_REPO}:${SANDBOX_USER}"
# Each layer builds FROM whatever the previous one left in $SANDBOX_IMAGE, so
# global packages are shared by every project instead of being rebuilt into
@@ -1670,6 +2030,7 @@ SANDBOX_HARNESS_IMAGE_FILES="$(manifest_get harness "$DEJIMA_HARNESS_NAME" image
if [ -n "$SANDBOX_HARNESS_INSTALL" ] || [ -n "$SANDBOX_HARNESS_IMAGE_DIRS" ] \
|| [ -n "$SANDBOX_HARNESS_IMAGE_ENV" ] || [ -n "$SANDBOX_HARNESS_IMAGE_FILES" ] \
|| [ -n "$DEJIMA_TOOLS" ] \
|| [ -s "$(config_root)/dependencies" ] \
|| { [ -n "$PROJECT_STORE" ] && [ -s "$PROJECT_STORE/dependencies" ]; }; then
SANDBOX_DERIVED_DOCKERFILE="$(mktemp "${TMPDIR:-/tmp}/dejima-derived.XXXXXX")"
@@ -1681,7 +2042,7 @@ fi
if [ -n "$SANDBOX_HARNESS_INSTALL" ] || [ -n "$SANDBOX_HARNESS_IMAGE_DIRS" ] \
|| [ -n "$SANDBOX_HARNESS_IMAGE_ENV" ] || [ -n "$SANDBOX_HARNESS_IMAGE_FILES" ]; then
tag="dejima:${SANDBOX_USER}-harness-${DEJIMA_HARNESS_NAME}"
tag="${SANDBOX_IMAGE_REPO}:${SANDBOX_USER}-harness-${DEJIMA_HARNESS_NAME}"
generate_spec_dockerfile "$SANDBOX_IMAGE" "$SANDBOX_HARNESS_INSTALL" \
"$SANDBOX_HARNESS_IMAGE_DIRS" "$SANDBOX_HARNESS_IMAGE_ENV" \
"$SANDBOX_HARNESS_IMAGE_FILES" \
@@ -1698,11 +2059,39 @@ if [ -n "$SANDBOX_HARNESS_INSTALL" ] || [ -n "$SANDBOX_HARNESS_IMAGE_DIRS" ] \
SANDBOX_IMAGE="$tag"
fi
# One layer per tool, above the harness and below the dependencies. Above the
# harness because a tool is meant to be on PATH for it; below the operator's
# own dependencies because those are the last word on what the image holds.
#
# Tagged by name and version-of-spec so two projects wanting the same tool
# share a layer, and a manifest that changes what it installs gets a new one
# rather than a stale cached hit.
for _tool in $DEJIMA_TOOLS; do
_tool_install="$(manifest_get tool "$_tool" install)"
_tool_env="$(manifest_get tool "$_tool" image_env)"
_tool_layout="$(manifest_get tool "$_tool" install_layout)"
tag="${SANDBOX_IMAGE_REPO}:${SANDBOX_USER}-tool-${_tool}-$(printf '%s' "$_tool_install" | sha256sum | cut -c1-12)"
DEJIMA_TARBALL_STRIP="$_tool_layout" \
generate_spec_dockerfile "$SANDBOX_IMAGE" "$_tool_install" "" "$_tool_env" "" \
> "$SANDBOX_DERIVED_DOCKERFILE"
echo "Building $tag ($_tool: $(manifest_get tool "$_tool" description))..."
if ! docker build -t "$tag" -f "$SANDBOX_DERIVED_DOCKERFILE" "$SANDBOX_DERIVED_CONTEXT"; then
# No fallback, for the reason the harness layer gives: a session told
# it has a tool and given an image without one fails inside the
# container, where it reads as dejima being broken.
echo "Failed to build $tag." >&2
echo "$_tool installs from: $_tool_install" >&2
exit 1
fi
SANDBOX_IMAGE="$tag"
done
unset _tool _tool_install _tool_env _tool_layout
if [ -s "$(config_root)/dependencies" ]; then
build_dep_layer "dejima:${SANDBOX_USER}-global" "$(config_root)"
build_dep_layer "${SANDBOX_IMAGE_REPO}:${SANDBOX_USER}-global" "$(config_root)"
fi
if [ -n "$PROJECT_STORE" ] && [ -s "$PROJECT_STORE/dependencies" ]; then
build_dep_layer "dejima:${SANDBOX_USER}-$(project_slug "$WORKSPACE_DIR")" "$PROJECT_STORE"
build_dep_layer "${SANDBOX_IMAGE_REPO}:${SANDBOX_USER}-$(project_slug "$WORKSPACE_DIR")" "$PROJECT_STORE"
fi
export SANDBOX_IMAGE
@@ -1795,10 +2184,108 @@ compose_status=0
#
# `|| true` and a backgrounded start: shipping is a bonus on top of the
# session, so a shipper that cannot start must not stop the session running.
"$SCRIPT_DIR/dejima-conversation-ship" "$WORKSPACE_DIR" &
SANDBOX_CONVERSATION_SHIP_PID=$!
#
# Gated here as well as inside the shipper, which checks the same two keys for
# itself: --no-conversation and --no-log are overrides held in this process,
# and the shipper re-reads the config rather than inheriting them.
if [ "$(config_get_bool logging conversation)" = "1" ] &&
[ -n "$(config_get logging loki_url)" ]; then
"$SCRIPT_DIR/dejima-conversation-ship" "$WORKSPACE_DIR" &
SANDBOX_CONVERSATION_SHIP_PID=$!
fi
docker compose -f "$COMPOSE_FILE" run --rm ${extra_mounts[@]+"${extra_mounts[@]}"} sandbox "$@" || compose_status=$?
# Ctrl-Z (#179). Two things have to be true at once, and the second is the
# one that took four rounds of measurement to find.
#
# The keystroke has to become a signal at all. Under a container run the
# terminal is raw, ISIG is off, and Ctrl-Z is the byte 0x1a delivered into the
# container - where a harness that implements suspend stops itself, with no
# job-control shell in there to continue it. dejima-tty-suspend takes that one
# character back for the host.
#
# And the container client must never receive SIGTSTP. Measured, holding
# everything else equal: the client survives SIGSTOP and does not survive
# SIGTSTP. A catchable stop signal interrupts the read it is blocked in, its
# input forwarding never recovers, and the session comes back alive but deaf -
# resumed, with nothing typed reaching the harness. That is what shipped once
# and had to be taken out again.
#
# So the client is started with SIGTSTP ignored, which it inherits and keeps
# across exec, and the launcher converts the keystroke into an explicit
# SIGSTOP for it before stopping itself. The job still stops completely, so
# the prompt comes back; the client is stopped by a signal that interrupts
# nothing.
#
# < /dev/tty because a background command is given /dev/null for stdin, and a
# container client with no terminal reads EOF and exits at once.
SANDBOX_TTY_MODES="$(stty -g < /dev/tty 2>/dev/null)" || SANDBOX_TTY_MODES=""
if [ -n "$SANDBOX_TTY_MODES" ]; then
"$SCRIPT_DIR/dejima-tty-suspend" &
SANDBOX_TTY_SUSPEND_PID=$!
trap '' TSTP
docker compose -f "$COMPOSE_FILE" run --rm ${extra_mounts[@]+"${extra_mounts[@]}"} sandbox "$@" < /dev/tty &
SANDBOX_CLIENT_PID=$!
trap - TSTP
# The pid $! names is the docker CLI; `docker compose run` then runs the
# compose plugin as a child of it, and the child is what holds the
# terminal. Stopping only the pid leaves that child in raw mode with the
# shell also on the terminal - a cursorless prompt that resumes to
# nothing, which is what the first version of this fix produced.
#
# Deepest first, so nothing is left running long enough to fork again;
# shallowest first on the way back, so a parent is ready before its child
# resumes.
_stop_tree() {
local p="$1" kid
for kid in $(pgrep -P "$p" 2>/dev/null); do _stop_tree "$kid"; done
kill -STOP "$p" 2>/dev/null || true
}
_cont_tree() {
local p="$1" kid
kill -CONT "$p" 2>/dev/null || true
for kid in $(pgrep -P "$p" 2>/dev/null); do _cont_tree "$kid"; done
}
_on_tstp() {
_stop_tree "$SANDBOX_CLIENT_PID"
# Show the cursor again. A full-screen harness hides it (DECTCEM,
# \033[?25l) and puts it back when it exits - but a suspend is not an
# exit, so the shell prompt inherits an invisible cursor and the
# terminal looks like a connection that died. The harness hides it
# again on resume, when the SIGWINCH below makes it repaint.
printf '\033[?25h' > /dev/tty 2>/dev/null || true
# Default disposition to actually stop, then put the handler back for
# the next time. Everything after the re-raise runs on resume.
trap - TSTP
kill -TSTP $$
trap _on_tstp TSTP
_cont_tree "$SANDBOX_CLIENT_PID"
}
trap _on_tstp TSTP
# Looped because `wait` returns early when a trap runs, which is exactly
# what happens on every suspend.
while kill -0 "$SANDBOX_CLIENT_PID" 2>/dev/null; do
if wait "$SANDBOX_CLIENT_PID"; then
compose_status=0
else
compose_status=$?
fi
# 128+n means a signal interrupted the wait rather than the client
# exiting; anything else is the client's own status.
case "$compose_status" in
146|148) continue ;;
esac
break
done
trap - TSTP
else
# No terminal - a CI job, or output redirected. Nothing to suspend, and
# /dev/tty would not open.
docker compose -f "$COMPOSE_FILE" run --rm ${extra_mounts[@]+"${extra_mounts[@]}"} sandbox "$@" || compose_status=$?
fi
if [ -n "${SANDBOX_ANTHROPIC_PROXY_PID:-}" ] && ! kill -0 "$SANDBOX_ANTHROPIC_PROXY_PID" 2>/dev/null; then
echo "Warning: the credential proxy exited before the session ended - API calls in" >&2
echo "the session may have been failing. Check for proxy errors in the output above." >&2
+160 -7
View File
@@ -17,6 +17,46 @@
# the thing running right now". Pass --container as well to do both.
set -euo pipefail
# --help before anything else runs: no libraries sourced, no config read. The
# moment someone reaches for it is often the moment the config is wrong.
# stdout and exit 0, where usage() stays on stderr and exits nonzero.
case "${1:-}" in
-h|--help)
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>
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.
--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.
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
printed if it is not, so a typo or a blocked port does not look like success.
See docs/configuration.md; docs/security.md explains what the allowlist bounds.
DEJIMA_ALLOW_HELP
exit 0 ;;
esac
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=dejima-project-lib.sh
source "$SCRIPT_DIR/dejima-project-lib.sh"
@@ -26,6 +66,11 @@ source "$SCRIPT_DIR/dejima-project-lib.sh"
# reason init-firewall.sh and dns-proxy.sh share this file.
# shellcheck source=../docker/fixed-domains.sh
source "$SCRIPT_DIR/../docker/fixed-domains.sh"
# The palette, heading and prompt dejima-attach uses. Picking one session out
# of several is the same job here, and an operator holding one terminal should
# not meet two different answers to it.
# shellcheck source=dejima-tui-lib.sh
source "$SCRIPT_DIR/dejima-tui-lib.sh"
usage() {
echo "Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>" >&2
@@ -94,25 +139,133 @@ fi
# This is the only thing that decides. Deliberately not an environment
# variable as well: a firewall CLI with an environment-triggered no-op path
# is one an env var can silently disarm.
# A container list an operator can act on without asking the agent anything.
#
# The name alone is a run id, which is exactly what someone with three
# sessions open does not know. What they do know is which project each one is
# working on, and - where remote access is on - the number the dejima-attach
# picker gives it, because that is the list they are already reading.
#
# The project comes from the container's own working directory rather than
# from the session registry, so it is there for every session and not only a
# remote-enabled one. The picker number comes from `dejima-attach --list`,
# whose rows the picker numbers 1..N in the order they are printed; absent
# where remote access is off, which is a column missing rather than a failure.
#
# Reads only. Nothing here decides which container is acted on - that is the
# caller's --container, or the single running one.
# Fills CT_IDS, CT_NAMES, CT_PROJECTS and CT_ATTACH, in the order docker
# listed them, and measures the widest project and name for the renderers
# below. Padding to the widest value rather than to a guess is not cosmetic
# here: "dejima-sandbox-run-" plus twelve hex digits is 31 characters, so a
# fixed 28 ran every name straight into the project beside it and there was
# no telling where one ended.
CT_IDS=(); CT_NAMES=(); CT_PROJECTS=(); CT_ATTACH=()
CT_W_NAME=0; CT_W_PROJECT=0; CT_HAS_ATTACH=0
collect_containers() {
local listing="$1" name id project row n=0 idx
local -A attach_index=()
while IFS=$'\t' read -r _ _ _ _ _ row; do
[ -n "$row" ] || continue
n=$((n + 1))
attach_index["$row"]="$n"
done < <(dejima-attach --list 2>/dev/null || true)
while IFS=$'\t' read -r name id; do
[ -n "$name" ] || continue
project="$(docker inspect -f '{{.Config.WorkingDir}}' "$id" 2>/dev/null)" || project=""
[ -n "$project" ] && project="$(basename "$project")"
project="${project:-?}"
idx="${attach_index[${id:0:12}]:-}"
CT_IDS+=("$id"); CT_NAMES+=("$name"); CT_PROJECTS+=("$project")
CT_ATTACH+=("$idx")
[ -z "$idx" ] || CT_HAS_ATTACH=1
[ "${#name}" -le "$CT_W_NAME" ] || CT_W_NAME="${#name}"
[ "${#project}" -le "$CT_W_PROJECT" ] || CT_W_PROJECT="${#project}"
done <<< "$listing"
}
# The non-interactive form: a list to read, with the flag that acts on a row.
# This is what a script gets, and what an agent is shown when it asks the
# operator to run one of these.
describe_containers() {
local i
for i in "${!CT_IDS[@]}"; do
printf ' %-*s %-*s %s\n' \
"$CT_W_PROJECT" "${CT_PROJECTS[$i]}" \
"$CT_W_NAME" "${CT_NAMES[$i]}" \
"${CT_ATTACH[$i]:+(dejima-attach ${CT_ATTACH[$i]})}"
done
}
# The interactive form. Same rows, numbered, under dejima-attach's heading -
# an operator with several sessions open is choosing between the same things
# in both tools, and picking by project is the only way they can.
#
# Headed, where dejima-attach's rows are not: there the coloured state word
# says what each column is, and vertical space on a phone is the scarce
# thing. Here the columns are two names and a number, which say nothing about
# themselves.
pick_container() {
local i
tui_banner 'running sessions'
# The attach column is absent, not blank, when no session has a number -
# remote access off is the ordinary case, and a labelled empty column
# reads as something that failed.
if [ "$CT_HAS_ATTACH" -eq 1 ]; then
printf ' %s%-*s %-*s %s%s\n' "$C_SUBTLE" \
"$CT_W_PROJECT" "project" "$CT_W_NAME" "container" "attach" "$C_OFF"
else
printf ' %s%-*s %s%s\n' "$C_SUBTLE" \
"$CT_W_PROJECT" "project" "container" "$C_OFF"
fi
for i in "${!CT_IDS[@]}"; do
printf ' %s%2d%s %s%-*s%s %s%-*s%s %s%s%s\n' \
"$C_WARN" "$((i + 1))" "$C_OFF" \
"$C_BOLD$C_TEXT" "$CT_W_PROJECT" "${CT_PROJECTS[$i]}" "$C_OFF" \
"$C_LINK" "$CT_W_NAME" "${CT_NAMES[$i]}" "$C_OFF" \
"$C_MUTED" "${CT_ATTACH[$i]}" "$C_OFF"
done
tui_pick "${#CT_IDS[@]}" "allow $domain in"
container="${CT_NAMES[$((TUI_CHOICE - 1))]}"
}
session_wanted=1
if [ -n "$project" ] || [ "$global" -eq 1 ]; then
[ -n "$container" ] || session_wanted=0
fi
if [ "$session_wanted" -eq 1 ]; then
if [ -z "$container" ]; then
containers=$(docker ps --filter "label=com.docker.compose.project=dejima" --filter "status=running" --format '{{.Names}}')
containers=$(docker ps --filter "label=com.docker.compose.project=dejima" \
--filter "status=running" --format '{{.Names}} {{.ID}}')
count=$(echo "$containers" | grep -c . || true)
if [ "$count" -eq 0 ]; then
echo "No running dejima container found." >&2
exit 1
elif [ "$count" -gt 1 ]; then
echo "Multiple running dejima containers found; specify one with --container:" >&2
echo "$containers" >&2
echo "(a sandboxed agent's own container ID is printed in its startup context," >&2
echo "or by 'cat /etc/hostname' inside that sandbox - don't let it guess one)" >&2
exit 1
collect_containers "$containers"
# A person at a terminal gets to choose; anything else gets the
# list and the flag. Both ends are checked because the prompt is
# useless without somewhere to draw it and something to read the
# answer from, and half of either is how a script ends up hung on
# a read nobody can answer.
if [ -t 0 ] && [ -t 1 ]; then
pick_container
else
echo "Multiple running dejima containers found; specify one with --container:" >&2
echo "" >&2
describe_containers >&2
echo "" >&2
echo "(a sandboxed agent's own container ID is printed in its startup context," >&2
echo "or by 'cat /etc/hostname' inside that sandbox - don't let it guess one)" >&2
exit 1
fi
fi
container="$containers"
# Only when nothing above chose one. This line used to be reachable
# only in the single-container case, because the multi-container
# branch exited - now that the picker returns from it, an
# unconditional assignment here would silently discard the choice and
# act on whichever session docker happened to list first.
[ -n "$container" ] || container="${containers%%$'\t'*}"
fi
# The DNS proxy (dns-proxy.sh) NXDOMAINs anything not in its allowlist, so
+43 -76
View File
@@ -11,6 +11,37 @@
# --list one tab-separated row per live session, for scripting
set -euo pipefail
# --help before anything else runs: no libraries sourced, no config read. The
# moment someone reaches for it is often the moment the config is wrong.
# stdout and exit 0, where usage() stays on stderr and exits nonzero.
case "${1:-}" in
-h|--help)
cat <<'DEJIMA_ATTACH_HELP'
dejima-attach - list live dejima sessions and attach to one
Usage: dejima-attach [run-id | --list]
(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
--list one tab-separated row per live session, for scripting
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
terminal can reach are the same thing described once.
Attaching gives a tmux session on the HOST, not a shell inside the container.
That is deliberate - it is how an operator watches or steers a session - but it
means the tmux prefix key opens a window outside the sandbox, so exposing it is
exposing host access.
A session appears here only while its container is running.
See docs/remote-access.md.
DEJIMA_ATTACH_HELP
exit 0 ;;
esac
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=dejima-project-lib.sh
source "$SCRIPT_DIR/dejima-project-lib.sh"
@@ -26,55 +57,20 @@ SESSIONS_DIR="$STATE_DIR/sessions"
# gruvbox dark, every value upstream's and tagged with its color.table row:
# https://github.com/gruvbox-community/gruvbox-contrib/blob/master/color.table
# The names are carried so a wrong hex is catchable by reading.
#
# Truecolor only: a terminal without it, or tmux without `Tc`, approximates
# 38;2 to its nearest colour rather than printing the escape, so the fallback
# already exists one layer down.
#
# Empty unless stdout is a colour-capable terminal, which keeps --list a
# machine interface (ttyd feeds run ids back in) and is also how the bats
# suite reads this script.
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-dumb}" != dumb ]; then
C_OFF=$'\033[0m'; C_BOLD=$'\033[1m'
C_BADGE=$'\033[38;2;254;128;25m' # bright_orange #fe8019, the wordmark
C_TEXT=$'\033[38;2;235;219;178m' # light1 #ebdbb2
C_MUTED=$'\033[38;2;146;131;116m' # gray_245 #928374
C_SUBTLE=$'\033[38;2;124;111;100m' # dark4 #7c6f64
C_RULE=$'\033[38;2;80;73;69m' # dark2 #504945, separator
C_OK=$'\033[38;2;184;187;38m' # bright_green #b8bb26, success
C_WARN=$'\033[38;2;250;189;47m' # bright_yellow #fabd2f, warning
C_ERR=$'\033[38;2;251;73;52m' # bright_red #fb4934, error
C_LINK=$'\033[38;2;131;165;152m' # bright_blue #83a598
GLYPH_BAR='▏'; GLYPH_DOT='●'
# A literal rather than a repeat of GLYPH_RULE: `tr` is byte-oriented and
# would shred a 3-byte box-drawing character, and every alternative costs
# a loop or a subshell to draw a fixed line. Both are the width of the
# heading they sit under ("dejima | live sessions"), and are next to it so
# they stay that way.
RULE_LINE='──────────────────────'
else
C_OFF=""; C_BOLD=""; C_BADGE=""; C_TEXT=""; C_MUTED=""; C_SUBTLE=""
C_RULE=""; C_OK=""; C_WARN=""; C_ERR=""; C_LINK=""
# Glyphs ride the same switch rather than a separate UTF-8 probe. Not
# because no-colour implies no-UTF-8, but because the two terminals this
# has to stay readable on - a $TERM the host has no entry for, and a pipe -
# are exactly the ones that mangle box drawing, and one flag is easier to
# reason about than two.
GLYPH_BAR='|'; GLYPH_DOT='*'
RULE_LINE='----------------------'
fi
# The palette, the heading and the prompt come from the shared library: this
# and dejima-allow both ask an operator to pick a session, and two copies of
# that would drift apart on the terminal an operator is actually holding.
# shellcheck source=dejima-tui-lib.sh
source "$SCRIPT_DIR/dejima-tui-lib.sh"
# Narrow deliberately. The phone is this script's main front door, and the
# rule underlines the heading instead of spanning the table because the
# table's width follows the longest project name - a rule that chased it would
# wrap on a phone exactly when the rows did.
banner() {
printf '\n %s%sdejima%s %s%s%s %slive sessions%s\n' \
"$C_BOLD" "$C_BADGE" "$C_OFF" "$C_SUBTLE" "$GLYPH_BAR" "$C_OFF" \
"$C_MUTED" "$C_OFF"
printf ' %s%s%s\n\n' "$C_RULE" "$RULE_LINE" "$C_OFF"
}
# Cleared first: this is a front door, and it is navigated to rather than
# scrolled to. dejima-allow's picker shares the banner and deliberately not
# this - see tui_clear.
banner() { tui_clear; tui_banner 'live sessions'; }
# Coloured by meaning, not by name: attachable is the one an operator wants to
# find, and stale/no-tmux are the two that will refuse if picked.
@@ -232,7 +228,7 @@ list_sessions() {
# ever race it. Removing it under a live session is not a leak
# being tidied but a session being broken - Docker recreates the
# bind source root-owned, hooks/dejima-notify.sh then fails on
# every Notification and Stop for the rest of the run, and nothing
# every notification for the rest of the run, and nothing
# rewrites the registry entry to make the session visible again.
continue
elif [ "$rc" -ne 0 ]; then
@@ -407,37 +403,8 @@ picker() {
"$sc" "$w_state" "${states[$i]}" "$C_OFF" \
"$C_SUBTLE" "${containers[$i]}" "$C_OFF"
done
# Numbered rather than arrow-driven: phone soft keyboards handle digits
# far better than arrow keys, and this is the phone's entry point.
printf '\n %sattach to%s %s[1-%d, q to quit]%s: ' \
"$C_MUTED" "$C_OFF" "$C_TEXT" "${#ids[@]}" "$C_OFF"
# `|| exit 0`, because a read that cannot happen is not a refusal: with no
# controlling terminal (`ssh -T`, or an SSH app that requests no pty) this
# returns nonzero immediately, and exiting 1 with nothing printed reads as
# a broken front door. attach() reasons about the same case explicitly.
read -r choice || exit 0
case "$choice" in
q|Q|'') exit 0 ;;
*[!0-9]*) echo "Not a number." >&2; exit 1 ;;
esac
# Normalized to base 10 once, here, because the range check and the
# subscript below do not read a number the same way: `[` is POSIX test and
# decimal-only, while $(( )) reads a leading zero as OCTAL. Without this,
# "010" - which a phone keypad makes easy to type - passes the check as 10
# and indexes as 8, and the caller is attached to a DIFFERENT session than
# the one they picked, with nothing printed to say so.
#
# It fixes the two ugly shapes as well. "09" is a value here rather than
# the "invalid octal constant" arithmetic error it is to $(( )); and a
# number past int64 wraps to one the range check can answer for, where
# `[ 99999999999999999999999 -gt 11 ]` instead exits 2 with "integer
# expression expected" - which makes the `if` false, so "Out of range."
# is never printed and the subscript below dies on an unbound element.
choice="$((10#$choice))"
if [ "$choice" -lt 1 ] || [ "$choice" -gt "${#ids[@]}" ]; then
echo "Out of range." >&2
exit 1
fi
tui_pick "${#ids[@]}" 'attach to'
choice="$TUI_CHOICE"
attach "${ids[$((choice - 1))]}"
}
+267 -36
View File
@@ -8,6 +8,45 @@
# `claude` in that repo again. See docs/security.md.
set -uo pipefail
# --help before anything else runs: no libraries sourced, no config read. The
# moment someone reaches for it is often the moment the config is wrong.
# stdout and exit 0, where usage() stays on stderr and exits nonzero.
case "${1:-}" in
-h|--help)
cat <<'DEJIMA_AUDIT_HELP'
dejima-audit - report what a session changed outside its workspace
Usage: dejima-audit <snapshot|compare|show|ack> [workspace] [state-file]
snapshot record the current state of everything watched
compare compare against the last snapshot and file a report
show [--full] index of reports waiting to be read; --full prints one
ack walk a report finding by finding and record a decision
([o] at the prompt opens the file in $VISUAL/$EDITOR)
--print-paths the resolved store paths, one per line, for scripts
workspace defaults to the enclosing git repository, else $PWD.
snapshot and compare are run by bin/dejima around a session, not usually by
hand. show and ack are for reading what they found.
What is watched is the part a container boundary cannot protect: dejima's own
trusted code, the harness configuration a later host session would execute,
and anything else on the host a session could have reached. A finding is
CRITICAL when it names something that runs unsandboxed on the host.
ack records a decision per finding, so an approved change stops being reported
until the thing it approved changes again. Acknowledging is per value, not per
path: the same file with different content is a new finding.
Reports and baselines live under ~/.local/share/dejima, outside every mount, so
a session cannot read what was concluded about it or edit what is pending.
See docs/security.md.
DEJIMA_AUDIT_HELP
exit 0 ;;
esac
# shellcheck source=dejima-provider-lib.sh
source "$(dirname "$(readlink -f "$0")")/dejima-provider-lib.sh"
# shellcheck source=dejima-color-lib.sh
@@ -105,17 +144,78 @@ report_key() {
printf '%s' "${b%-CRITICAL.txt}.txt" | sed 's/\.txt\.txt$/.txt/'
}
# How many reports are on disk regardless of whether anything is outstanding.
# The two numbers answer different questions and both get asked: an operator
# told "no reports" while looking at a directory of them is being told
# something false, even when nothing there wants a decision.
count_reports_total() {
find "$1" -maxdepth 1 -name '*.txt' 2>/dev/null | wc -l
}
# A report walked to its end is done with, and stays on disk as the record of
# what that session did - rotation evicts it, not `ack`. Everything below
# therefore has to distinguish "on disk" from "still wants a decision", or the
# operator is told work is waiting that no keystroke can clear. That was #195:
# five reports waiting, "nothing new - 32 already dealt with", forever, with
# the other four unreachable because selection kept returning the same one.
report_dealt_with() {
baseline_reviewed "$(report_key "$1")"
}
# Reports that still want a decision, oldest first. The caller gets a list
# rather than one path so counting and choosing cannot disagree about what is
# outstanding.
outstanding_reports() {
local dir="$1" r
while IFS= read -r r; do
[ -n "$r" ] || continue
report_dealt_with "$r" || printf '%s\n' "$r"
done < <(find "$dir" -maxdepth 1 -name '*.txt' 2>/dev/null | sort)
}
count_reports() {
local dir="$1"
printf '%s %s\n' \
"$(find "$dir" -maxdepth 1 -name '*.txt' 2>/dev/null | wc -l)" \
"$(find "$dir" -maxdepth 1 -name '*-CRITICAL.txt' 2>/dev/null | wc -l)"
local dir="$1" all crit r
all=0; crit=0
while IFS= read -r r; do
[ -n "$r" ] || continue
all=$((all + 1))
case "$r" in *-CRITICAL.txt) crit=$((crit + 1)) ;; esac
done < <(outstanding_reports "$dir")
printf '%s %s\n' "$all" "$crit"
}
# Like select_report, but skipping whatever this run has already been
# through. A report can be left outstanding on purpose - the operator skipped
# a finding in it, and skipping means ask again next time - and without this
# the run would be handed that same report back, decide it is not a new one,
# and stop, leaving every report behind it unread. That is the shape of #195
# again, one step along: not "cannot retire" but "cannot move on".
#
# Reads the walked_this_run array the ack loop maintains; harmless where that
# array does not exist, which is every other caller.
select_next_report() {
local dir="$1" r out unwalked=""
out="$(outstanding_reports "$dir")"
[ -n "$out" ] || return 1
while IFS= read -r r; do
[ -n "$r" ] || continue
[ -n "${walked_this_run[$r]:-}" ] && continue
unwalked="${unwalked}${unwalked:+$'\n'}$r"
done <<< "$out"
[ -n "$unwalked" ] || return 1
r="$(printf '%s\n' "$unwalked" | grep -- '-CRITICAL\.txt$' | head -1)"
[ -n "$r" ] || r="$(printf '%s\n' "$unwalked" | tail -1)"
printf '%s\n' "$r"
}
select_report() {
local dir="$1" r
r="$(find "$dir" -maxdepth 1 -name '*-CRITICAL.txt' 2>/dev/null | sort | head -1)"
[ -n "$r" ] || r="$(find "$dir" -maxdepth 1 -name '*.txt' 2>/dev/null | sort | tail -1)"
local dir="$1" r out
out="$(outstanding_reports "$dir")"
[ -n "$out" ] || return 1
# An unacknowledged CRITICAL first, whatever its age, then the newest of
# the rest - the same order as before, over the outstanding ones only.
r="$(printf '%s\n' "$out" | grep -- '-CRITICAL\.txt$' | head -1)"
[ -n "$r" ] || r="$(printf '%s\n' "$out" | tail -1)"
[ -n "$r" ] || return 1
printf '%s\n' "$r"
}
@@ -692,17 +792,9 @@ id_severity() {
esac
}
# Every finding interpolates workspace-controlled text (paths, config values)
# into a terminal. A planted value containing C0 escapes can scroll, clear or
# overwrite the report; C1 controls and Unicode bidi overrides/isolates
# (U+202A-U+202E, U+2066-U+2069) can visually reverse or reorder the
# displayed text without touching the bytes an operator would grep for - so
# strip all of these at the display boundary, after all parsing and
# TAB-splitting is done.
sanitize() {
printf '%s' "$1" | LC_ALL=C tr -d '\000-\037\177' \
| LC_ALL=C sed $'s/\xc2[\x80-\x9f]//g; s/\xe2\x80[\xaa-\xae]//g; s/\xe2\x81[\xa6-\xa9]//g'
}
# sanitize() and sanitize_body() come from dejima-color-lib.sh - every finding
# here interpolates workspace-controlled text (paths, config values) into a
# terminal, and so does every epitaph, so the rule has one definition.
# sanitize() is lossy, so an ID must never pass through it. `ack` derives the
# baseline id from the report text and the next `compare` matches it against
@@ -874,7 +966,11 @@ tcb_snapshot() {
# here. Both decide what the next launch permits, which is what the
# TCB list is for. A visible edit stays a normal reviewable diff - the
# split above only reports what git cannot see.
for dir in bin hooks docker test forges workflows harnesses; do
# tools/ for the same reason as the provider directories, and it was
# missed when that layer landed: a tool.conf names a tarball URL and
# the sha256 checked against it, and the host's image build installs
# what it points at as root.
for dir in bin hooks docker test forges workflows harnesses tools; do
if [ -d "$ws/$dir" ]; then
while IFS= read -r -d '' f; do
stage_entry "tcb:${f#"$ws"/}" "$f"
@@ -1044,6 +1140,46 @@ show_capped() {
fi
}
# The file a finding is about, as it is on disk now, or empty when the finding
# is not about a file at all - a git config key has no path, and a surface that
# has since been removed has nothing to open.
#
# Deliberately the current file rather than the text the report captured: the
# question `ack` asks is about what is on the host now, and an approval pins
# the content that is there at the moment it is recorded. Reading a stale copy
# and approving would be approving something nobody read.
path_for_id() {
local id="$1" f _hp
if _hp="$(host_exec_path_for_id "$id")"; then
f="$_hp"
else
case "$id" in
gitconfig:*) return 1 ;;
tcb:*) f="$ws/${id#tcb:}" ;;
*) f="$ws/$id" ;;
esac
fi
[ -f "$f" ] || return 1
printf '%s\n' "$f"
}
# The operator's own editor, read-only where the editor understands how.
#
# Read-only is the default because the file being opened is the thing under
# audit: editing it mid-decision changes what an approval would then pin, and
# an approval recorded against content the session did not write is a worse
# record than no approval. -R is only added for the vi family, whose spelling
# of it is known; anything else is run as the operator configured it.
open_in_editor() {
local file="$1" ed base
ed="${VISUAL:-${EDITOR:-vi}}"
base="$(basename "${ed%% *}")"
case "$base" in
vi|vim|nvim|view) "$ed" -R "$file" <&3 || true ;;
*) "$ed" "$file" <&3 || true ;;
esac
}
# Where the operator can read the whole thing, when the display was capped.
# Never sanitized through a path that would make it unfindable: it is the
# audit's OWN text about a location, not workspace-controlled content.
@@ -1498,8 +1634,17 @@ case "$cmd" in
show)
color_init 1
report_dir="$AUDIT_DIR/$(ws_slug "$ws")"
report="$(select_report "$report_dir")" \
|| { echo "dejima audit: no reports for $ws" >&2; exit 0; }
report="$(select_report "$report_dir")" || {
_total="$(count_reports_total "$report_dir")"
if [ "$_total" -gt 0 ]; then
printf 'dejima audit: nothing waiting for %s.\n' "$ws" >&2
printf ' %s report(s) kept in %s as the record of what those sessions did.\n' \
"$_total" "$report_dir" >&2
else
echo "dejima audit: no reports for $ws" >&2
fi
exit 0
}
# Default is an index, not a dump. `cat` of one report was a wall of
# text that answered neither question an operator actually has - how
# much is waiting, and which of it still needs a decision - and it
@@ -1537,8 +1682,16 @@ case "$cmd" in
color_init 1
report_dir="$AUDIT_DIR/$(ws_slug "$ws")"
bf="$BASELINE_DIR/$(ws_slug "$ws")"
report="$(select_report "$report_dir")" \
|| { echo "dejima audit: no reports for $ws" >&2; exit 0; }
report="$(select_report "$report_dir")" || {
_total="$(count_reports_total "$report_dir")"
if [ "$_total" -gt 0 ]; then
printf 'dejima audit: nothing waiting - every finding in %s report(s) has been dealt with.\n' \
"$_total" >&2
else
echo "dejima audit: no reports for $ws" >&2
fi
exit 0
}
read -r n_all n_crit <<< "$(count_reports "$report_dir")"
if [ "$n_all" -gt 1 ]; then
printf '%s reports waiting' "$n_all"
@@ -1546,10 +1699,25 @@ case "$cmd" in
"$C_CRIT" "$n_crit" "$C_RESET"
printf '.\n\n'
fi
# What has already been put in front of the operator during THIS
# invocation, keyed by surface and the content that is on disk now.
#
# Reports overlap: the same file can appear in several, and `ack`
# walks them in one run. Once its current content has been shown, an
# older report asking about the same file is asking about a version
# that no longer exists - the operator has seen what is there and
# decided, or declined to. Approving covers this through the baseline
# already; skipping does not, and that is the case this handles.
declare -A shown_this_run=()
# Reports this invocation has already walked, so it moves on rather
# than being handed one of them again.
declare -A walked_this_run=()
# One report per pass, every report in one invocation. Walking one and
# exiting meant clearing a backlog took as many runs as there were
# reports, with nothing on screen to say another was behind it.
while :; do
walked_this_run["$report"]=1
sc="$(sidecar_dir "$report")"
# Removed only at the end, and only for findings actually
# acknowledged - an abort must leave the operator exactly where they
@@ -1572,6 +1740,11 @@ case "$cmd" in
# Like unackable_shown, it means the report has been dealt with as far
# as it can be, so it earns a `reviewed` marker.
removed_cleared=0
# Findings the operator chose to be asked about again, and un-ackable
# ones that were shown but could not be recorded either way. The first
# keeps the report outstanding; the second must not, or it re-prompts
# forever - which is the distinction the reviewed marker turns on.
left_undecided=0
# Findings passed over because the baseline already covers them, or
# because this report has been walked before - counted so a run that
# displays nothing can say why instead of looking broken.
@@ -1712,6 +1885,23 @@ case "$cmd" in
if [ "$tier" = CRITICAL ]; then crit_acked=$((crit_acked + 1)); fi
continue
fi
# Already shown this run, for the content that is on disk now.
# Counted as dealt with rather than silently dropped, so a report
# that displays nothing still says why.
#
# crit_acked is deliberately NOT advanced: nothing was approved,
# so a report whose CRITICAL was only superseded must keep its
# -CRITICAL.txt. And left_undecided is set, so this report is not
# retired on the strength of a question that was never answered -
# it comes back on the next run, where the operator meets it once.
if [ "$ackable" -eq 1 ]; then
_seen_key="$rid"$'\t'"$(current_sha_for_id "$rid")"
if [ -n "${shown_this_run[$_seen_key]:-}" ]; then
already=$((already + 1))
left_undecided=1
continue
fi
fi
# A finding cleared on an earlier pass because its surface was
# gone. Nothing was recorded for it - there is no content to pin -
# so baseline_suppresses cannot cover it, and it is ackable, so
@@ -1744,7 +1934,10 @@ case "$cmd" in
else
printf ' %s[%s]%s %s\n' "$(tier_color "$tier")" "$tier" "$C_RESET" "$body"
fi
[ "$ackable" -eq 1 ] && render_finding "$rid" "$sc"
if [ "$ackable" -eq 1 ]; then
render_finding "$rid" "$sc"
shown_this_run["$rid"$'\t'"$(current_sha_for_id "$rid")"]=1
fi
# Each option says what it does to FUTURE findings, because that
# is the whole difference between the two that record something
# and it was not visible: approve pins this exact content and
@@ -1761,16 +1954,46 @@ case "$cmd" in
esac
k_a="${C_KEY}[a]$C_RESET"; k_i="${C_KEY}[i]$C_RESET"
k_s="${C_KEY}[s]$C_RESET"; k_q="${C_KEY}[q]$C_RESET"
k_o="${C_KEY}[o]$C_RESET"
# Offered only where there is a file to open. The report shows the
# part that changed, which is chosen by what changed and not by
# what a person needs in order to judge it - three changed lines in
# a hook script are only judgeable by reading the script.
open_path="$(path_for_id "$rid")" || open_path=""
[ "$ackable" -eq 1 ] || open_path=""
open_hint=""
[ -n "$open_path" ] && open_hint=" $k_o open the file"
# Looped, so opening the file returns to the same decision rather
# than counting as one.
key=""
while :; do
if [ "$ackable" -ne 1 ]; then
printf ' %s skip %s quit (this finding cannot be acknowledged): ' "$k_s" "$k_q"
elif [ "$tier" = CRITICAL ]; then
printf ' %s approve this exact content %s skip (ask again next time) %s quit: ' \
"$k_a" "$k_s" "$k_q"
printf ' %s approve this exact content%s %s skip (ask again next time) %s quit: ' \
"$k_a" "$open_hint" "$k_s" "$k_q"
else
printf ' %s approve this exact content %s ignore this %s from now on %s skip (ask again next time) %s quit: ' \
"$k_a" "$k_i" "$ign_noun" "$k_s" "$k_q"
printf ' %s approve this exact content%s %s ignore this %s from now on %s skip (ask again next time) %s quit: ' \
"$k_a" "$open_hint" "$k_i" "$ign_noun" "$k_s" "$k_q"
fi
{ read -r key <&3; } 2>/dev/null || { aborted=1; break; }
{ read -r key <&3; } 2>/dev/null || { aborted=1; break 2; }
case "$key" in
o)
if [ -z "$open_path" ]; then
echo " nothing to open: this finding is not about a file that exists."
continue
fi
open_in_editor "$open_path"
# Re-read after the editor: the content may have moved on,
# and what gets approved below is whatever is there when
# the key is pressed, not what was rendered above.
render_finding "$rid" "$sc"
shown_this_run["$rid"$'\t'"$(current_sha_for_id "$rid")"]=1
continue ;;
esac
break
done
[ "$aborted" -eq 1 ] && break
case "$key" in
q|$'\e'|'') aborted=1; break ;;
i)
@@ -1783,7 +2006,7 @@ case "$cmd" in
sc_done+=("$rid")
fi
;;
s) ;;
s) [ "$ackable" -eq 1 ] && left_undecided=1 ;;
a)
if [ "$ackable" -ne 1 ]; then
echo " cannot acknowledge: this finding cannot be acknowledged."
@@ -1826,11 +2049,19 @@ case "$cmd" in
echo ""; echo "dejima audit: nothing recorded."
exit 0
fi
# Written once the report has been walked to its end, and only when
# it actually showed something that cannot be acknowledged - there is
# nothing else to stop those from re-prompting forever.
if { [ "$unackable_shown" -eq 1 ] || [ "$removed_cleared" -eq 1 ]; } \
&& [ "$report_reviewed" -eq 0 ]; then
# Written once the report has been walked to its end with nothing
# left undecided. It used to be written only for a report carrying
# something that cannot be acknowledged, which left a report whose
# findings were all approved with no record that it had been finished
# - so selection kept returning it and the ones behind it were never
# reached (#195).
#
# The undecided check is what keeps "skip (ask again next time)"
# honest. Retiring the report retires every finding in it, so a marker
# written over a skipped finding would turn that promise into "never
# ask again" - silently, and for a finding the operator had looked at
# and declined to approve.
if [ "$report_reviewed" -eq 0 ] && [ "$left_undecided" -eq 0 ]; then
pending+=("reviewed $(report_key "$report")")
fi
if [ "${#pending[@]}" -gt 0 ]; then
@@ -1873,7 +2104,7 @@ case "$cmd" in
# Next report, if there is one. Asked rather than assumed: an operator
# who has decided enough for now should not have to abort out of a
# report they have not read to stop.
next_report="$(select_report "$report_dir")" || next_report=""
next_report="$(select_next_report "$report_dir")" || next_report=""
if [ -n "$next_report" ] && [ "$next_report" != "$report" ]; then
read -r n_all n_crit <<< "$(count_reports "$report_dir")"
printf '\n%s report(s) still waiting' "$n_all"
+87 -9
View File
@@ -17,25 +17,103 @@
#
# The variables are always DEFINED, empty when colour is off, so callers
# interpolate them unconditionally and no call site needs an if.
# Session-authored text on its way to the operator's terminal. A planted value
# containing C0 escapes can scroll, clear or overwrite what is being read; C1
# controls and Unicode bidi overrides/isolates (U+202A-U+202E, U+2066-U+2069)
# reverse or reorder the displayed text without touching the bytes an operator
# would grep for. Stripped at the display boundary, after all parsing and
# TAB-splitting is done.
#
# Two forms, because the rule differs. A field is one line, so every C0 byte
# goes - including LF, which is what keeps a planted newline from forging a
# second row. A body is many lines, and applying the field rule to one would
# fold a whole epitaph onto a single line, so there LF and TAB survive and
# nothing else does. CR goes either way: it is the overwrite-this-line trick.
_sanitize_c1_bidi() {
LC_ALL=C sed $'s/\xc2[\x80-\x9f]//g; s/\xe2\x80[\xaa-\xae]//g; s/\xe2\x81[\xa6-\xa9]//g'
}
sanitize() {
printf '%s' "$1" | LC_ALL=C tr -d '\000-\037\177' | _sanitize_c1_bidi
}
# Reads the text on stdin rather than taking it as an argument: the callers
# are whole files, and a body large enough to be worth keeping is not worth
# passing through argv.
sanitize_body() {
LC_ALL=C tr -d '\000-\010\013-\037\177' | _sanitize_c1_bidi
}
# One palette, and it is the terminal's own.
#
# gruvbox is dejima's brand, and the values below ARE gruvbox on a gruvbox
# terminal - every colour here is an ANSI slot that a gruvbox theme fills with
# the exact hex the marks use (bright green #b8bb26, bright yellow #fabd2f,
# bright red #fb4934, bright blue #83a598, bright black #928374). What it does
# not do is hardcode them.
#
# It used to, in dejima-tui-lib.sh, and that broke on any terminal whose
# background dejima does not control. Measured against a light background:
# light1 #ebdbb2 - the colour the session picker drew project names in - came
# out at 1.21:1, bright yellow at 1.49:1, bright green at 1.82:1. Nothing in
# the palette cleared 3:1 on both a dark and a light background, because a
# palette drawn for #282828 cannot.
#
# An indexed colour cannot have that problem: the theme's author chose it
# against their own background. So dejima gets its brand on the terminals set
# up for it, and legibility on the rest, and those are the same decision.
#
# The one place dejima keeps exact hexes is web/src/page.css, where it sets
# the background itself and so owns both sides of the contrast.
#
# Empty unless the fd is a colour-capable terminal, which keeps machine
# interfaces (dejima-attach --list, which ttyd feeds run ids back from)
# parseable and is how the bats suite reads these scripts.
color_init() {
local fd="${1:-1}"
C_RESET=""; C_CRIT=""; C_WARN=""; C_INFO=""; C_PATH=""
C_KEY=""; C_DIM=""; C_ADD=""; C_DEL=""; C_OK=""
C_OFF=""; C_BOLD=""; C_BADGE=""; C_TEXT=""; C_MUTED=""; C_SUBTLE=""
C_RULE=""; C_ERR=""; C_LINK=""
# Glyphs ride the same switch rather than a separate UTF-8 probe. Not
# because no-colour implies no-UTF-8, but because the two terminals this
# has to stay readable on - a $TERM the host has no entry for, and a pipe -
# are exactly the ones that mangle box drawing, and one flag is easier to
# reason about than two.
GLYPH_SEP='|'; GLYPH_BAR='|'; GLYPH_DOT='*'; GLYPH_RULE='-'
[ -t "$fd" ] || return 0
[ -z "${NO_COLOR+x}" ] || return 0
case "${TERM:-}" in ''|dumb) return 0 ;; esac
C_RESET=$'\033[0m'
C_CRIT=$'\033[1;31m' # bold red - runs unsandboxed host code
C_WARN=$'\033[33m' # yellow
C_INFO=$'\033[36m' # cyan
C_PATH=$'\033[1;36m' # bold cyan - the file or key a finding is about
C_KEY=$'\033[1m' # bold - the keystroke an operator has to press
C_DIM=$'\033[2m' # dim - the audit's own framing around content
C_ADD=$'\033[32m' # green - a diff's added lines
C_DEL=$'\033[31m' # red - a diff's removed lines
C_OK=$'\033[32m' # green
C_OFF="$C_RESET" # the pickers' name for it
C_BOLD=$'\033[1m'
C_KEY="$C_BOLD" # bold - the keystroke an operator has to press
# Default foreground, deliberately empty. This was light1 #ebdbb2, which
# is gruvbox's own foreground - so on a gruvbox terminal it is what the
# terminal already draws with, and on a light one it was invisible.
C_TEXT=""
C_DIM=$'\033[2m'
C_SUBTLE="$C_DIM" # dark4 receded; dim recedes against any background
C_RULE="$C_DIM" # dark2, ditto
C_MUTED=$'\033[90m' # bright black - gruvbox gray_245 #928374
C_OK=$'\033[92m' # bright green - gruvbox #b8bb26
C_ADD="$C_OK"
C_WARN=$'\033[93m' # bright yellow - gruvbox #fabd2f
C_ERR=$'\033[91m' # bright red - gruvbox #fb4934
C_DEL="$C_ERR"
C_CRIT=$'\033[1;91m' # bold bright red - runs unsandboxed host code
C_INFO=$'\033[96m' # bright cyan - gruvbox aqua #8ec07c
C_LINK=$'\033[94m' # bright blue - gruvbox #83a598
C_PATH=$'\033[1;96m' # bold - the file or key a finding is about
# No ANSI slot is orange, and gruvbox's #fe8019 is the wordmark's colour.
# Bold bright yellow is the nearest thing every terminal has, and it is
# the accent rather than the brand exactly.
C_BADGE=$'\033[1;93m'
GLYPH_SEP='▏'; GLYPH_BAR='▏'; GLYPH_DOT='●'; GLYPH_RULE='─'
}
# The colour for a tier name, so the mapping lives in one place rather than
# at each site that prints one.
tier_color() {
+49 -3
View File
@@ -42,7 +42,11 @@ core.harness provider:harness
core.forge provider:forge none
core.workflow provider:workflow local-only
core.command ?identifier
core.identity ?identifier
core.tools ?list:identifier
core.firewall bool on
ui.banner bool on
core.update_check bool on
core.share_dir path ~/.local/share/dejima/shared
core.ssh_key ?path
core.known_hosts path ~/.ssh/known_hosts
@@ -69,6 +73,7 @@ remote-access.ttyd_port int:1:65535 7681
remote-access.ttyd_font_size ?int:8:72
remote-access.ttyd_tmux_prefix ?chord
remote-access.ttyd_focus_on_tap ?bool
remote-access.ttyd_button_colors ?bool
remote-access.debounce int:0:86400 60
EOF
}
@@ -248,8 +253,23 @@ valid_tarball_spec() {
# A declared domain has to survive being written into a dnsmasq
# server=/domain/ line. Here rather than in dejima-project-lib.sh as well, so
# there is one definition and no pair of them to drift apart.
# A hostname, label by label. The matcher is a trust boundary: what it accepts
# becomes a dnsmasq `server=` line and an ipset entry, so it should accept only
# what it means to rather than only what it can see a use for.
#
# The previous form allowed an EMPTY label - `a..b.com` matched - because its
# repeated group could consume a dot having matched no characters before it.
# Harmless in practice, since nothing that shape resolves, and fixed on the
# principle rather than on a known abuse: the published bypass in this space
# came from a matcher accepting a string the resolver read differently.
#
# Per label: 1-63 characters, starting and ending alphanumeric, hyphens in
# between. That also newly refuses a label with a leading or trailing hyphen,
# which is not a hostname either. A single label with no dot stays legal - an
# internal name resolved by the host's own resolver is a real case.
valid_domain() {
[[ "$1" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,62}\.)*[a-zA-Z0-9-]{1,63}$ ]]
local label='[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?'
[[ "$1" =~ ^$label(\.$label)*$ ]]
}
# Extension point for types this layer cannot judge on its own. A stub here,
@@ -351,6 +371,20 @@ _config_valid_value() {
identifier)
[[ "$value" =~ ^[A-Za-z0-9._-]+$ ]]
;;
text)
# A sentence rather than a token - a person's name, an email
# address, a one-line description. An identifier has no room for a
# space and a path refuses a colon, and all of those are legal
# here. Refuses only what cannot survive the trip: a newline splits
# the value where every consumer expects one line, and a control
# character reaching a terminal is the escape-sequence hazard
# docs/security.md names. Everything else is the operator's
# business, the same reading `path` takes of a space.
case "$value" in
*[$'\n\r\t']*) return 1 ;;
esac
[[ ! "$value" =~ [[:cntrl:]] ]]
;;
chord)
# A tmux control chord, as the remote terminal page's PREFIX,
# window and ZOOM buttons send. Narrower than tmux's own prefix
@@ -385,7 +419,7 @@ _config_extra_section_known() { return 1; }
_config_section_known() {
case "$1" in
core|github|mounts|credentials|logging|audit|remote-access|epitaph) return 0 ;;
core|github|mounts|credentials|logging|audit|remote-access|epitaph|ui) return 0 ;;
*) _config_extra_section_known "$1" ;;
esac
}
@@ -493,7 +527,19 @@ _config_parse() {
return 1
fi
if ! _config_valid_value "$type" "$value"; then
echo "invalid value for [$section] $key in $conf: $value" >&2
# The value is echoed so the operator can see what was wrong with
# it, except for the userinfo of a URL. A url-typed value is the
# one kind here that carries a credential - ntfy takes its
# authentication as https://<user>:<token>@<host>/<topic> - and a
# value only reaches this line by being malformed, so the mistake
# would be what wrote the token into a terminal or a journal.
# Everything after the @ is kept: the host and path are what make
# the message useful.
local shown="$value"
case "$type:$value" in
*url:*://*@*) shown="${value%%://*}://<redacted>@${value#*@}" ;;
esac
echo "invalid value for [$section] $key in $conf: $shown" >&2
return 1
fi
# Last assignment wins, which is why this overwrites rather than
+33
View File
@@ -16,6 +16,39 @@
# Usage: dejima-conversation-ship <workspace>
set -uo pipefail # not -e: a torn line or a Loki hiccup must not kill the tailer
# --help before anything else runs: no libraries sourced, no config read. The
# moment someone reaches for it is often the moment the config is wrong.
# stdout and exit 0, where usage() stays on stderr and exits nonzero.
case "${1:-}" in
-h|--help)
cat <<'DEJIMA_CONVERSATION_SHIP_HELP'
dejima-conversation-ship - ship a session's conversation to Loki
Usage: dejima-conversation-ship <workspace>
Started and stopped by bin/dejima around a session. Running it by hand is only
useful for debugging.
Ships prompts, replies and tool invocations - never tool output, which is most
of the volume and was not asked for. Runs on the host rather than in the
container, because a log of a session that the session shares a filesystem and
a signal namespace with is one it can silence.
What that does not buy is trust in the contents. The transcript is written by
the session, so this ships what was recorded without attesting to it.
Off unless [logging] loki_url is set AND [logging] conversation is on. Both are
required because session content is a different sensitivity class from the DNS
and firewall logs, which ship on loki_url alone.
Where the transcripts are, and how one becomes a log line, are the selected
harness's business; the batching and the shipping are dejima's.
See docs/configuration.md.
DEJIMA_CONVERSATION_SHIP_HELP
exit 0 ;;
esac
# shellcheck source=dejima-provider-lib.sh
source "$(dirname "$(readlink -f "$0")")/dejima-provider-lib.sh"
# shellcheck source=dejima-slug-lib.sh
+36
View File
@@ -17,6 +17,42 @@
# for a project are the union of its global and project approvals.
set -euo pipefail
# --help before anything else runs: no libraries sourced, no config read. The
# moment someone reaches for it is often the moment the config is wrong.
# stdout and exit 0, where usage() stays on stderr and exits nonzero.
case "${1:-}" in
-h|--help)
cat <<'DEJIMA_DEP_HELP'
dejima-dep - approve a package a project needs, so its image carries it
Usage: dejima-dep <add|remove|list> [--global | --project [path]] [<spec>]
add <spec> approve it; the image is rebuilt with it at the next launch
remove <spec> withdraw the approval
list show what is approved in the scope given
Scope:
--project [path] one project, every session in it. Path defaults to $PWD.
--global every project on this host.
(none) same as --project for the current directory.
A spec is <apt|npm|pip|cargo>:<name>, optionally @<version>, or a release
archive as tarball:<name>@<sha256>:<url>. The digest is required and checked at
build time, because a tarball is code that will run in every future session.
Approving is a separate act from declaring. A project declares what it needs in
.dejima/dependencies, which is inside the workspace and therefore writable by
the session; nothing is installed until an operator approves it here. Use
`dejima-project status` to see what a project is asking for.
Nothing is installed into a running container: a rebuild happens at the next
launch, so an approval made mid-session takes effect on the one after it.
See docs/configuration.md.
DEJIMA_DEP_HELP
exit 0 ;;
esac
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=dejima-project-lib.sh
source "$SCRIPT_DIR/dejima-project-lib.sh"
+344 -8
View File
@@ -21,22 +21,354 @@
# file it came from. This preserves what was recorded, it does not attest to
# it.
#
# Usage: dejima-epitaph save <workspace>
# Usage: dejima-epitaph save <workspace>
# dejima-epitaph list [--all] [workspace]
# dejima-epitaph search [--all] <pattern> [workspace]
# dejima-epitaph show [n | name] [workspace]
#
# `save` is what bin/dejima's exit trap runs. The other three are the
# operator's, and they only ever read from the host: nothing here gives a
# container a path to the store, and nothing here hands an epitaph back to a
# session. That second one is the load-bearing half. A session can write its
# own transcript, so it can author what its own epitaph says - delivering
# that to a later session automatically would plant instructions in its
# premises, which is the channel the never-mounted store exists to close
# (#104). Printing to the operator's terminal keeps a person in the middle,
# and that is the whole design rather than a missing feature.
set -uo pipefail
# --help before anything else runs: no libraries sourced, no config read. The
# moment someone reaches for it is often the moment the config is wrong.
# stdout and exit 0, where usage() stays on stderr and exits nonzero.
case "${1:-}" in
-h|--help)
cat <<'DEJIMA_EPITAPH_HELP'
dejima-epitaph - keep what a session worked out before its context was compacted
Usage: dejima-epitaph save <workspace>
dejima-epitaph list [--all] [workspace]
dejima-epitaph search [--all] <pattern> [workspace]
dejima-epitaph show [n | name] [workspace]
save extract any compaction summaries from that workspace's transcripts
and keep them. Run by bin/dejima when a session exits, not by hand
list what is kept, oldest first, and whether the session behind each is
still resumable - where it is, resuming beats reading a summary
search case-insensitive regex over the bodies, reporting each hit's
position in the listing list prints
show open one in $VISUAL or $EDITOR, or print it where there is no
terminal to open an editor on
--all widen list and search to every project rather than this one
A harness that compacts a long session writes a summary of what it is about to
forget, and it goes away with the container. This keeps it, host-side, in a
store nothing mounts - so one session cannot read what an earlier one concluded
or edit what this one is about to record.
Reading works with [epitaph] enabled off: turning the feature off is when
reviewing and clearing the store is most likely.
Nothing is kept when the selected harness does not lose anything to compaction,
which is not a failure - it means the transcript already holds it all.
See docs/security.md.
DEJIMA_EPITAPH_HELP
exit 0 ;;
esac
# shellcheck source=dejima-provider-lib.sh
source "$(dirname "$(readlink -f "$0")")/dejima-provider-lib.sh"
# shellcheck source=dejima-slug-lib.sh
source "$(dirname "$(readlink -f "$0")")/dejima-slug-lib.sh"
# shellcheck source=dejima-harness-lib.sh
source "$(dirname "$(readlink -f "$0")")/dejima-harness-lib.sh"
# shellcheck source=dejima-color-lib.sh
source "$(dirname "$(readlink -f "$0")")/dejima-color-lib.sh"
config_validate || exit 1
color_init 1
cmd="${1:-}"; ws_arg="${2:-}"
usage() {
cat >&2 <<'EOF'
usage: dejima-epitaph save <workspace>
dejima-epitaph list [--all] [workspace]
dejima-epitaph search [--all] <pattern> [workspace]
dejima-epitaph show [n | name] [workspace]
n is a position in the listing `list` and `search` print, so it means what
those just showed - including under --all, which numbers every project's
epitaphs in one sequence.
EOF
exit 2
}
cmd="${1:-}"
shift 2>/dev/null || true
# --all anywhere, so it reads naturally either side of a search pattern.
ALL=0; args=()
for a in ${@+"$@"}; do
case "$a" in
--all) ALL=1 ;;
-h|--help) usage ;;
*) args+=("$a") ;;
esac
done
set -- ${args[@]+"${args[@]}"}
[ "$(config_get epitaph enabled)" = "on" ] || exit 0
STORE="$(config_get epitaph dir)"
# `enabled` gates writing, not reading. Turning epitaph off must not strand
# what it already kept behind a config key: an operator who switches it off
# is the one most likely to want to look at, and get rid of, the existing
# store.
case "$cmd" in
save) [ "$(config_get epitaph enabled)" = "on" ] || exit 0 ;;
list|search|show) ;;
*) usage ;;
esac
# Every epitaph belonging to $1, oldest first. The sort key is the basename,
# not the path: names begin with the timestamp, so this is chronological
# across a workspace's several slugs rather than grouped by whichever slug
# directory each file happens to sit in.
_epitaphs_in() {
local f
[ -d "$1" ] || return 0
for f in "$1"/*.md; do
[ -f "$f" ] || continue
printf '%s\t%s\n' "$(basename "$f")" "$f"
done
}
epitaph_files() {
local ws="$1" slug d
{
if [ "$ALL" = 1 ]; then
for d in "$STORE"/*/; do _epitaphs_in "${d%/}"; done
else
while IFS= read -r slug; do
[ -n "$slug" ] || continue
_epitaphs_in "$STORE/$slug"
done < <(workspace_slugs_sorted "$ws")
fi
} | sort | cut -f2-
}
# The workspace to read from: the one given, else this checkout. Tries the
# worktree top and then the main checkout that worktree belongs to, taking
# the first that actually has epitaphs - the same shape as dejima-audit's
# resolve_ws, and it matters more here. A worktree only has epitaphs of its
# own if dejima was launched against the worktree itself; run from one after
# working in the main checkout, the answer is the main checkout.
resolve_ws() {
local given="$1" top common main c
if [ -n "$given" ]; then
( cd "$given" 2>/dev/null && pwd ) || printf '%s' "$given"
return
fi
top="$(git rev-parse --show-toplevel 2>/dev/null)" || top=""
common="$(git rev-parse --git-common-dir 2>/dev/null)" || common=""
main=""
if [ -n "$common" ]; then
common="$(readlink -f "$common" 2>/dev/null)" || common=""
[ -n "$common" ] && main="$(dirname "$common")"
fi
for c in "$top" "$main" "$PWD"; do
[ -n "$c" ] || continue
[ -n "$(epitaph_files "$c")" ] || continue
printf '%s' "$c"
return
done
printf '%s' "$PWD"
}
case "$cmd" in
list)
ws="$(resolve_ws "${1:-}")"
mapfile -t FILES < <(epitaph_files "$ws")
if [ "${#FILES[@]}" -eq 0 ]; then
if [ "$ALL" = 1 ]; then echo "dejima epitaph: nothing kept yet." >&2
else echo "dejima epitaph: nothing kept for $ws." >&2; fi
exit 0
fi
if [ "$ALL" = 1 ]; then printf '%s epitaph(s), all projects\n\n' "${#FILES[@]}"
else printf '%s epitaph(s) for %s%s%s\n\n' "${#FILES[@]}" "$C_PATH" "$ws" "$C_RESET"; fi
# One python pass rather than one per file, and python rather than three
# greps and a stat because this reads the header of every epitaph in the
# store under --all.
#
# Deliberately no topic column. Every Claude Code summary opens on the
# same boilerplate, the "Primary Request" section that follows is written
# four different ways across the summaries here, and what it opens on is
# usually scene-setting rather than the request. Nothing extractable
# tells the reader which session this was, and a column repeating the
# same sentence down eight rows reads as a broken tool while leaving
# discoverability exactly where it was. `search` is the answer to "which
# one was it"; this is the answer to "what is here".
#
# Which sessions still have a transcript is the harness's answer, not
# something to work out from a filename: the id is in the name for one
# harness and is the directory for the next. Under --all the workspace is
# not passed, because a store key is a slug and a slug does not resolve
# back to a path - the plugin answers for every key it holds instead. A
# harness that does not implement the hook returns nothing, and every row
# then says so rather than claiming the transcript is gone.
harness_plugin_load "$(config_get core harness)"
live=""
if [ "$ALL" = 1 ]; then live="$(harness_hook harness_live_sessions)"
else live="$(harness_hook harness_live_sessions "$ws")"; fi
C_KEY="$C_KEY" C_DIM="$C_DIM" C_OK="$C_OK" C_RESET="$C_RESET" \
DEJIMA_LIVE_SESSIONS="$live" DEJIMA_HAS_LIVE="$(declare -F harness_live_sessions >/dev/null && echo 1)" \
python3 - "$ALL" ${FILES[@]+"${FILES[@]}"} <<'PY'
import os, re, sys
show_slug = sys.argv[1] == "1"
# "<key>\t<session-id>" per live transcript. The distinction that matters is
# an empty set from a harness that answered versus no answer at all: the
# first means aged out, the second means unknown.
known = os.environ.get("DEJIMA_HAS_LIVE") == "1"
live = set(os.environ.get("DEJIMA_LIVE_SESSIONS", "").splitlines())
KEY, DIM, OK, RESET = (os.environ.get(k, "") for k in ("C_KEY", "C_DIM", "C_OK", "C_RESET"))
# The index is padded before it is coloured: a width in the format string
# counts the escape sequence's bytes and knocks every row out of line the
# moment colour is on.
ROW = "%s %-16s %-6s %6s %5s %s"
print(DIM + ROW % ("%4s" % "#", "when", "how", "ctx", "size", "transcript") + RESET)
# The bash side's sanitize(), stated again because this renderer parses the
# fields itself and there is nothing to hand it a cleaned string. The classes
# are pinned equal to that one by a test rather than by this comment.
_BAD = re.compile(
"[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]")
def clean(s):
return _BAD.sub("", s)
for i, path in enumerate(sys.argv[2:], 1):
try:
with open(path, errors="replace") as fh:
head = [next(fh, "") for _ in range(12)]
except OSError:
continue
h = {}
for l in head:
m = re.match(r"- (session|at|trigger|tokens): (.*)", l)
if m:
h[m.group(1)] = m.group(2).strip()
sid = h.get("session", "")
# How much context the session had accumulated by the time it compacted:
# the one field here that separates a long session from a short one, and
# it is recorded rather than guessed at.
m = re.match(r"(\d+) before", h.get("tokens", ""))
ctx = "%dk" % (int(m.group(1)) / 1000) if m else "?"
# The store key is the directory this epitaph sits in, which is dejima's
# own arrangement; pairing it with the session id is what makes the answer
# unambiguous under --all, where two projects can hold the same id.
if not known:
state = DIM + "?" + RESET
elif "%s\t%s" % (os.path.basename(os.path.dirname(path)), sid) in live:
state = OK + "resumable" + RESET
else:
state = DIM + "aged out" + RESET
row = ROW % (KEY + "%4d" % i + RESET,
clean(h.get("at", ""))[:16].replace("T", " ") or "?",
clean(h.get("trigger", "?"))[:6], ctx,
"%dK" % ((os.path.getsize(path) + 1023) // 1024), state)
if show_slug:
row += " " + DIM + clean(os.path.basename(os.path.dirname(path)))[:30] + RESET
print(row)
PY
printf '\n %sdejima-epitaph show <n>%s opens one; %sdejima-epitaph search <pattern>%s finds one.\n' \
"$C_KEY" "$C_RESET" "$C_KEY" "$C_RESET"
# `resumable` means the transcript this was distilled from is still on
# disk, so `claude --resume <session>` gives the real session rather than
# a summary of it. That is the better recovery whenever it is available;
# the epitaph is what remains once the harness has aged the transcript out.
exit 0
;;
search)
pattern="${1:-}"; [ -n "$pattern" ] || usage
ws="$(resolve_ws "${2:-}")"
mapfile -t FILES < <(epitaph_files "$ws")
[ "${#FILES[@]}" -gt 0 ] || { echo "dejima epitaph: nothing kept for $ws." >&2; exit 0; }
# Case-insensitive extended regex, so a bare word works and a pattern is
# available without a second flag to remember.
gcolor=never; [ -n "$C_RESET" ] && gcolor=always
i=0; hits=0
for f in ${FILES[@]+"${FILES[@]}"}; do
i=$((i + 1))
grep -qiE -- "$pattern" "$f" 2>/dev/null || continue
hits=$((hits + 1))
# The index is this file's position in the SAME listing `list`
# prints, so `show <n>` after a search means what the search showed.
# The name is built from the transcript's own timestamp and uuid
# fields, so it is session-authored too - not just the body under it.
printf '\n%s%4d%s %s%s%s\n' "$C_KEY" "$i" "$C_RESET" \
"$C_DIM" "$(sanitize "$(basename "$f")")" "$C_RESET"
# Sanitized BEFORE grep, not after: grep adds its own colour when the
# output is a terminal, and cleaning afterwards would strip the
# highlight along with the planted bytes.
sanitize_body < "$f" \
| grep -inE --color=$gcolor -- "$pattern" 2>/dev/null \
| head -3 | sed 's/^/ /' | cut -c1-100
done
if [ "$hits" -eq 0 ]; then
printf 'dejima epitaph: no epitaph matches %s (searched %s).\n' "$pattern" "$i" >&2
else
printf '\n%s of %s epitaph(s) match. %sdejima-epitaph show <n>%s opens one.\n' \
"$hits" "$i" "$C_KEY" "$C_RESET"
fi
exit 0
;;
show)
sel="${1:-}"; ws_arg="${2:-}"
# A lone argument that names a directory is the workspace, not a
# selector: `dejima-epitaph show ~/workspace/thing` is the obvious thing
# to type and would otherwise be read as a name to match.
if [ -n "$sel" ] && [ -z "$ws_arg" ] && [ -d "$sel" ]; then ws_arg="$sel"; sel=""; fi
ws="$(resolve_ws "$ws_arg")"
mapfile -t FILES < <(epitaph_files "$ws")
[ "${#FILES[@]}" -gt 0 ] || { echo "dejima epitaph: nothing kept for $ws." >&2; exit 0; }
file=""
case "$sel" in
"") file="${FILES[-1]}" ;;
*[!0-9]*)
for f in ${FILES[@]+"${FILES[@]}"}; do
case "$(basename "$f")" in "$sel"*) file="$f"; break ;; esac
done ;;
*)
[ "$sel" -ge 1 ] 2>/dev/null && [ "$sel" -le "${#FILES[@]}" ] \
&& file="${FILES[$((sel - 1))]}" ;;
esac
[ -n "$file" ] || { echo "dejima epitaph: no epitaph '$sel' in ${#FILES[@]} kept for $ws." >&2; exit 1; }
# Piped or redirected, print it: `show | grep` must not try to open an
# editor, and neither must a test.
ed="${VISUAL:-${EDITOR:-}}"
if [ -z "$ed" ] || [ ! -t 1 ] || [ ! -t 0 ]; then
# The body is what a session wrote, and printing it IS this tool's
# purpose - so it is the one place the operator's terminal meets those
# bytes directly. sanitize_body rather than sanitize: the field rule
# takes LF too, and an epitaph is not one line.
sanitize_body < "$file"
exit 0
fi
# Through `sh -c` so $EDITOR may carry flags (`code -w`, `emacsclient
# -nw`), with the path passed as an argument rather than interpolated so
# a space in it stays one word.
sh -c "$ed \"\$1\"" sh "$file"
# An editor writes through a temp file and renames it into place, which
# leaves the result at the umask's mode rather than the 0600 `save`
# wrote. The 0700 directory means nothing else could read it either way;
# the mode is restated because it is what this store promises, not
# because the directory needs the help.
chmod 600 "$file" 2>/dev/null || true
exit 0
;;
esac
# Everything below is `save`. Reading the store is dejima's own business and
# needs the plugin only to label a row (see `list` above, which tolerates its
# absence); extracting an epitaph is entirely the plugin's, so from here a
# missing one is fatal rather than a blank column.
harness_plugin_load "$(config_get core harness)"
# With no harness selected nothing knows what a compaction looks like, so
@@ -52,15 +384,12 @@ if [ -z "$(config_get core harness)" ]; then
exit 0
fi
ws_arg="${1:-}"
ws="$ws_arg"
[ -n "$ws" ] || ws="$(pwd)"
ws="$(cd "$ws" 2>/dev/null && pwd)" || exit 0
case "$cmd" in
save) ;;
*) echo "usage: dejima-epitaph save <workspace>" >&2; exit 2 ;;
esac
# One file per summary, named for the moment it was written, so a directory
# listing is a timeline. Keyed on the summary record's own uuid: `save` runs
# on every exit, and a session compacted three times has three epitaphs, two
@@ -126,6 +455,13 @@ echo "dejima: wrote $saved epitaph(s) to $STORE" >&2
# operators who already centralise session logs. Host-side rather than
# through the container's log-shipper, so the same "never in the session's
# reach" property holds for the shipping path too.
#
# DEJIMA_EPITAPH_NO_SHIP is bin/dejima's --no-log reaching the one decision it
# cannot make on this tool's behalf. Honoured from the environment, which
# nothing else here is, because it can only ever suppress: a stale export
# keeps an epitaph off the wire and never puts one on it. The epitaph is still
# written either way - the store is the durable copy, and this is the bonus.
[ "${DEJIMA_EPITAPH_NO_SHIP:-0}" != 1 ] || exit 0
[ "$(config_get epitaph loki)" = "on" ] || exit 0
loki="$(config_get logging loki_url)"
[ -n "$loki" ] || exit 0
+1 -1
View File
@@ -40,7 +40,7 @@
# The hooks core will call. A plugin defines the ones its harness needs and
# omits the rest; anything else it defines is a name core does not call, and
# the static test refuses it rather than letting it look wired up.
DEJIMA_HARNESS_HOOKS="harness_prepare_config harness_state_mounts harness_register_notify harness_register_guards harness_host_exec_surfaces harness_config_dirs harness_staged_config harness_extract_epitaphs harness_transform_transcript harness_guards_registered harness_briefing_state_dir harness_transcript_files harness_seed"
DEJIMA_HARNESS_HOOKS="harness_prepare_config harness_state_mounts harness_register_notify harness_register_guards harness_host_exec_surfaces harness_config_dirs harness_staged_config harness_extract_epitaphs harness_transform_transcript harness_guards_registered harness_briefing_state_dir harness_transcript_files harness_live_sessions harness_seed"
# What an operator loses when a harness cannot host a given guard. The set
# itself is DEJIMA_KNOWN_GUARDS in dejima-provider-lib.sh, next to the
+75 -2
View File
@@ -27,6 +27,35 @@
# markers can abort it and silence every other session's notifications.
set -euo pipefail
# --help before anything else runs: no libraries sourced, no config read. The
# moment someone reaches for it is often the moment the config is wrong.
# stdout and exit 0, where usage() stays on stderr and exits nonzero.
case "${1:-}" in
-h|--help)
cat <<'DEJIMA_NOTIFY_WATCH_HELP'
dejima-notify-watch - turn a session's notifications into phone pushes
Usage: dejima-notify-watch [--once]
(no argument) watch until killed, which is what the systemd unit runs
--once one scan, then exit
Started by a systemd USER unit on the host. Never as root, never inside a
container.
A session's hooks drop marker files when it wants attention or has finished;
this turns those into ntfy pushes. It runs host-side because it is the trust
boundary: a possibly-compromised session writes the markers, and the phone they
reach is outside the sandbox. Nothing the session writes is used as a command,
a URL or a topic - the marker only says which of a fixed set of events happened.
Does nothing unless [remote-access] enabled is on and a notify URL is set.
See docs/remote-access.md.
DEJIMA_NOTIFY_WATCH_HELP
exit 0 ;;
esac
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=dejima-project-lib.sh
source "$SCRIPT_DIR/dejima-project-lib.sh"
@@ -54,11 +83,36 @@ fi
# Refusing rather than sanitizing, because the config is operator-written:
# a value that can't be used as intended means they got something wrong, and
# this is the one place they say where their phone notifications go.
#
# The value is not echoed back whole. An ntfy server that wants credentials
# takes them in the URL - https://<user>:<token>@<host>/<topic>, which
# docs/remote-access.md documents - and a misconfiguration should not be the
# thing that writes that token into the journal, where it stays.
redacted_url() {
case "$1" in
*://*@*) printf '%s://<redacted>@%s' "${1%%://*}" "${1#*@}" ;;
*) printf '%s' "$1" ;;
esac
}
require_url() {
case "$2" in
http://*|https://*) ;;
*)
echo "$1 in $(config_path) must be an http:// or https:// URL: $2" >&2
echo "$1 in $(config_path) must be an http:// or https:// URL: $(redacted_url "$2")" >&2
exit 1
;;
esac
# The URL is handed to curl through a config file on stdin rather than as
# an argument (see the push below), and that format is line-oriented with
# its own quoting: a newline in the value would start a second directive,
# and a quote or a backslash would end or escape the string. The `url`
# config type's own character set already excludes all three, so this
# refuses a value that arrived some other way rather than sanitizing one
# that did - the same reading as the scheme check above.
case "$2" in
*[!A-Za-z0-9:/._~?=%@+-]*)
echo "$1 in $(config_path) has a character dejima will not send: $(redacted_url "$2")" >&2
exit 1
;;
esac
@@ -139,12 +193,31 @@ push() {
# stdout is discarded (ntfy answers with a JSON receipt); stderr is not.
# -sS exists precisely to keep transport errors on stderr, and under a
# systemd user unit that is the journal, which is where they belong.
#
# The URL goes in on stdin, not in argv. It is the one value here that can
# carry a credential, and every argument of every process on this host is
# readable by any local account through /proc/<pid>/cmdline - so a token in
# argv is a token published to the machine for as long as the request runs,
# and `ps` is enough to read it. Nothing else on this line is secret: the
# title, the body and the click URL are all composed from host data.
#
# This also retires the `-K/path` hazard the scheme check above was half
# of: a URL that is not a URL can no longer be read as an option, because
# it is no longer in the option list at all. Both checks stay anyway, and
# the second one is now what keeps this format from gaining a directive.
# Redirected from a process substitution rather than piped into. `printf |
# curl` is a pipeline under `set -o pipefail`, so a curl that exits before
# draining stdin leaves printf holding a closed pipe and the pipeline
# reports 141 - a "push failed" line for a push that succeeded. A
# redirection is not a pipeline and has no such status to inherit. It also
# keeps `--config -` in argv rather than a /dev/fd path, which is a fixed
# string a test can compare.
curl -fsS --max-time 10 --retry 2 \
-H "Title: $title" \
-H "Priority: default" \
"${click[@]}" \
-d "$body" \
-- "$NTFY_URL" >/dev/null \
--config - >/dev/null < <(printf 'url = "%s"\n' "$NTFY_URL") \
|| echo "dejima-notify-watch: push failed for $run_id/$event" >&2
}
+65 -2
View File
@@ -8,6 +8,44 @@
# Usage: dejima-project <status|approve> [<path>]
set -euo pipefail
# --help before anything else runs: no libraries sourced, no config read. The
# moment someone reaches for it is often the moment the config is wrong.
# stdout and exit 0, where usage() stays on stderr and exits nonzero.
case "${1:-}" in
-h|--help)
cat <<'DEJIMA_PROJECT_HELP'
dejima-project - review and approve what a project declares it needs
Usage: dejima-project <status|approve> [path]
status [path] what the project declares, and whether each entry is
approved, pending, rejected as malformed, or beyond the cap
approve [path] approve everything pending, after showing it and asking
path defaults to the current directory.
A project declares its requirements in files inside its own checkout:
.dejima/dependencies packages its image should carry
.dejima/allowed-domains hosts its sessions may reach
.dejima/corpus rules about how its history is recorded
.dejima/tools optional tools its sessions should have
Those are declarations, not grants. They live in the workspace, which a session
can write, so nothing they contain takes effect until it is approved here and
recorded in a store outside the container's reach. That is the whole point of
the split: a session can ask, and only an operator can agree.
An entry that fails validation is reported as rejected and never offered for
approval, so a malformed declaration cannot be waved through by answering yes.
approve is interactive and shows everything before it asks once.
See docs/configuration.md.
DEJIMA_PROJECT_HELP
exit 0 ;;
esac
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=dejima-project-lib.sh
source "$SCRIPT_DIR/dejima-project-lib.sh"
@@ -127,12 +165,15 @@ case "$action" in
report allowed-domains valid_domain
echo "corpus:"
report corpus valid_corpus_rule
echo "tools:"
report tools valid_tool_name
;;
approve)
pending_deps=$(classify dependencies valid_dep_spec | sed -n 's/^pending //p')
pending_doms=$(classify allowed-domains valid_domain | sed -n 's/^pending //p')
pending_corpus=$(classify corpus valid_corpus_rule | sed -n 's/^pending //p')
rejected=$( { classify dependencies valid_dep_spec; classify allowed-domains valid_domain; classify corpus valid_corpus_rule; } | sed -n 's/^rejected //p')
pending_tools=$(classify tools valid_tool_name | sed -n 's/^pending //p')
rejected=$( { classify dependencies valid_dep_spec; classify allowed-domains valid_domain; classify corpus valid_corpus_rule; classify tools valid_tool_name; } | sed -n 's/^rejected //p')
trunc_deps=$(classify dependencies valid_dep_spec | sed -n 's/^truncated //p')
trunc_doms=$(classify allowed-domains valid_domain | sed -n 's/^truncated //p')
if [ -n "$trunc_deps" ]; then
@@ -146,7 +187,8 @@ case "$action" in
sed 's/^/ /' <<< "$rejected" >&2
echo >&2
fi
if [ -z "$pending_deps" ] && [ -z "$pending_doms" ] && [ -z "$pending_corpus" ]; then
if [ -z "$pending_deps" ] && [ -z "$pending_doms" ] && [ -z "$pending_corpus" ] \
&& [ -z "$pending_tools" ]; then
echo "Nothing pending approval for $project."
exit 0
fi
@@ -154,6 +196,12 @@ case "$action" in
[ -n "$pending_deps" ] && printf ' dependency: %s\n' $pending_deps
[ -n "$pending_doms" ] && printf ' domain: %s\n' $pending_doms
[ -n "$pending_corpus" ] && printf ' corpus: %s\n' $pending_corpus
# Named as what it is rather than as a package: approving one adds a
# program to the image every session on this project then has on
# PATH. dejima hardens what it ships (docs/tools.md), which is what
# makes this a shorter conversation than a dependency - but it is
# still a change to what the sandbox contains.
[ -n "$pending_tools" ] && printf ' tool: %s (installed into the image)\n' $pending_tools
echo
printf 'Approve all of the above? [y/N] '
read -r reply || reply=n
@@ -196,6 +244,15 @@ case "$action" in
# RUNNING container's firewall or image; a corpus rule changes what the
# next session is told and nothing about a live one, so there is
# nothing to patch and no CLI to route through.
tool_failed=""
tool_approved=0
for tool in $pending_tools; do
if approve_entry "$(ensure_project_store "$project")" tools "$tool"; then
tool_approved=1
else
tool_failed="$tool_failed $tool"
fi
done
corpus_failed=""
corpus_approved=0
for rule in $pending_corpus; do
@@ -212,6 +269,12 @@ case "$action" in
if [ "$corpus_approved" = 1 ]; then
echo "Approved corpus rules for $project. They take effect at the next launch."
fi
if [ -n "$tool_failed" ]; then
echo "NOT approved for $project (could not record):$tool_failed" >&2
fi
if [ "$tool_approved" = 1 ]; then
echo "Approved tools for $project. The image rebuilds on the next launch."
fi
if [ -n "$dep_failed" ]; then
echo "NOT approved for $project (dejima-dep failed):$dep_failed" >&2
echo "Fix the cause and re-run; nothing was recorded for those." >&2
+42 -2
View File
@@ -236,6 +236,7 @@ _approved_validator() {
allowed-domains) printf 'valid_domain' ;;
dependencies) printf 'valid_dep_spec' ;;
corpus) printf 'valid_corpus_rule' ;;
tools) printf 'valid_tool_name' ;;
*) return 1 ;;
esac
}
@@ -249,6 +250,18 @@ _approved_validator() {
# not. There is one today.
DEJIMA_CORPUS_RULES="commit-per-step"
# An optional tool a project asks for. A name dejima ships a manifest for and
# nothing else: an entry naming a tool that does not exist would approve
# cleanly, sit in the store and install nothing, which reads as the tool being
# in force when it is not - the same reasoning as the corpus rules above.
#
# Refusing at declaration time also means the approval prompt cannot offer an
# operator a name that would silently do nothing.
valid_tool_name() {
valid_provider_name "$1" || return 1
provider_dir tool "$1" >/dev/null 2>&1
}
valid_corpus_rule() {
case " $DEJIMA_CORPUS_RULES " in
*" $1 "*) return 0 ;;
@@ -423,8 +436,22 @@ unapprove_entry() {
#
# -xaf rather than -xzf: compression is detected from the archive, so xz and
# zstd releases work without the spec having to say which it is.
# DEJIMA_TARBALL_STRIP picks between the two archive shapes. "prefix" is the
# default and the one every dependency uses: everything under a single
# directory, stripped so bin/ lands in the same place whatever the release
# called its top level. "binary" is an archive that IS the executable, with no
# enclosing directory at all - stripping a component there strips the binary
# and extracts nothing, silently, leaving a layer that built fine and contains
# no tool.
#
# A parameter rather than a second emitter, because the digest-before-unpack
# below is the part that must not exist twice.
_emit_tarball_layers() {
local spec rest name sha url
local spec rest name sha url dest strip
case "${DEJIMA_TARBALL_STRIP:-prefix}" in
binary) strip="" ;;
*) strip="--strip-components=1 " ;;
esac
for spec in "$@"; do
[ -n "$spec" ] || continue
rest="${spec#tarball:}"
@@ -436,7 +463,12 @@ _emit_tarball_layers() {
printf ' wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \\\n'
printf ' -O /tmp/%s.tar "%s" && \\\n' "$name" "$url"
printf ' echo "%s /tmp/%s.tar" | sha256sum -c - && \\\n' "$sha" "$name"
printf ' tar -C /opt/dejima/%s --strip-components=1 -xaf /tmp/%s.tar && \\\n' "$name" "$name"
# A bare binary goes straight into the bin/ a prefix archive would
# have brought with it, so the PATH line below is the same either way.
dest="/opt/dejima/$name"
[ -n "$strip" ] || dest="$dest/bin"
printf ' mkdir -p %s && \\\n' "$dest"
printf ' tar -C %s %s-xaf /tmp/%s.tar && \\\n' "$dest" "$strip" "$name"
printf ' rm /tmp/%s.tar\n' "$name"
printf 'ENV PATH=$PATH:/opt/dejima/%s/bin\n' "$name"
done
@@ -629,6 +661,14 @@ remote_ttyd_font_size() {
# useful default and is deliberately NOT a bool default of off: the page
# decides per client from the pointer type, which a host-wide setting cannot
# see. Only a value the operator actually wrote is passed on.
# Whether the terminal page colour-codes its toolbar. Unset means on, which
# the page decides for itself - so, like the tap setting above, only a value
# the operator actually wrote is passed on.
remote_ttyd_button_colors() {
config_is_set remote-access ttyd_button_colors || return 0
config_get remote-access ttyd_button_colors
}
remote_ttyd_focus_on_tap() {
config_is_set remote-access ttyd_focus_on_tap || return 0
config_get remote-access ttyd_focus_on_tap
+444 -4
View File
@@ -44,6 +44,11 @@ _provider_axis_dir() {
forge) printf 'forges' ;;
workflow) printf 'workflows' ;;
harness) printf 'harnesses' ;;
# Not an axis in the select-one sense the other three are: a launch
# enables zero or more tools, and none is the default. It is here
# because everything else about it is the same - a manifest that is
# data, validated by the same schema machinery, never sourced.
tool) printf 'tools' ;;
*) echo "unknown provider axis: $1" >&2; return 1 ;;
esac
}
@@ -53,6 +58,7 @@ _provider_manifest_name() {
forge) printf 'forge.conf' ;;
workflow) printf 'workflow.conf' ;;
harness) printf 'harness.conf' ;;
tool) printf 'tool.conf' ;;
*) return 1 ;;
esac
}
@@ -143,6 +149,10 @@ capabilities ?list:identifier
pr_command ?command
issue_command ?command
merge_block ?command
repo_flags ?list:flag
repo_env ?identifier
repo_paths ?list:repopath
identity_keys ?list:identifier
disclose ?disclosures
keys ?list:keyspec
requires ?list:identifier
@@ -152,13 +162,29 @@ fixed_hosts ?list:hostname
hosts_allowlisted_by_core bool off
install ?list:installspec
env_export ?list:export
EOF
;;
# An optional tool. Deliberately small: a tool declares what it is,
# what installs it, what its environment needs and what the session
# should be told about it - and nothing that could change what dejima
# permits. There is no key here for a hook, a mount, a domain or a
# guard, because a tool that needed one of those would be a change to
# the boundary rather than a program on PATH, and would have to be
# argued for rather than declared.
tool) cat <<'EOF'
description text
install depspec
install_layout enum:prefix|binary prefix
image_env ?list:envpair
command identifier
instructions ?relpath
EOF
;;
workflow) cat <<'EOF'
requires ?list:identifier
push bool on
blocked_branches ?list:identifier
block_merge bool off
block_merge bool on
requires_worktree bool off
autonomy enum:commit|push-branch|open-pr commit
briefing ?relpath
@@ -271,6 +297,29 @@ _provider_valid_value() {
# and nothing shell-active does.
[[ "$value" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*( [A-Za-z0-9][A-Za-z0-9._-]*)*$ ]]
;;
flag)
# A command-line option name, compared against argv by
# docker/forge-guard.sh. The leading dash is required: without it
# the guard would be comparing a word that could just as well be a
# subcommand or a value.
[[ "$value" =~ ^--?[A-Za-z0-9][A-Za-z0-9-]*$ ]]
;;
repopath)
# A repository named in a path rather than in a flag:
# `<subcommand>:<first path segment>`, as in `api:repos` for
# `gh api repos/<owner>/<name>/...`.
#
# Scoped to a subcommand because the alternative is comparing every
# positional argument that happens to contain two slashes, and a
# false refusal on an issue title is worse than the miss - this is
# a fast-fail, so a wrong yes costs an honest agent a working
# command. The guard applies it only when the subcommand is argv's
# first word.
case "$value" in *:*) ;; *) return 1 ;; esac
local sub="${value%%:*}" seg="${value#*:}"
[[ "$sub" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]] || return 1
[[ "$seg" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]]
;;
relpath)
# Joined to a provider directory, so no absolute paths and no
# traversal - a manifest may only name a file it ships.
@@ -600,11 +649,54 @@ _config_extra_section_known() {
case "$section" in
forge.*) axis=forge; name="${section#forge.}" ;;
harness.*) axis=harness; name="${section#harness.}" ;;
# An identity is the operator's own name for a set of credentials, so
# there is no directory to check it against - only that it is a name
# at all. Unlike a provider, an unused one is not a misconfiguration:
# having [identity.oss] on a machine that never selects it is the
# normal state of a second account.
identity.*)
name="${section#identity.}"
valid_provider_name "$name"
return
;;
*) return 1 ;;
esac
provider_dir "$axis" "$name" >/dev/null 2>&1
}
# The keys an [identity.<name>] section may carry, as "<key>\t<type>" lines.
#
# ssh_key and the git identity are core's, because every forge needs the same
# two answers to "who is pushing". Everything else is declared by a forge's
# identity_keys and typed by that forge's own keys entry, so the identity
# inherits the validation the forge already wrote rather than a second copy of
# it that could disagree.
#
# A key name declared by two forges resolves to one type here, which is why a
# static test refuses that collision rather than this function guessing.
_identity_key_specs() {
printf 'ssh_key ?path
'
printf 'user_name ?text
'
printf 'user_email ?text
'
local forge key spec
for forge in $(provider_list forge); do
for key in $(manifest_get forge "$forge" identity_keys); do
for spec in $(_provider_keys_field forge "$forge"); do
[ "${spec%%:*}" = "$key" ] || continue
# The forge's own type, with its default deliberately dropped:
# a default belongs to the forge's section, and repeating it
# here would make an identity that names no token look like one
# that named the default path.
printf '%s\t?%s\n' "$key" \
"$(cut -f2 <<< "$(_provider_keyspec_parse "$spec")" | sed 's/^?//')"
done
done
done
}
# Looks up one [forge.<name>]/[harness.<name>] key's TYPE<TAB>DEFAULT in that
# provider's `keys` manifest entry instead of a row hardcoded in core.
_config_extra_spec() {
@@ -612,6 +704,17 @@ _config_extra_spec() {
case "$want" in
forge.*) axis=forge ;;
harness.*) axis=harness ;;
identity.*)
rest="${want#identity.}"
key="${rest#*.}"
[ "$key" != "$rest" ] || return 1
while IFS=$'\t' read -r pname ptype; do
[ "$pname" = "$key" ] || continue
printf '%s\t' "$ptype"
return 0
done < <(_identity_key_specs)
return 1
;;
*) return 1 ;;
esac
rest="${want#*.}"
@@ -632,6 +735,250 @@ _config_extra_spec() {
return 1
}
# What the selected identity says about one key, if anything.
#
# An identity answers for the credentials and the git name a PERSON brings to
# a project, which is the half a project cannot declare: .dejima/ is committed
# and shared, and a key path is per-user and per-machine. The project (or the
# operator) names an identity; the identity supplies the paths.
#
# Returns 1 for a key no identity owns, which is most of them - the caller
# then reads the config file as before. Deliberately narrow: an identity that
# could set any key would be a second config file with different rules about
# what may be in it.
#
# core.identity itself is excluded, and not merely by convention: the caller
# that consults this is bin/dejima's config_get wrapper, so answering for the
# key that names the identity would recurse into itself.
# Whether the named identity exists in the config at all - any of its keys
# being present is enough, because a section with no keys is not a section
# the parser records.
#
# The check that matters: [core] identity naming a section nobody wrote is a
# silent fall-through to the machine's defaults, which is the wrong direction
# to be wrong in. Pushing under the wrong key fails loudly; pushing under the
# default key when you asked for another one does not fail at all.
identity_exists() {
local name="$1" key
[ -n "$name" ] || return 1
valid_provider_name "$name" || return 1
while IFS=$'\t' read -r key _; do
config_is_set "identity.$name" "$key" && return 0
done < <(_identity_key_specs)
return 1
}
# The credential keys the selected identity leaves to the section beneath it,
# as "<section> <key>" lines, empty when there are none.
#
# Only the selected forge's, because refusing over a credential belonging to a
# forge this launch does not use would be refusing over a value nothing reads.
#
# "Set" means written in the config file, not resolved from a schema default.
# A forge whose credential key carries a default still hands an identity that
# names none the machine's own path, and that is not caught here - see
# docs/configuration.md, which says so rather than leaving it to be found.
identity_borrowed_credentials() {
local name="$1" forge="$2" key
if ! config_is_set "identity.$name" ssh_key && config_is_set core ssh_key; then
printf 'core ssh_key
'
fi
for key in $(manifest_get forge "$forge" identity_keys); do
if ! config_is_set "identity.$name" "$key" \
&& config_is_set "forge.$forge" "$key"; then
printf 'forge.%s %s
' "$forge" "$key"
fi
done
}
# Whether the selected identity, if there is one, is one a session can act
# under without borrowing from anyone.
#
# Two refusals, one reason. A name with no section resolves to nothing and
# falls through to the machine's own credentials; a section that names only
# some of them falls through for the rest. Either way a session launched as
# this identity acts under another account, and nothing says so. Pushing under
# the wrong key fails loudly; pushing under the default key when you asked for
# another one does not fail at all, which is the direction worth being careful
# about.
#
# One function rather than a check at each caller: bin/dejima's --print-config
# needs the same answer, because every value it prints resolves THROUGH the
# identity and printing this machine's credentials under an identity's name is
# the confusion the refusal exists to prevent.
#
# Guarded on the config existing rather than swallowing config_get's stderr:
# with no file at all it says so, and a selection validated without one is a
# legitimate case this must stay silent for.
# The identity in force: [core] identity when it names one, and otherwise the
# reserved `default` section when the config has one.
#
# `default` is what [core] ssh_key was without a name. Giving it one is what
# lets a host state its ordinary account the same way it states any other,
# with a git name and a forge credential beside the key rather than those
# coming from three different places.
#
# Empty when neither applies, which is the pre-identity config and still a
# supported one.
# Assigns rather than prints, and every caller uses this form. identity_get
# runs on every config_get through bin/dejima's wrapper, and `$(...)` around
# an answer this cheap costs a fork per read - which is most of what a launch
# spends on config.
identity_selected_into() {
local __var="$1" __name=""
if config_exists; then
__name="$(config_get core identity)" || __name=""
if [ -z "$__name" ] && _identity_section_present default; then
__name=default
fi
fi
printf -v "$__var" '%s' "$__name"
}
identity_selected() {
local _n
identity_selected_into _n
printf '%s' "$_n"
}
# Whether a section has any key in the parsed config, read straight off the
# parse rather than through identity_exists.
#
# identity_exists walks every forge's manifest to decide which keys count, and
# bin/dejima's config_get wrapper reaches this on every single read - which
# turned a --print-config from three seconds into forty. It does not need the
# manifest: the parser refuses an unknown key outright, so anything present
# under identity.<name> in a config that validated is already one of the keys
# identity_exists would have accepted.
_identity_section_present() {
local name="$1" k
config_exists || return 1
config_validate || return 1
for k in "${!_CFG_SEEN[@]}"; do
case "$k" in "identity.$name".*) return 0 ;; esac
done
return 1
}
identity_validate() {
local forge="$1" name="" borrowed section key
name="$(identity_selected)"
# [core] ssh_key is the default identity under its old name, so a config
# holding both says the same thing twice and dejima would have to pick.
# Refused rather than ranked: which key a session pushed with is not a
# thing to work out from precedence rules.
if identity_exists default && config_is_set core ssh_key; then
echo "[core] ssh_key is set and so is [identity.default], which is the same" >&2
echo "thing under two names - the key a session gets when none is asked for." >&2
echo "Move it into [identity.default] and remove [core] ssh_key." >&2
echo "See docs/configuration.md, \"Identities\"." >&2
return 1
fi
# Deprecated, not removed: it is how every config written before
# identities existed says this, and those still work. Warned on every
# launch that has it set, not only the ones using it - a config with
# [core] ssh_key and a named identity is one migration away from the
# refusal above, and finding that out on the launch that breaks is worse
# than being told now.
#
# Every launch rather than once, because there is nowhere to record
# "once" that a session could not reach, and a deprecation nobody sees
# until removal is not a deprecation.
if config_is_set core ssh_key; then
printf ' %s[core] ssh_key is deprecated%s - it is [identity.default] ssh_key\n' \
"${C_WARN:-}" "${C_RESET:-}" >&2
printf ' under its old name.\n' >&2
if [ -n "$name" ]; then
printf ' This launch runs as %s%s%s, so it is not the key being used here.\n' \
"${C_KEY:-}" "$name" "${C_RESET:-}" >&2
else
printf ' Nothing else names an account, so it is the key this launch uses.\n' >&2
fi
printf ' Move it into an [identity.default] section, which can carry the git\n' >&2
printf ' name and the forge credential belonging to that same account:\n\n' >&2
# The value in the file, not through config_get: in this process
# config_get is bin/dejima's wrapper, so with a named identity in
# force it answers with THAT identity's key - and printing it as the
# thing to move would name a key the operator is not being asked to
# touch.
printf ' [identity.default]\n ssh_key = %s\n\n' \
"$(_config_get_from_file core ssh_key)" >&2
fi
[ -n "$name" ] || return 0
if ! identity_exists "$name"; then
echo "[core] identity is '$name', and no [identity.$name] section sets" >&2
echo "anything in $(config_path). An identity that resolves to nothing would" >&2
echo "quietly use this machine's own credentials instead - refusing rather than" >&2
echo "doing that. See docs/configuration.md, \"Identities\"." >&2
return 1
fi
borrowed="$(identity_borrowed_credentials "$name" "$forge")"
[ -n "$borrowed" ] || return 0
echo "[identity.$name] leaves a credential to the section beneath it, so a" >&2
echo "session launched as '$name' would act under whichever account owns:" >&2
while IFS=$'\t' read -r section key; do
[ -n "$key" ] || continue
echo " [$section] $key" >&2
done <<< "$borrowed"
echo "An identity is how one session's account is kept separate from another's;" >&2
echo "set each of those under [identity.$name], or unset it in the section named." >&2
echo "See docs/configuration.md, \"Identities\"." >&2
return 1
}
identity_get() {
local section="$1" key="$2" name value pname
[ "$section.$key" != "core.identity" ] || return 1
# Which keys an identity could own, before working out which identity is
# in force: this runs on every config_get, and all but a handful of keys
# are ones no identity can answer for. Resolving the identity first meant
# doing that work ~40 times a launch to discard it ~35 of those times.
case "$section" in
core) [ "$key" = ssh_key ] || return 1 ;;
forge.*) ;;
*) return 1 ;;
esac
identity_selected_into name
[ -n "$name" ] || return 1
# The manifest lookup last, for the same reason: it forks, and by here
# there is an identity that might actually answer.
case "$section" in
forge.*) _forge_owns_identity_key "${section#forge.}" "$key" || return 1 ;;
esac
value="$(config_get "identity.$name" "$key")" || return 1
[ -n "$value" ] || return 1
printf '%s' "$value"
}
# Whether one forge declared this key as naming an account rather than the
# forge itself - a credential a session acts under, not a setting like an API
# URL that every session on this host shares.
_forge_owns_identity_key() {
local forge="$1" key="$2" k
for k in $(manifest_get forge "$forge" identity_keys); do
[ "$k" = "$key" ] && return 0
done
return 1
}
# The git name and address the identity carries, for the gitconfig copy the
# container gets. Not core config keys: the host's own ~/.gitconfig is where
# they come from without an identity, and adding a second place to set them
# globally would be a new way to get the same answer.
identity_git_field() {
local field="$1" name
name="$(identity_selected)" || return 1
[ -n "$name" ] || return 1
config_get "identity.$name" "$field"
}
# One key of the SELECTED harness, rather than of a harness named literally
# at the call site.
#
@@ -707,6 +1054,8 @@ provider_validate_selection() {
# quietly absent, because an operator who has them everywhere else would
# otherwise have to read a manifest to find out they are gone here.
#
identity_validate "$forge" || return 1
# A name outside this set is a hard error rather than an unknown guard
# treated as absent: a typo would otherwise silently turn a guard off,
# which is the failure this whole declaration exists to prevent.
@@ -951,6 +1300,10 @@ What this means:
briefing.{{writable_mounts}} Everything else is disposable: installed system
packages, anything outside the mounted paths, 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
user interface, look at it rather than describing what it should look like,
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
@@ -963,9 +1316,9 @@ What this means:
it appears to. If a task needs one more domain and nothing else, that's
a quick fix, not a reason to stop. Tell the user to run, from a terminal
outside the sandbox, whichever of these matches why the domain is needed:
bin/dejima-allow --container {{container_id}} <domain> # just this session
bin/dejima-allow --project <path> <domain> # this project, always
bin/dejima-allow --global <domain> # every project
dejima-allow --container {{container_id}} <domain> # just this session
dejima-allow --project <path> <domain> # this project, always
dejima-allow --global <domain> # every project
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
@@ -1333,6 +1686,77 @@ _briefing_substitute() {
# the resume command) - in that reading order. Every {{key}} is substituted
# except {{session_id}}/{{container_id}}, deferred as @@SESSION_ID@@ and
# @@CONTAINER_ID@@ for the hook that mounts this at session start.
# A tool's manifest, plus the one rule its schema cannot express.
#
# image_env becomes a Dockerfile ENV, which every process in the container
# inherits - not just the tool that declared it. A harness may set a bare name
# there because a harness IS the container's main process; a tool is one
# program among many and may not.
#
# This is not hypothetical. rtk shipped with NO_COLOR=1, reasoning that its
# output lands in a model's context where an escape sequence is noise. The
# reasoning was fine, the scope was wrong, and a launch with that tool enabled
# lost colour in the harness, git, ls and everything else. The rule that would
# have caught it is that a tool's variables must be namespaced to the tool.
#
# Uppercased, with anything that is not alphanumeric folded to an underscore,
# so a tool named `my-tool` claims `MY_TOOL_*`.
tool_validate() {
local name="$1" prefix pair varname
manifest_validate tool "$name" || return 1
prefix="$(printf '%s' "$name" | tr '[:lower:]' '[:upper:]' | tr -c '[:alnum:]' '_')_"
for pair in $(manifest_get tool "$name" image_env); do
varname="${pair%%=*}"
case "$varname" in
"$prefix"*) continue ;;
esac
echo "tool '$name' declares image_env $varname, which is not its own." >&2
echo "A tool's image_env reaches every process in the container, so a bare" >&2
echo "name like NO_COLOR or DO_NOT_TRACK changes the harness and everything" >&2
echo "else too. Name it ${prefix}something, or do not set it." >&2
return 1
done
}
# That the authorship marker is added for the session, so it does not add one.
#
# Every issue and pull request on a project worked on this way opens with that
# line, so an agent reading the existing ones concludes it is the house style
# and writes it into the body - and the shadow then added a second. Each
# doubled artifact taught the next agent to do the same. docker/forge-guard.sh
# refuses to add a second now, which holds whatever the session does; this is
# the other half, which stops it being written in the first place.
_briefing_core_disclosure() {
printf '%s' "Bodies you write for the forge - pull requests, issues, comments - are
marked as agent-written before they are sent. Do not write that line
yourself: it is added for you, and one is what the artifact should carry.
The existing issues on a project all open with it for this reason, not
because it was typed."
}
# What the session is told about the optional tools this launch enabled.
#
# The heading is core's and the body is each tool's own instructions.md, so a
# tool describes itself once and every harness gets the same words. Nothing
# here can change what the session may do - a tool's manifest has no key for
# that - which is what makes rendering a project-selectable fragment into the
# briefing safe at all.
_briefing_core_tools() {
local tools="$1" tool frag dir out=""
out="Extra tools this session has, beyond the usual shell:"
for tool in $tools; do
dir="$(provider_dir tool "$tool")" || continue
frag="$(manifest_get tool "$tool" instructions)" || frag=""
out+=$'\n\n'
out+="- **$tool** - $(manifest_get tool "$tool" description)"
if [ -n "$frag" ] && [ -f "$dir/$frag" ]; then
out+=$'\n\n'
out+="$(sed 's/^/ /' "$dir/$frag")"
fi
done
printf '%s' "$out"
}
briefing_compose() {
local forge="$1" workflow="$2" harness="$3"
local caps autonomy workflow_frag harness_frag text
@@ -1362,6 +1786,22 @@ briefing_compose() {
text+="$(_briefing_core_corpus)"
text+=$'\n\n'
fi
# Optional tools, from DEJIMA_TOOLS. A tool the session is not told about
# is one it will not use, so the manifest's instructions fragment is the
# whole of the wiring for a harness: dejima renders it rather than letting
# the tool's own installer patch a config, which is the difference
# docs/tools.md draws between a hardened tool and a bare install.
if [ -n "${DEJIMA_TOOLS:-}" ]; then
text+="$(_briefing_core_tools "${DEJIMA_TOOLS}")"
text+=$'\n\n'
fi
# Only when something actually discloses. A session told about a marker
# nothing adds would be told a falsehood, and would then reasonably write
# one itself - which is the failure this paragraph exists to stop.
if [ -n "${DEJIMA_DISCLOSE:-}" ]; then
text+="$(_briefing_core_disclosure)"
text+=$'\n\n'
fi
# The harness fragment closes the briefing: it supplies both the resume
# command closing_a has just promised and the sentence about where the
# session history it resumes from actually lives. Both are the harness's
+159
View File
@@ -0,0 +1,159 @@
#!/bin/bash
# Makes Ctrl-Z suspend the whole launch instead of disappearing into the
# container. Started in the background by bin/dejima for the duration of the
# container run; not something to run by hand.
#
# `docker compose run` puts the terminal in raw mode to proxy the container's
# TTY, and raw mode has ISIG off - so Ctrl-Z is not a signal at all, it is the
# byte 0x1a, delivered to whatever is running in the container. A harness that
# implements Ctrl-Z itself (nvim and Claude Code both do) then restores the
# terminal and stops, and nothing inside a container can continue it: there is
# no job-control shell in there. The session is wedged, the host never noticed,
# and there is no job to `fg`. That is #179.
#
# So the one character that means "suspend" is taken back for the host, and
# only that one: intr and quit stay undefined, so Ctrl-C and Ctrl-\ still reach
# the session as bytes, which is how a harness expects to be interrupted.
#
# Everything host-side is in one process group - this script, the container
# run, the credential proxy, the conversation shipper - so the kernel stops and
# continues all of it together. Nothing here forwards signals.
#
# On resume the terminal has to be put back. The shell restores its OWN modes
# when it takes the terminal at the prompt, and puts those same modes back on
# `fg` rather than the ones the job was using - measured on both bash and zsh -
# so without this the session comes back line-buffered and echoing. The SIGCONT
# trap re-applies the modes the container run was using.
#
# It then has to make the harness redraw, over whatever the shell printed while
# the session was away. That used to be `kill -WINCH`, on the reasoning that a
# resize is what makes a full-screen program repaint. The reasoning was right
# and the signal was not enough: a program that answers SIGWINCH by re-reading
# the size, seeing the same numbers and doing nothing gets a poke that costs it
# nothing to ignore.
#
# Measured against the real programs rather than a stand-in, on a pty, counting
# the bytes each wrote in a fixed window after each kind of poke:
#
# idle same-size SIGWINCH size changed and back
# Claude Code 0 0 3337
# Cline 0 0 17810
# nvim (control) 0 3356 6724
#
# Both shipped harnesses ignore the signal outright. So the size is changed
# and changed straight back, which is a genuine resize twice over: TIOCSWINSZ
# raises SIGWINCH on the foreground process group by itself, the container
# client answers a resize by resizing the container's TTY, and the harness
# inside sees dimensions that actually moved.
set -u
usage() {
cat <<'EOF'
Usage: dejima-tty-suspend
Reclaims Ctrl-Z for the host while a dejima session runs, so that suspending
backgrounds the whole launch and `fg` brings back a working session. Without
it Ctrl-Z is delivered into the container as a keystroke and the session is
left suspended with no way to resume it.
Started in the background by bin/dejima and stopped when the session ends.
Running it by hand does nothing useful: it acts on the terminal it inherits
and exits on its own if no container run takes that terminal over.
Options:
-h, --help Show this help.
EOF
}
case "${1:-}" in
-h|--help) usage; exit 0 ;;
"") ;;
*) usage >&2; exit 1 ;;
esac
# A terminal to act on, or nothing to do. bin/dejima starts this in the
# background, where the shell gives stdin /dev/null, so every stty below reads
# the terminal by name rather than from a descriptor.
[ -e /dev/tty ] || exit 0
stty -a < /dev/tty >/dev/null 2>&1 || exit 0
# Wait for the container run to take the terminal, which is what "-isig" in the
# settings means. Applying the reclaim before that would just be overwritten by
# it. Bounded rather than open-ended: a launch that fails before attaching
# should leave this script gone, not waiting.
attached=0
for _ in $(seq 1 100); do
if stty -a < /dev/tty 2>/dev/null | grep -q -- '-isig'; then
attached=1
break
fi
sleep 0.05
done
[ "$attached" = 1 ] || exit 0
# isig turns the signal characters back on; the three that are not wanted are
# undefined by name, so only susp survives. dsusp is not on Linux terminals but
# is named anyway for the cost of nothing, and ignored where it does not exist.
stty isig susp '^Z' intr undef quit undef < /dev/tty 2>/dev/null ||
stty isig susp '^Z' intr undef quit undef dsusp undef < /dev/tty 2>/dev/null ||
exit 0
# What the session's terminal looks like now, to be put back on resume.
session_modes="$(stty -g < /dev/tty 2>/dev/null)" || exit 0
on_cont() {
stty "$session_modes" < /dev/tty 2>/dev/null || true
# One row shorter, held, and back. Only the kernel's idea of the size
# changes - the terminal emulator is not told and does not need to be,
# because what a program reads is the kernel's value. It ends where it
# started, so a failure partway leaves the size wrong rather than the
# session unusable, which is why the restore is unconditional.
#
# The hold is the whole of it, and the first version of this did not have
# one. Two ioctls back to back are not a resize a program can see: the
# signal arrives, the handler reads the size as it is NOW, and by then it
# is back where it started. Measured on a pty, bytes written after the
# poke:
#
# no hold 0.1s 0.2s
# Cline 0 0 17810
# Claude Code 0 3328 3328
#
# Cline sets the floor at something between 0.1 and 0.2 seconds. This uses
# half a second, which is 2.5x that - headroom for the hop this could not
# measure, since a real session's resize has to travel from the host tty
# through the container client and the daemon to the container's own tty
# before the harness sees it at all.
#
# The cost is that the terminal is one row short for half a second during
# a resume, while the harness is redrawing anyway.
local size rows
size="$(stty size < /dev/tty 2>/dev/null)" || size=""
rows="${size%% *}"
case "$rows" in
''|*[!0-9]*) rows="" ;;
*) [ "$rows" -gt 1 ] || rows="" ;;
esac
if [ -n "$rows" ]; then
stty rows "$((rows - 1))" < /dev/tty 2>/dev/null || true
sleep 0.5
stty rows "$rows" < /dev/tty 2>/dev/null || true
fi
# Kept as well as the resize, and not redundant: nvim is the measured
# example of a program that repaints on the bare signal, and a session
# running one under `--cmd` should not be worse off than a harness. The
# whole process group, since this script does not know the container
# client's pid and SIGWINCH is ignored by default everywhere else in it.
kill -WINCH 0 2>/dev/null || true
}
trap on_cont CONT
# Idle until the session ends and bin/dejima stops this. `wait` rather than a
# bare sleep: a trap runs the moment it interrupts a wait, where it would
# otherwise be held until the sleep finished.
while :; do
sleep 300 &
wait $! 2>/dev/null || true
done
+61
View File
@@ -28,6 +28,35 @@
# must match the run-id shape before it is used as a path component.
set -euo pipefail
# --help before anything else runs: no libraries sourced, no config read. The
# moment someone reaches for it is often the moment the config is wrong.
# stdout and exit 0, where usage() stays on stderr and exits nonzero.
case "${1:-}" in
-h|--help)
cat <<'DEJIMA_TTYD_HELP'
dejima-ttyd - serve the web terminal that fronts dejima-attach
Usage: dejima-ttyd
Takes no arguments. Started by systemd/dejima-ttyd.service; running it by hand
is only useful for debugging.
Reads ttyd_port from [remote-access] in the config so the port has exactly one
source of truth - the systemd unit deliberately does not repeat it.
Binds 127.0.0.1 only, and that is a requirement rather than a default worth
overriding. Attaching gives a tmux session on the host, so a listener on any
address a container can route to is an escape hatch out of the sandbox;
loopback is not one. Put nginx in front of it, bound to one LAN address - see
docs/nginx-dejima.conf.example.
Serves nothing until [remote-access] enabled is on.
See docs/remote-access.md.
DEJIMA_TTYD_HELP
exit 0 ;;
esac
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=dejima-project-lib.sh
source "$SCRIPT_DIR/dejima-project-lib.sh"
@@ -68,6 +97,10 @@ tmux_prefix="$(remote_ttyd_tmux_prefix)"
# cannot see. See docs/remote-access.md.
focus_on_tap="$(remote_ttyd_focus_on_tap)"
# Whether the toolbar is colour-coded. Passed on only when set, for the same
# reason as the line above: the page has a default and it is the right one.
button_colors="$(remote_ttyd_button_colors)"
# Checked rather than left to exec, whose failure is bash's "ttyd: not found"
# on a line number - true, and useless to an operator who needs to know that
# the missing piece is a package.
@@ -142,10 +175,38 @@ prefix_opt=()
[ -z "$tmux_prefix" ] || prefix_opt=(-t "tmuxPrefix=$tmux_prefix")
focus_opt=()
[ -z "$focus_on_tap" ] || focus_opt=(-t "focusOnTap=$focus_on_tap")
colors_opt=()
[ -z "$button_colors" ] || colors_opt=(-t "buttonColors=$button_colors")
# The sixteen ANSI slots xterm.js draws with, which without this are its own
# defaults - white on black, a bright red, a magenta. dejima's own output
# reaches a terminal in indexed colour precisely so the terminal's theme fills
# those slots (see dejima-color-lib.sh), and here dejima IS the terminal: the
# browser has no gruvbox to inherit, so the front door showed the stock
# palette while the same session over SSH showed the operator's.
#
# So the exact hexes belong here, by the same rule as web/src/page.css: they
# are the two surfaces where dejima owns the background. A static test keeps
# the values they share in step.
#
# Upstream gruvbox dark, by its own names. `background` and `foreground` are
# bg0/light1, which is what a gruvbox terminal is set to.
theme_opt=(-t 'theme={
"background":"#282828","foreground":"#ebdbb2",
"cursor":"#ebdbb2","cursorAccent":"#282828",
"selectionBackground":"#504945",
"black":"#282828","red":"#cc241d","green":"#98971a","yellow":"#d79921",
"blue":"#458588","magenta":"#b16286","cyan":"#689d6a","white":"#a89984",
"brightBlack":"#928374","brightRed":"#fb4934","brightGreen":"#b8bb26",
"brightYellow":"#fabd2f","brightBlue":"#83a598","brightMagenta":"#d3869b",
"brightCyan":"#8ec07c","brightWhite":"#ebdbb2"
}')
exec ttyd --interface 127.0.0.1 --port "$port" --check-origin --url-arg --writable \
--index "$index_html" \
${font_opt[@]+"${font_opt[@]}"} \
${prefix_opt[@]+"${prefix_opt[@]}"} \
${focus_opt[@]+"${focus_opt[@]}"} \
${colors_opt[@]+"${colors_opt[@]}"} \
"${theme_opt[@]}" \
"$SCRIPT_DIR/dejima-attach"
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env bash
# The small terminal interface dejima-attach and dejima-allow share.
#
# Two tools ask an operator to pick one running session out of several, and
# both had to be readable on whatever terminal the operator happened to be
# holding. This is the part that does not depend on which of them is asking:
# the palette, the heading, and the prompt.
#
# What is NOT here is the row. Each tool lists a different thing from a
# different source - the registry of remote-enabled sessions on one side,
# `docker ps` on the other - so the columns are the tool's own, measured by
# the tool against its own data.
#
# Sourced, never run.
# The palette is dejima-color-lib.sh's, and there is only one of it. This file
# used to carry a second, in hardcoded gruvbox truecolor, which collided with
# that one on C_OK and C_WARN and broke on any background dejima does not own -
# see the comment there for the contrast measurements that decided it.
#
# Sourced and initialised here rather than left to the caller, because both
# callers draw to stdout and this file's functions assume the names exist.
# shellcheck source=dejima-color-lib.sh
source "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/dejima-color-lib.sh"
color_init 1
# The rule under the heading, the width of the heading itself. Built rather
# than written out because the heading is now the caller's: two literals kept
# next to two headings stay in step only while nobody adds a third.
#
# A loop rather than `tr`, which is byte-oriented and would shred a 3-byte box
# drawing character into three copies of nothing.
tui_rule() {
local n="$1" i out=""
for ((i = 0; i < n; i++)); do out+="$GLYPH_RULE"; done
printf '%s' "$out"
}
# "dejima | <subtitle>", underlined to its own width.
# A blank screen to draw a front door on, scrollback included.
#
# The picker is reached by navigating to it - the web toolbar's SESS button is
# a full page load - and it drew on top of whatever the previous session had
# left on screen, so the rows arrived interleaved with the tail of the session
# just left. Over SSH it was invisible, because a fresh connection to the
# forced command usually starts on an empty screen (#213).
#
# 3J as well as 2J: without it a swipe up on a phone lands in the middle of
# the previous session's output, which is the same confusion one gesture away.
#
# Its own function rather than part of tui_banner, because dejima-allow's
# picker must NOT do this: that one is a prompt inside a shell the operator is
# already using, and wiping their scrollback to ask one question is worse than
# the interleaving this fixes.
#
# Rides the same switch as the colour and the glyphs, for the reason stated
# above them: one flag is easier to reason about than two, and the terminals
# that mangle box drawing are the ones these bytes should not reach either. So
# NO_COLOR turns this off as well - a reading of it this file has already
# committed to, and the alternative is a picker that answers to two different
# ideas of "plain".
tui_clear() {
[ -n "$C_OFF" ] || return 0
printf '\033[H\033[2J\033[3J'
}
tui_banner() {
local subtitle="$1"
printf '\n %s%sdejima%s %s%s%s %s%s%s\n' \
"$C_BOLD" "$C_BADGE" "$C_OFF" "$C_SUBTLE" "$GLYPH_BAR" "$C_OFF" \
"$C_MUTED" "$subtitle" "$C_OFF"
# 6 for the wordmark, 3 for the bar and the spaces either side.
printf ' %s%s%s\n\n' "$C_RULE" "$(tui_rule $((9 + ${#subtitle})))" "$C_OFF"
}
# Asks for one of 1..$1 and leaves it in TUI_CHOICE. $2 is the verb the
# prompt opens with. Exits the caller outright on anything meaning "not this
# time" - q, an empty line, or no terminal to read from.
#
# The answer comes back in a variable rather than on stdout because those
# exits have to be the caller's. In `$(tui_pick ...)` the function runs in a
# subshell, where `exit 0` ends the subshell and hands the caller an empty
# string it would then use as an index.
#
# Numbered rather than arrow-driven: phone soft keyboards handle digits far
# better than arrow keys, and dejima-attach is the phone's entry point.
TUI_CHOICE=""
tui_pick() {
local count="$1" verb="$2" choice
TUI_CHOICE=""
printf '\n %s%s%s %s[1-%d, q to quit]%s: ' \
"$C_MUTED" "$verb" "$C_OFF" "$C_TEXT" "$count" "$C_OFF"
# `|| exit 0`, because a read that cannot happen is not a refusal: with no
# controlling terminal (`ssh -T`, or an SSH app that requests no pty) this
# returns nonzero immediately, and exiting 1 with nothing printed reads as
# a broken front door.
read -r choice || exit 0
case "$choice" in
q|Q|'') exit 0 ;;
*[!0-9]*) echo "Not a number." >&2; exit 1 ;;
esac
# Normalized to base 10 once, here, because the range check and the
# subscript the caller does with this do not read a number the same way:
# `[` is POSIX test and decimal-only, while $(( )) reads a leading zero as
# OCTAL. Without this, "010" - which a phone keypad makes easy to type -
# passes the check as 10 and indexes as 8, and the caller acts on a
# DIFFERENT session than the one picked, with nothing printed to say so.
#
# It fixes the two ugly shapes as well. "09" is a value here rather than
# the "invalid octal constant" arithmetic error it is to $(( )); and a
# number past int64 wraps to one the range check can answer for, where
# `[ 99999999999999999999999 -gt 11 ]` instead exits 2 with "integer
# expression expected" - which makes the `if` false, so "Out of range."
# is never printed and the caller dies on an unbound element.
choice="$((10#$choice))"
if [ "$choice" -lt 1 ] || [ "$choice" -gt "$count" ]; then
echo "Out of range." >&2
exit 1
fi
TUI_CHOICE="$choice"
}
+190
View File
@@ -0,0 +1,190 @@
#!/bin/bash
# Says when the dejima checkout a launch is running from is behind the branch
# it tracks. Advisory: it prints and returns, and the session starts either
# way. dejima updates by `git pull`, so "a newer version" means commits on the
# branch this checkout already follows - there is no release channel to ask.
#
# The remote is contacted by URL, from outside the repository, and never by
# remote name. `git ls-remote origin` reads the checkout's own config for how
# to reach that remote, and core.sshCommand, credential.helper and
# url.<base>.insteadOf each name a command git then runs on the host - which
# is measurable, not theoretical. dejima mounts its own checkout writable when
# it is the workspace (bin/dejima warns about this), so a session that wrote
# .git/config there would get host execution out of the *next* launch. That is
# the same class as the core.hooksPath route docs/security.md describes, with
# one difference that matters: this one would fire on its own, where hooksPath
# waits for the operator to run a git command. Reading the URL here and handing
# it to a git started outside the repository leaves none of that config in the
# path, and protocol.allow keeps the transports to the two that cannot name a
# command.
#
# What still crosses that line is the URL itself, which a session with the same
# write can point elsewhere. That costs one outbound request to a host of its
# choosing, and no execution; dejima-audit reports the config change it took.
#
# Runs on the host, before the container exists.
set -uo pipefail
case "${1:-}" in
-h|--help)
cat <<'DEJIMA_UPDATE_CHECK_HELP'
dejima-update-check - say when this dejima checkout is behind its remote
Usage: dejima-update-check
Compares the checkout bin/dejima runs from against the branch it tracks and
prints one line when the remote has commits this checkout does not. Run at
startup by bin/dejima; safe to run by hand.
Silent when the checkout is current or ahead, when [core] update_check is off,
when HEAD is detached or the branch has no counterpart on the remote, and
whenever the remote cannot be reached. A version check that cannot answer is
not a reason to interrupt a launch.
The remote is asked at most once an hour. In between, the answer it last gave
is compared against the current HEAD, so the notice goes away as soon as you
pull rather than an hour later. Nothing is fetched either way - this reads one
branch tip and no objects.
See docs/configuration.md.
DEJIMA_UPDATE_CHECK_HELP
exit 0 ;;
esac
# provider-lib rather than config-lib alone: an unknown key in the config file
# is fatal, and [forge.<name>]/[harness.<name>] keys are only known once the
# manifests are loaded.
# shellcheck source=dejima-provider-lib.sh
source "$(dirname "$(readlink -f "$0")")/dejima-provider-lib.sh"
# shellcheck source=dejima-color-lib.sh
source "$(dirname "$(readlink -f "$0")")/dejima-color-lib.sh"
REPO_ROOT="$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)"
# How long the remote's answer stands before it is asked again. Every launch
# reads the cached tip and compares it to HEAD; only the network call is
# throttled, so this is a bound on staleness of the remote half, not on how
# often the operator is told.
CHECK_INTERVAL=3600
# Alongside everything else dejima keeps on the host. This was under
# XDG_STATE_HOME, which is where a throwaway timestamp belongs by the letter
# of the spec - but it made ~/.local/state a fifth place dejima writes, for
# one file, and `mkdir -p` created that base directory on hosts that had no
# other use for it. One root is worth more than the distinction.
#
# No config key, where the four directories beside it have one: those are
# records an operator reads or a directory that gets mounted, and this is a
# hash that can be deleted at any moment with no loss. Nothing is served by
# being able to move it.
_cache_file() {
printf '%s/.local/share/dejima/update-check' "$HOME"
}
# Only the transports that cannot name a command to run. `ext::` is refused by
# git itself, but the check is written against the shape of the URL rather than
# against one scheme's current default, and a leading dash would be read as an
# option by whatever eventually splits this.
_usable_url() {
case "$1" in
-*|*'::'*) return 1 ;;
https://*|ssh://*|file://*|/*) return 0 ;;
*@*:*) return 0 ;; # scp-like, which is ssh
*) return 1 ;;
esac
}
# The remote's tip for one branch, or nothing. GIT_DIR and GIT_WORK_TREE are
# dropped so an inherited one cannot put a repository's config back in the path
# that -C '/' takes it out of.
_remote_tip() {
local url="$1" ref="$2" 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 --heads "$url" "$ref" 2>/dev/null)" || return 1
printf '%s' "${out%%$'\t'*}"
}
# 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.
[ "$(config_get core update_check 2>/dev/null)" = "on" ] || exit 0
git -C "$REPO_ROOT" rev-parse --git-dir >/dev/null 2>&1 || exit 0
# --quiet so a detached HEAD is silence rather than an error on the terminal.
branch="$(git -C "$REPO_ROOT" symbolic-ref --quiet --short HEAD)" || exit 0
head_sha="$(git -C "$REPO_ROOT" rev-parse HEAD 2>/dev/null)" || exit 0
# The branch's own remote, falling back to origin: a branch created locally has
# no branch.<name>.remote until it is pushed, and comparing it against origin's
# branch of the same name is the right answer when there is one and silence
# when there is not.
remote="$(git -C "$REPO_ROOT" config --get "branch.$branch.remote" 2>/dev/null)"
[ -n "$remote" ] || remote=origin
# The ref it tracks, which is not always its own name - `git worktree add -b x
# origin/development` leaves a branch called x tracking refs/heads/development.
# Asking for refs/heads/x there gets an empty answer and reports nothing, which
# looks exactly like being up to date.
upstream="$(git -C "$REPO_ROOT" config --get "branch.$branch.merge" 2>/dev/null)"
case "$upstream" in refs/heads/*) ;; *) upstream="refs/heads/$branch" ;; esac
url="$(git -C "$REPO_ROOT" config --get "remote.$remote.url" 2>/dev/null)" || exit 0
[ -n "$url" ] || exit 0
_usable_url "$url" || exit 0
cache="$(_cache_file)"
tip=""
if [ -f "$cache" ]; then
read -r c_branch c_tip < "$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:-}"
cached=1
fi
fi
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=""
mkdir -p "$(dirname "$cache")" 2>/dev/null \
&& printf '%s %s\n' "$upstream" "${tip:--}" > "$cache" 2>/dev/null
fi
# "-" is a recorded failure; empty is a branch the remote does not have.
case "${tip:-}" in ""|"-") exit 0 ;; esac
[ "$tip" = "$head_sha" ] && exit 0
# Ahead, or on a branch that has since been rewritten: having the commit and
# it being an ancestor of HEAD are two different questions, and only the second
# means there is nothing to report.
count=""
if git -C "$REPO_ROOT" cat-file -e "$tip^{commit}" 2>/dev/null; then
git -C "$REPO_ROOT" merge-base --is-ancestor "$tip" HEAD 2>/dev/null && exit 0
count="$(git -C "$REPO_ROOT" rev-list --count "HEAD..$tip" 2>/dev/null)"
fi
color_init
# Both come out of the checkout's .git/config, which is session-writable when
# dejima's own checkout is the workspace - the same reason the remote is
# reached by URL rather than by name. A name carrying escape sequences would
# fire on the terminal of the operator this is trying to inform.
_remote_shown="$(sanitize "$remote")"
_branch_shown="$(sanitize "${upstream#refs/heads/}")"
# The count only when it is known. It is not, whenever the remote's tip is a
# commit this checkout has never fetched, which is the common case - and a
# number invented for those would be the number of commits it can see, not the
# number there are.
if [ -n "$count" ] && [ "$count" != "0" ]; then
if [ "$count" = "1" ]; then noun="commit"; else noun="commits"; fi
printf '%sdejima%s: %s/%s has %s %s this checkout does not.\n' \
"$C_KEY" "$C_RESET" "$_remote_shown" "$_branch_shown" "$C_INFO$count$C_RESET" "$noun" >&2
else
printf '%sdejima%s: %s/%s has commits this checkout does not.\n' \
"$C_KEY" "$C_RESET" "$_remote_shown" "$_branch_shown" >&2
fi
printf ' Update with %sgit -C %s pull%s\n' "$C_KEY" "$REPO_ROOT" "$C_RESET" >&2
+42 -4
View File
@@ -10,8 +10,17 @@
# reason this is code is the pairing in the second block, which resolves one
# directory tree through the filenames of another.
# Registers the waiting/done notification handlers into dejima's settings
# layer, in the shape Claude Code reads them.
# Registers the `done` notification handler into dejima's settings layer, in
# the shape Claude Code reads them.
#
# Stop only, though the hook and the host-side watcher both understand
# `waiting` as well. Claude Code raises Notification when it needs permission
# to run something and when the prompt has sat idle, and a dejima session runs
# with permission prompts off - so the first never happens, and what is left
# is a timer that expires shortly after the Stop it follows. Registering both
# put two pushes on the operator's phone for one event, a minute apart, the
# second saying nothing the first had not. `waiting` stays in the enum for a
# harness that can say it without having stopped first.
#
# Claude Code merges hook entries across settings layers rather than letting
# the higher one replace the lower, so these are added to whatever the
@@ -25,8 +34,7 @@
harness_register_notify() {
local settings="$1" tmp
tmp="$(mktemp "${TMPDIR:-/tmp}/dejima-settings-remote.XXXXXX.json")"
if jq '.hooks.Notification = ((.hooks.Notification // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/dejima-notify.sh waiting"}]}])
| .hooks.Stop = ((.hooks.Stop // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/dejima-notify.sh done"}]}])' \
if jq '.hooks.Stop = ((.hooks.Stop // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/dejima-notify.sh done"}]}])' \
"$settings" > "$tmp"; then
mv "$tmp" "$settings"
else
@@ -444,6 +452,36 @@ harness_transcript_files() {
done < <(workspace_slugs_sorted "$ws")
}
# Which sessions still have a transcript on disk, as "<key>\t<session-id>"
# lines. `dejima-epitaph list` matches an epitaph's own session id against
# this to say whether resuming the real session is still on offer, and it has
# no way to work that out for itself: the id is in the filename here and is
# the directory name for the next harness.
#
# $1 is a workspace, or absent for every key this harness holds transcripts
# under - which is what `list --all` needs, since a store key is a slug and a
# slug does not resolve back to a path.
harness_live_sessions() {
local ws="${1:-}" projects key f d
projects="$(harness_config_get projects_dir)"
[ -n "$projects" ] || return 0
if [ -n "$ws" ]; then
while IFS=$'\t' read -r key f; do
[ -n "$f" ] || continue
printf '%s\t%s\n' "$key" "$(basename "$f" .jsonl)"
done < <(harness_transcript_files "$ws")
return 0
fi
for d in "$projects"/*/; do
[ -d "$d" ] || continue
key="$(basename "$d")"
for f in "$d"/*.jsonl; do
[ -f "$f" ] || continue
printf '%s\t%s\n' "$key" "$(basename "$f" .jsonl)"
done
done
}
# Compaction summaries out of this harness's transcripts.
#
# Claude Code writes line-delimited JSON, one record per line, with the
+1 -1
View File
@@ -218,7 +218,7 @@ harness_prepare_config() {
echo "yourself, which needs no key. That means a loopback or RFC 1918" >&2
echo "address, or a host you have allowlisted:" >&2
echo "" >&2
echo " bin/dejima-allow --project <path> <host>" >&2
echo " dejima-allow --project <path> <host>" >&2
echo "" >&2
echo "The allowlist is what decides whether the container can reach it" >&2
echo "at all, so an endpoint that fails this check is one the session" >&2
+11
View File
@@ -301,6 +301,17 @@ COPY docker/install-forge-bins.sh /tmp/install-forge-bins.sh
RUN bash /tmp/install-forge-bins.sh /tmp/forges /usr/local/lib/dejima/forge /usr/local/bin \
&& rm -rf /tmp/forges /tmp/install-forge-bins.sh
# The other half of NODE_PATH, which only ever applied to require(): ESM
# resolution ignores it entirely and walks up from the importing file looking
# for node_modules, so `import { chromium } from "playwright"` fails from
# every directory a script is likely to be written in while require() of the
# same package succeeds. At the root, that walk always ends here.
#
# It is worth a line because of how the failure reads. An agent writing the
# modern form gets ERR_MODULE_NOT_FOUND, concludes the image has no browser,
# and describes what a page should look like instead of going and seeing.
RUN ln -s /usr/local/share/npm-global/lib/node_modules /node_modules
RUN chmod +x /usr/local/bin/dns-proxy.sh /usr/local/bin/init-firewall.sh /usr/local/bin/log-shipper.sh /usr/local/bin/entrypoint.sh /usr/local/bin/dejima-harness-wrapper /usr/local/lib/dejima/git-guard.sh /usr/local/lib/dejima/forge-guard.sh /usr/local/lib/dejima/dejima-briefing-render.sh && \
echo 'export PATH="$PATH:/usr/local/share/npm-global/bin"' > /etc/profile.d/npm-global-path.sh
+48 -1
View File
@@ -34,7 +34,13 @@ services:
# $SANDBOX_IMAGE lets bin/dejima point a run at a derived,
# per-project image instead, without this build ever producing one
# under that name itself - see bin/dejima.
image: ${SANDBOX_IMAGE:-dejima:${SANDBOX_USER:-node}}
# localhost/ so this can never resolve to Docker Hub: Docker reads a first
# component containing a dot or colon, or exactly "localhost", as a
# registry host, and a bare `dejima:node` is `docker.io/library/dejima:node`
# to anything that pushes (#53). bin/dejima holds the same prefix in
# SANDBOX_IMAGE_REPO and passes it in; this default is for a compose
# invocation by hand.
image: ${SANDBOX_IMAGE:-localhost/dejima:${SANDBOX_USER:-node}}
# Run tini as PID 1 so orphaned processes get reaped. Without it PID 1 is
# the session's own command (claude, or nvim under SANDBOX_NVIM_CONFIG),
# which never reaps: every process-substitution child the test suite and
@@ -42,9 +48,50 @@ services:
# ten per run - becomes a permanent zombie, and a long session eventually
# exhausts the cgroup pids limit and can no longer fork at all.
init: true
# An explicit floor rather than Docker's default set plus two. The default
# carries about fourteen capabilities and this container uses seven: it
# writes and chowns files, runs the firewall, drops privilege twice (gosu
# for the session, `ulogd -u` for the log daemon), and signals dnsmasq.
# FSETID, MKNOD, SETFCAP, SETPCAP, SYS_CHROOT, AUDIT_WRITE and
# NET_BIND_SERVICE are used by nothing here - the last one because the
# sysctl below is what lets the TLS terminator bind :443 unprivileged.
#
# KILL is the one worth explaining, because it is not obvious from reading
# entrypoint.sh and was missing from the first version of this list. It is
# not for anything the entrypoint does. `dejima-allow --container` adds a
# domain to a RUNNING session by appending a server= line and sending
# dnsmasq a SIGHUP, from outside via docker exec - and dnsmasq drops to its
# own user (`user=dnsmasq`), so root in this container cannot signal it
# without CAP_KILL. Without this line, adding a domain mid-session fails
# with "Operation not permitted", which is a documented feature every
# session briefing tells the agent to ask for.
#
# The general lesson, since the next capability will be the same shape:
# what the container needs is not only what its entrypoint does. Things
# reach into it later.
#
# This narrows the ROOT phase. The session user's capabilities were already
# empty: they are cleared on the gosu drop, which runtime.bats asserts by
# reading CapEff of the running process. What this adds is that the set was
# never there to be regained.
cap_drop:
- ALL
cap_add:
- NET_ADMIN
- NET_RAW
- CHOWN
- DAC_OVERRIDE
- FOWNER
- SETUID
- SETGID
- KILL
# No path back up. gosu drops privilege rather than gaining it, so this
# does not interfere with it - what it forbids is a setuid binary or a file
# capability raising privileges on exec, which is the shape any escalation
# from the session user would have to take. The gosu claim becomes
# structural instead of only observed.
security_opt:
- no-new-privileges:true
# The credential-proxy TLS terminator binds :443 as the unprivileged
# sandbox user (see entrypoint.sh). Docker already defaults this to 0, but
# pinning it makes the dependency explicit: a future default change then
+197 -17
View File
@@ -1,7 +1,7 @@
#!/bin/bash
# Shadows a forge CLI on PATH, so that two rules hold whatever ran it: the one
# subcommand a workflow blocks is refused, and a body the session authored says
# so.
# Shadows a forge CLI on PATH, so that three rules hold whatever ran it: the
# 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
@@ -26,6 +26,12 @@
# "pr gitea-pr create". Both are resolved host-side by
# bin/dejima from the forge's manifest, never hardcoded
# to a forge here.
# DEJIMA_FORGE_REPO the "<owner>/<name>" of the repository the session
# was launched against, and DEJIMA_FORGE_REPO_FLAGS /
# DEJIMA_FORGE_REPO_ENV / DEJIMA_FORGE_REPO_PATHS the
# spellings this forge's CLI accepts for naming a
# different one: a flag, an environment variable, and a
# path segment under a named subcommand.
# DEJIMA_FORGE_REAL_DIR where the real CLIs live, behind the shadow.
set -u
@@ -39,7 +45,9 @@ fi
# Outside a dejima container, or with nothing to do, this is a pass-through.
if [ -z "${DEJIMA:-}" ] ||
{ [ -z "${DEJIMA_MERGE_BLOCK:-}" ] && [ -z "${DEJIMA_DISCLOSE:-}" ]; }; then
{ [ -z "${DEJIMA_MERGE_BLOCK:-}" ] && [ -z "${DEJIMA_DISCLOSE:-}" ] &&
[ -z "${DEJIMA_FORGE_REPO_FLAGS:-}" ] && [ -z "${DEJIMA_FORGE_REPO_ENV:-}" ] &&
[ -z "${DEJIMA_FORGE_REPO_PATHS:-}" ]; }; then
exec "$real" "$@"
fi
@@ -100,6 +108,125 @@ if [ -n "${DEJIMA_MERGE_BLOCK:-}" ]; then
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
fi
# --- the authorship disclosure ---------------------------------------------
[ -n "${DEJIMA_DISCLOSE:-}" ] || exec "$real" "$@"
@@ -141,16 +268,71 @@ marker="$(marker_for "$kind")"
# A command with no body argument is passed through untouched: there is
# nothing to disclose on. `gh pr create --fill` is that case - its body comes
# from commit messages, which are prose the session wrote somewhere else.
read_body() {
if [ "$1" = "-" ]; then cat; else cat -- "$1"; fi
# The CLI behind this validates its own --body-file and then never gets the
# chance to: what reaches it is a temp file that always exists. So the check
# happens here instead, and refuses rather than sending something the operator
# did not write.
#
# Without it a mistyped path produced a PR whose entire body was the marker -
# created, not refused - and a --body-file naming something unreadable-forever
# (/dev/zero) filled a temp file until the disk did. Both are shapes the CLIs
# already refused before this shim stood in front of them.
body=""
read_body_or_die() {
if [ "$1" = "-" ]; then
body="$(cat)"
return 0
fi
if [ ! -f "$1" ]; then
echo "dejima: --body-file names something that is not a readable file: $1" >&2
exit 1
fi
body="$(cat -- "$1")" || exit 1
}
# The path to a temp file holding the marker and the body. Set outside the
# command substitution as well, so the caller can delete it afterwards.
# The body with the marker on it, whichever way the body arrived.
#
# One function for all four spellings, because there were two: --body-file
# went through here and --body prepended the marker inline at its own call
# site, so a rule added in one place was not in force in the other. That is
# the whole reason this exists as a function rather than as a printf repeated
# four times.
#
# Idempotent, which is the rule that was missing. An agent that has read the
# project's existing issues has seen every one of them open with this line,
# and writes it into the body itself - at which point a second was added and
# the artifact said it twice. Each doubled issue then taught the next agent to
# do the same, so it spread rather than settling.
#
# Skipping is safe because the marker is a statement, not a count: a body that
# already opens by saying an agent wrote it has disclosed exactly what this
# exists to disclose. Anchored at the start, after leading whitespace - the
# same words further down are prose ABOUT disclosure rather than the
# disclosure, and still get one prepended.
#
# The whole first LINE has to be the marker, not just begin with it. A prefix
# test let a body open `**Filed by LLM agent** - reviewed and confirmed by a
# person` and skip, which makes the marker line itself agent-authorable: the
# one line here exists to write is the one the session gets to finish. Trailing
# whitespace is allowed because it is invisible and changes nothing.
with_marker() {
local head first
head="${1#"${1%%[![:space:]]*}"}"
first="${head%%$'\n'*}"
first="${first%"${first##*[![:space:]]}"}"
if [ "$first" = "$marker" ]; then
printf '%s' "$1"
return 0
fi
printf '%s\n\n%s' "$marker" "$1"
}
# The path to a temp file holding the disclosed body. Set outside the command
# substitution as well, so the caller can delete it afterwards.
tmp=""
disclosed() {
tmp="$(mktemp)" || exit 1
printf '%s\n\n%s' "$marker" "$1" > "$tmp" || exit 1
with_marker "$1" > "$tmp" || exit 1
}
# Only the first body argument is rewritten. A second would mean the CLI is
@@ -164,7 +346,8 @@ while [ "$#" -gt 0 ]; do
# Left alone when the value is missing: that is the CLI's
# error to give, not this script's to pre-empt.
if [ "$#" -ge 2 ]; then
disclosed "$(read_body "$2")"
read_body_or_die "$2"
disclosed "$body"
args+=(--body-file "$tmp")
rewrote=1
shift 2
@@ -172,7 +355,8 @@ while [ "$#" -gt 0 ]; do
fi
;;
--body-file=*)
disclosed "$(read_body "${1#--body-file=}")"
read_body_or_die "${1#--body-file=}"
disclosed "$body"
args+=("--body-file=$tmp")
rewrote=1
shift
@@ -180,18 +364,14 @@ while [ "$#" -gt 0 ]; do
;;
--body)
if [ "$#" -ge 2 ]; then
args+=(--body "$marker
$2")
args+=(--body "$(with_marker "$2")")
rewrote=1
shift 2
continue
fi
;;
--body=*)
args+=("--body=$marker
${1#--body=}")
args+=("--body=$(with_marker "${1#--body=}")")
rewrote=1
shift
continue
+46 -1
View File
@@ -49,14 +49,59 @@ ipset destroy github-domains 2>/dev/null || true
# level, e.g. no ip6_tables module) - once it's confirmed to work at all, a
# failure partway through must fail the whole script (set -e, no more
# blanket `|| true`) rather than silently leaving IPv6 wide open.
#
# The `else` below used to log that the family was presumably off at the
# kernel level, and continue. That inferred a fact about the WORLD from a fact
# about a TOOL, and the two are not the same condition: a container whose kernel
# lacks ip6_tables, on a host with IPv6 enabled, took that branch, logged a
# reassuring line, and ran with IPv6 live and unfiltered. Nothing failed,
# because the only thing that had happened was a check that could not tell.
#
# So the branch asks the kernel instead. /proc/net/if_inet6 lists every IPv6
# address on every interface, and it is the netns's own view - present with
# `::1` alone when the stack is up and nothing is reachable, absent entirely
# when IPv6 is compiled out.
#
# Loopback does not count. Refusing to start over `::1` would refuse every
# container that has no IPv6 connectivity at all, which is a false refusal
# for the common case. What matters is an address on an interface that leaves
# the box.
_ipv6_off_box() {
[ -e /proc/net/if_inet6 ] || return 1
# address ifindex prefixlen scope flags devname
awk '$6 != "lo" { found = 1 } END { exit !found }' /proc/net/if_inet6
}
if ip6tables -L >/dev/null 2>&1; then
ip6tables -F
ip6tables -X
ip6tables -P INPUT DROP
ip6tables -P OUTPUT DROP
ip6tables -P FORWARD DROP
elif _ipv6_off_box; then
echo "ip6tables is unusable here, and IPv6 is live on an interface that" >&2
echo "leaves this container. Turning the family off in this network" >&2
echo "namespace, since it cannot be filtered." >&2
# Written to /proc directly rather than through sysctl(8), which is one
# more thing that has to be in the image for the firewall to hold. Per
# interface as well as `all`, because which of those flushes existing
# addresses has varied across kernels.
for _conf in /proc/sys/net/ipv6/conf/*/disable_ipv6; do
[ -w "$_conf" ] || continue
echo 1 > "$_conf" 2>/dev/null || true
done
if _ipv6_off_box; then
echo "" >&2
echo "IPv6 is still up and there is no way to filter it from here, so" >&2
echo "every rule below would be bypassable by an AAAA-resolved address." >&2
echo "Refusing to start rather than reporting a boundary that is not" >&2
echo "there. Either give the container a usable ip6tables (the" >&2
echo "ip6_tables module on the host), or disable IPv6 for its network." >&2
exit 1
fi
echo "IPv6 is off in this namespace."
else
echo "ip6tables unusable - assuming IPv6 is unavailable at the kernel level"
echo "ip6tables unusable, and no IPv6 address outside loopback - nothing to filter"
fi
# Reinsert just the rules captured above - nothing else Docker had set up in
+300 -144
View File
@@ -2,10 +2,10 @@
[← dejima README](../README.md)
Day-to-day configuration once dejima is installed and running: what a
project declares versus what you approve, config parity with your host agent
harness setup, a different editor, writable host directories, shared
scratch space, frontend testing, and what the sandbox knows about itself.
Day-to-day configuration once dejima is installed and running. The reference
table below is every key there is; the sections after it cover what a project
declares versus what you approve, what carries over from your host, mounts and
scratch space, and what the sandbox knows about itself.
## Configuration reference
@@ -16,22 +16,30 @@ set to get a working sandbox: uncomment what you need. `dejima --print-config`
prints the resolved schema with real values, which is the fastest way to see
what a given launch will actually use.
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.
| Key | Default | Purpose |
| --- | --- | --- |
| `[core] harness` | *(none - must be set)* | agent harness the container runs; refused if unset, so a later default cannot change your selection. Also settable per launch with `--harness`, which is how a host keeps one harness as its default and reaches another when it wants to |
| `[core] forge` | `none` | forge integration: `gitea` (`gitea-pr`/`gitea-issue`), `github` (`gh`, opens `ssh.github.com:22` for push), or `none` (no forge at all) |
| `[core] forge` | `none` | forge integration: `gitea` (`gitea-pr`/`gitea-issue`), `github` (`gh`/`github-pr`, opens `ssh.github.com:22` for push), or `none` (no forge at all) |
| `[core] workflow` | `local-only` | workflow `sandbox-context.sh` tells the agent to follow: `worktree-pr` (push + open a PR), `branch-push` (push, stop at the branch), or `local-only` (commit locally, push nothing) |
| `[core] command` | `claude` | container's main process (e.g. `nvim`), also settable per launch with `--cmd` |
| `[core] command` | *(the selected harness's own `command`)* | container's main process (e.g. `nvim`), also settable per launch with `--cmd`. Overriding it changes what dejima launches, not which harness is wrapped |
| `[core] identity` | unset | which `[identity.<name>]` section supplies the credentials and git name this session acts under; also settable per launch with `--identity`, which is how one host runs a session per account. Unset selects `[identity.default]` where the config has one. A name with no section is refused rather than falling back |
| `[core] tools` | unset | optional tools to put in the image, colon-separated; also `.dejima/tools` in a project (approved first) and `--include` per launch. None by default. See [docs/tools.md](tools.md) |
| `[core] update_check` | `on` | at startup, say when this checkout is behind the branch it tracks. One `ls-remote` to your own remote, at most hourly, never fetching; `off` makes dejima contact nothing of its own accord. The answer is cached in `~/.local/share/dejima/update-check`, which is safe to delete |
| `[ui] banner` | `on` | the three-line summary at the top of a launch - project and branch, the selection, and the switches whose state changes what a session can do. `off` prints nothing. Never drawn when output is redirected |
| `[core] firewall` | `on` | egress firewall; `off` disables it (debugging only), also settable per launch with `--no-firewall` |
| `[core] share_dir` | `~/.local/share/dejima/shared` | host-visible read/write scratch dir, mounted at `~/shared` |
| `[core] ssh_key` | unset | dedicated deploy key for headless/unattended git push |
| `[core] ssh_key` | unset | **deprecated** - the pre-identity spelling of `[identity.default] ssh_key`. Still read when the config has no `[identity.default]`; refused when it has one, since the two say the same thing |
| `[core] known_hosts` | `~/.ssh/known_hosts` | known-hosts file used for git-over-ssh |
| `[harness.claude-code] projects_dir` | `~/.claude/projects` | where Claude Code session transcripts live |
| `[harness.claude-code] file_history_dir` | `~/.claude/file-history` | Claude Code's edit-undo record, mounted per session rather than whole |
| `[harness.claude-code] statusline_path` | `~/.claude/statusline.sh` | statusline script path |
| `[harness.cline] model` | *(none - must be set)* | model id the endpoint serves; no default, because any shipped one goes stale with the next Cline release |
| `[harness.cline] base_url` | unset | OpenAI-compatible endpoint. Unset means Anthropic through dejima's credential proxy. Accepts a loopback or RFC 1918 address, or a host you have allowlisted |
| `[harness.cline] context_window` | unset | tokens Cline may use before compacting. Needed for a model it has no catalogue entry for, which is every self-served one |
| `[harness.cline] model` | *(none - must be set)* | model id the endpoint serves; no default, because any shipped one goes stale with the next Cline release. Also settable per launch with `--model` |
| `[harness.cline] base_url` | unset | OpenAI-compatible endpoint. Unset means Anthropic through dejima's credential proxy. Accepts a loopback or RFC 1918 address, or a host you have allowlisted. Also settable per launch with `--base-url` |
| `[harness.cline] context_window` | unset | tokens Cline may use before compacting. Needed for a model it has no catalogue entry for, which is every self-served one. Also settable per launch with `--context-window` |
| `[harness.cline] state_dir` | `~/.local/share/dejima/cline` | per-project Cline state; one directory per workspace |
| `[forge.gitea] ssh_host` | unset | Gitea/Forgejo SSH hostname: skip if you only push to GitHub |
| `[forge.gitea] api` | unset | `https://<gitea host>/api/v1`: skip if you only push to GitHub |
@@ -42,21 +50,25 @@ what a given launch will actually use.
| `[mounts] read_write` | unset | host paths (colon-separated) mounted **read-write** at their own path: for directories meant to outlive the session; refused if they overlap anything dejima needs out of a session's reach |
| `[mounts] nvim_config` | unset | nvim config dir, used when `[core] command = nvim` |
| `[credentials] proxy` | `on` | host-side credential proxy; `off` mounts the real OAuth credential into the container directly |
| `[credentials] transport` | `tls` | set to `loopback` to fall back to a plaintext forwarder with an explicit `ANTHROPIC_BASE_URL`, keeping the credential out of the container but reinstating the base-URL fingerprint: for when certificate pinning breaks the default TLS path (see [security notes](docs/security.md)) |
| `[logging] loki_url` | unset | ship DNS/firewall logs to a Loki endpoint |
| `[epitaph] enabled` | `on` | keep the summary Claude Code writes when a session compacts |
| `[credentials] transport` | `tls` | set to `loopback` to fall back to a plaintext forwarder with an explicit `ANTHROPIC_BASE_URL`, keeping the credential out of the container but reinstating the base-URL fingerprint: for when certificate pinning breaks the default TLS path (see [security notes](security.md)) |
| `[logging] loki_url` | unset | ship DNS/firewall logs to a Loki endpoint; `--no-log` clears it for one launch, which silences everything downstream of it |
| `[epitaph] enabled` | `on` | keep the summary a harness writes when a session compacts; `--no-epitaph` skips it for one launch |
| `[epitaph] dir` | `~/.local/share/dejima/epitaphs` | where they are kept; never mounted into a container |
| `[epitaph] loki` | `off` | also push each epitaph to `[logging] loki_url`, host-side |
| `[logging] conversation` | `off` | opt-in: also ship session transcripts to Loki (needs `loki_url`); see "Conversation logging (opt-in)" in docs/security.md |
| `[logging] conversation` | `off` | opt-in: also ship session transcripts to Loki (needs `loki_url`); see "Conversation logging (opt-in)" in docs/security.md. `--no-conversation` turns it off for one launch, and `--no-log` implies it |
| `[audit] dir` | `~/.local/share/dejima/audit` | where exit-time audit findings are stored |
| `[audit] baselines_dir` | `~/.local/share/dejima/baselines` | where audit baselines are stored |
| `[audit] keep` | `10` | how many past findings/baselines to retain |
| `[audit] strict` | `off` | refuse a headless (non-tty) launch over unacknowledged CRITICAL findings |
| `[audit] diffs` | `on` | keep the diff of what changed alongside each finding; `off` records that something changed without retaining the content |
| `[audit] strict` | `off` | refuse a headless (non-tty) launch over unacknowledged CRITICAL findings; also settable per launch with `--strict-audit` |
| `[remote-access] enabled` | `off` | the switch: nothing in "Remote access" below runs until this is `on` |
| `[remote-access] ntfy_url` | unset | ntfy topic URL for session notifications |
| `[remote-access] base_url` | unset | public URL of the web front door, for notification links |
| `[remote-access] ttyd_port` | `7681` | port `ttyd` listens on |
| `[remote-access] ttyd_font_size` | unset | `ttyd` terminal font size |
| `[remote-access] ttyd_tmux_prefix` | unset | the tmux prefix the page's buttons send; the page assumes stock `C-b` without it |
| `[remote-access] ttyd_focus_on_tap` | unset | `on` lets a tap on the terminal raise the phone keyboard; unset means only the KBD button does |
| `[remote-access] ttyd_button_colors` | unset | `off` returns the toolbar to monochrome |
| `[remote-access] debounce` | `60` | seconds between repeat notifications for the same session |
**No forge is the default**: `[core] forge = none` with
@@ -67,12 +79,11 @@ opting into when you pick one.
## Global and per-project domains and dependencies
Allowed domains and dependencies each stack through their own layers. The
layers **add** to each other, so a narrower layer can only widen what a
broader one already permits: nothing at the project layer can withdraw a
built-in domain, and nothing at the container layer can withdraw a project
one. Ordering them least specific first is how they are listed below and how
they are applied, and it means the set a session gets is the union of every
layer that applies to it, never an override of one by another.
layers **add** to each other, so a narrower layer can only widen what a broader
one already permits: the set a session gets is the union of every layer that
applies to it, never an override of one by another. Nothing at the project
layer can withdraw a built-in domain, and nothing at the container layer can
withdraw a project one.
| Config | Layers, least specific first |
| --- | --- |
@@ -91,61 +102,79 @@ A project can declare what it needs in two committed files:
# tarball:<name>@<sha256>:<url> release archive
```
### Declaring that the history is evidence
**Declaring grants nothing.** The project directory is bind-mounted
read-write, so a session could write these files; they are read only to
compare against reality and suggest a command. Approvals live outside the
container under `~/.config/dejima/` (`$DEJIMA_CONFIG_DIR` relocates the
tree) - global at its root, per project under `projects/<slug>/` - written
only by tools you run on the host:
A third file, approved the same way as the two above:
```
.dejima/corpus # one rule per line; today, commit-per-step
```sh
dejima-project status ~/myproject # declared vs approved vs missing
dejima-project approve ~/myproject # review and approve the declared set
dejima-allow --project ~/myproject crates.io
dejima-allow --global crates.io # applies to every project
dejima-dep add --project ~/myproject cargo:cargo-nextest
dejima-dep add --global cargo:cargo-nextest
```
An approved `commit-per-step` adds a paragraph to the session briefing
requiring **one commit per plan step that writes a file**, rather than one
commit per task, and explaining why. Absent or unapproved, nothing is added
and nothing changes, which is the default for every project that has not asked
for it.
Two of those tools also write the declaration, so an approval you make by
hand does not stay invisible to everyone else on the project:
`dejima-allow --project` appends to `.dejima/allowed-domains`, and
`dejima-dep add --project` appends to `.dejima/dependencies`. Both write the
approval first - that is the part that grants - and report separately on the
declaration, so a project you can't write to still gets you the approval.
`--global` writes no declaration at all: there is no project to declare in.
It goes through approval despite granting no capability, and that is
deliberate. `.dejima/` lives in the workspace, which is session-writable, so a
session able to turn this on by writing a file would be writing the *next*
session's system prompt. `bin/dejima` reads the approval store and never a
project's declarations - a static test holds that it does not so much as name
that directory.
The declaration those two write still grants nothing, and writing to it does
not promote it. That it is session-writable is exactly why both tools refuse to
write a `.dejima` directory or a `.dejima/<kind>` file that is a **symlink**.
The refusal is the `open` itself rather than a check before it, so it holds even
against a session still running while you type the command - see
[Declaration files are session-writable](security.md#declaration-files-are-session-writable).
The same write reads the file back to skip an already-declared entry, and that
read is capped, since a session can make its own `.dejima/<kind>` arbitrarily
large for free.
An unknown rule is reported but never offered for approval. One that nothing
implements would approve cleanly, sit in the store and do nothing, which reads
as being in force when it is not.
Neither writer has an inverse for the declaration. `dejima-dep remove
--project` withdraws the approval and deliberately leaves the declaration
alone (it says so, since `dejima-project approve` will otherwise offer the
entry again); `dejima-allow --project` has no `remove` at all. Taking
something back from the project itself means editing `.dejima/` and
committing that.
**Check the forge's merge policy before relying on this.** The briefing tells
the agent how to commit, but the setting that can undo all of it is yours: a
squash-merge is survivable, because a clone fetches remote branches and the
step commits stay reachable through the branch ref, but deleting the branch
afterwards removes the last thing pointing at them. Several forges offer
"delete branch on merge" and some default it on. Measured both ways on a
throwaway repository: squash-merge with the branch retained left every step
commit reachable in a fresh clone; the same merge with the branch deleted first
left none.
`dejima-project status`/`approve` classify a declared entry against
the union of global and project approvals, so something already approved
globally shows as approved, not pending. Unlike `dejima-allow`,
`dejima-dep`'s `--global` and `--project` are mutually exclusive -
an entry belongs to exactly one scope. `dejima-dep` has no `--container`
scope either, and refuses the flag rather than ignoring it: a dependency
needs an image rebuild, so there is nothing to patch in a live session.
So a project can adopt the rule, write every plan correctly, and still yield
nothing, with no error anywhere - a squash-merged `main` looks perfectly
healthy. dejima's own history is intact because it merges with merge commits.
Approved domains join the allowlist at the next launch. `dejima-allow` can also
patch a running session immediately, but only when run bare or with
`--container` - approving here (`--project`/`--global`) never touches one; see
[Adding a domain to a running session](security.md#adding-a-domain-to-a-running-session).
A domain that resolves to a private address has no live patch at all. Approved
dependencies are baked into a derived image built `FROM` the shared base -
first `dejima:<username>-global` if the global store has any dependency
approved, then `dejima:<username>-<slug>` on top of that if the project store
does - so they take effect on the **next** launch. Because of that rebuild, the
usual moment to approve is *after* leaving the sandbox, and persistence never
requires a running container.
The reason is recoverability rather than tidiness. A step is only legible
afterwards if the file's state immediately before it and immediately after it
can both be read back from history: before from the commit's parent, after
from the commit. One commit carrying several steps that touch the same file
destroys that, because no step in it can be isolated any more, and
reconstructing one would mean building a file state the repository never held.
Nothing recovers that later, so it has to be recorded while the work happens.
`dejima-project approve` never attempts to patch a running session: it only
ever calls `dejima-allow` without `--container`. If an individual approval
can't be recorded - an unwritable store, a slug collision, a corrupt approval
list - it reports that entry as not approved and keeps going, so one bad entry
can't cost you the rest of the set. It then exits non-zero if anything failed,
so `dejima-project approve ~/myproject && dejima` won't launch a session
missing the approvals you just confirmed.
It lives in the project rather than in `~/.config/dejima/config` because it is
a fact about the repository. Anyone who clones it and runs dejima gets the same
commit discipline, which is what makes the history uniformly usable: a rule
half the contributors follow produces a history nothing can be taken from.
What dejima does not do is say what the history is *for*. Collecting it, and
deciding whether to, is the operator's business; the briefing states the rule
the project already adopted and stops there.
A session whose project declares something it doesn't have is told so in its
startup context, with the exact command to hand you. Project approvals are
inherited by subdirectories, so a git worktree under the project gets its
parent's; global approvals apply everywhere regardless.
### Tarball dependencies
@@ -194,88 +223,53 @@ interpolated into a `RUN` line that runs as root at build time, so anything
shell-active is refused outright rather than quoted around, and only `https`
is accepted.
**Declaring grants nothing.** The project directory is bind-mounted
read-write, so a session could write these files; they are read only to
compare against reality and suggest a command. Approvals live outside the
container under `~/.config/dejima/` (`$DEJIMA_CONFIG_DIR` relocates the
tree) - global at its root, per project under `projects/<slug>/` - written
only by tools you run on the host:
### Declaring that the history is evidence
```sh
dejima-project status ~/myproject # declared vs approved vs missing
dejima-project approve ~/myproject # review and approve the declared set
dejima-allow --project ~/myproject crates.io
dejima-allow --global crates.io # applies to every project
dejima-dep add --project ~/myproject cargo:cargo-nextest
dejima-dep add --global cargo:cargo-nextest
A third file, approved the same way as the two above:
```
.dejima/corpus # one rule per line; today, commit-per-step
```
Two of those tools also write the declaration, so an approval you make by
hand does not stay invisible to everyone else on the project:
`dejima-allow --project` appends to `.dejima/allowed-domains`, and
`dejima-dep add --project` appends to `.dejima/dependencies`. Both write the
approval first - that is the part that grants - and report separately on the
declaration, so a project you can't write to still gets you the approval.
`--global` writes no declaration at all: there is no project to declare in.
An approved `commit-per-step` adds a paragraph to the session briefing
requiring **one commit per plan step that writes a file**, rather than one
commit per task, and explaining why. Absent or unapproved, nothing is added
and nothing changes, which is the default for every project that has not asked
for it.
The declaration those two write still grants nothing; it is the same
untrusted file the section above describes, and writing to it does not
promote it. That it is session-writable is exactly why both tools refuse to
write a `.dejima` directory or a `.dejima/<kind>` file that is a **symlink**.
The refusal is the `open` itself rather than a check before it, so it holds
even against a session that is still running while you type the command. See
[Declaration files are session-writable](security.md#declaration-files-are-session-writable)
for what that defends against and how. The same write
reads the file back to skip an already-declared entry, and that read is
capped - a session can make its own `.dejima/<kind>` arbitrarily large for
free, and an oversized one is refused rather than loaded into your memory.
It goes through approval despite granting no capability, and that is
deliberate. `.dejima/` lives in the workspace, which is session-writable, so a
session able to turn this on by writing a file would be writing the *next*
session's system prompt. `bin/dejima` reads the approval store and never a
project's declarations - a static test holds that it does not so much as name
that directory.
Neither writer has an inverse for the declaration. `dejima-dep remove
--project` withdraws the approval and deliberately leaves the declaration
alone (it says so, since `dejima-project approve` will otherwise offer the
entry again); `dejima-allow --project` has no `remove` at all. Taking
something back from the project itself means editing `.dejima/` and
committing that.
An unknown rule is reported but never offered for approval. One that nothing
implements would approve cleanly, sit in the store and do nothing, which reads
as being in force when it is not.
`dejima-project status`/`approve` classify a declared entry against
the union of global and project approvals, so something already approved
globally shows as approved, not pending. Unlike `dejima-allow`,
`dejima-dep`'s `--global` and `--project` are mutually exclusive -
an entry belongs to exactly one scope. `dejima-dep` has no `--container`
scope either, and refuses the flag rather than ignoring it: a dependency
needs an image rebuild, so there is nothing to patch in a live session.
The reason is recoverability rather than tidiness. A step is legible afterwards
only if the file's state immediately before and immediately after it can both
be read back: before from the commit's parent, after from the commit. One
commit carrying several steps that touch the same file destroys that, and
nothing recovers it later, so it has to be recorded while the work happens.
Approved domains join the allowlist at the next launch. `dejima-allow` can
also patch a running session immediately, but only when it is run bare or
with `--container` - approving here (`--project`/`--global`, with no
`--container`) never touches one; see
[Adding a domain to a running session](security.md#adding-a-domain-to-a-running-session).
A domain that resolves to a private address has no live patch at all - an
internally-routed host can only be approved and picked up at the next launch.
Approved dependencies are baked into a derived image built `FROM` the shared
base - first `dejima:<username>-global`, if the global store has any
dependency approved, then `dejima:<username>-<slug>` on top of that,
if the project store does - so they need an image rebuild: they take effect
on the **next** launch.
**Check the forge's merge policy before relying on this.** The setting that can
undo all of it is yours. A squash-merge is survivable, because the step commits
stay reachable through the branch ref, but "delete branch on merge" - which
several forges offer and some default on - removes the last thing pointing at
them. Measured both ways on a throwaway repository: squash-merge with the
branch retained left every step commit reachable in a fresh clone; the same
merge with the branch deleted first left none. So a project can adopt the rule,
write every plan correctly, and still yield nothing, with no error anywhere.
dejima's own history is intact because it merges with merge commits.
Because of that rebuild, the usual moment to approve is *after* leaving the
sandbox - so persistence (`--global` or `--project`) never requires a running
container.
`dejima-project approve` never attempts to patch a running session: it only
ever calls `dejima-allow` without `--container`, so recording the approval
is the only thing it does. If an individual approval can't be recorded - an
unwritable store, a slug collision, a corrupt approval list - it reports
that entry as not approved and keeps going, rather than stopping at the
first failure; that holds for dependencies and domains alike, so one bad
entry can't cost you the rest of the set. It then exits non-zero if anything
failed, so `dejima-project approve ~/myproject && dejima` won't launch a
session missing the approvals you just confirmed.
A session whose project declares something it doesn't have is told so in its
startup context, with the exact command to hand you. Project approvals are
inherited by subdirectories, so a git worktree under the project gets its
parent's; global approvals apply everywhere regardless.
It lives in the project rather than in `~/.config/dejima/config` because it is
a fact about the repository: anyone who clones it gets the same commit
discipline, and a rule half the contributors follow produces a history nothing
can be taken from. What dejima does not do is say what the history is *for*.
Collecting it is the operator's business; the briefing states the rule the
project adopted and stops there.
## Config parity with your host agent harness setup
@@ -414,6 +408,100 @@ editing the base `Dockerfile`: a dependency added that way is declared,
approved and layered like any other, and it survives a rebuild. Editing the
Dockerfile directly puts it outside that record.
## Identities
**Several accounts on one machine, and none of them yours.** An
`[identity.<name>]` section groups what one session acts as - the SSH key, the
forge token, the name on the commits - so that pointing a launch at a
different account is one word rather than four edits.
The rest of these documents ask you to give a session an account of its own -
the README under [Using this responsibly](../README.md#using-this-responsibly),
and security.md's [Give the sandbox its own
identity](security.md#give-the-sandbox-its-own-identity). This is how you
have more than one of them, which you will: an account is scoped to what it
may reach, and one account scoped to everything any session might ever need
is the thing separate accounts existed to avoid.
So the sections are per **agent account**, not per person:
```ini
# Pushes to the gitea forge, and can reach nothing on GitHub.
[identity.agent-gitea]
ssh_key = ~/.ssh/id_agent_gitea
user_name = dejima agent (gitea)
user_email = agent-gitea@example.invalid
token_file = ~/.config/gitea/agent-token
# A GitHub account, with push rights to the repositories this host works on.
[identity.agent-github]
ssh_key = ~/.ssh/id_agent_github
user_name = dejima agent (github)
user_email = agent-github@example.invalid
push_token_file = ~/.config/gh/agent-push
# A second GitHub account, for a repository the one above may not touch.
[identity.agent-vendor]
ssh_key = ~/.ssh/id_agent_vendor
user_name = dejima agent (vendor)
user_email = agent-vendor@example.invalid
push_token_file = ~/.config/gh/vendor-push
```
Select one with `[core] identity = agent-gitea`, or per launch with
`--identity agent-github` - which is how one host runs a session against each
of these at the same time, each pushing as itself.
**The separation is the point.** Three accounts means three things you can
scope, revoke and read in a log independently: a session that turns out to
have been prompt-injected reached what its own account could reach, and
answering "what did it touch" does not start with "everything I can touch".
**What an identity may set** is deliberately narrow: `ssh_key`, `user_name`,
`user_email`, and whichever keys the forges declare as an account's. A forge
says which of its own keys those are (`identity_keys` in its manifest), so
`token_file` belongs to an identity on the gitea path and `push_token_file`
does on the GitHub one, and neither is a list core keeps its own copy of.
**`default` is the account a session gets when none is named.** With `[core]
identity` unset, an `[identity.default]` section is what a launch acts under -
the ordinary account, given a name so it can carry the git name and the forge
credential too.
**`[core] ssh_key` is that key under its old name, and is deprecated.** A
config with no `[identity.default]` reads it as before; a config with both is
refused rather than ranked, because which key a session pushed with should not
depend on precedence rules. Every launch that sets it says so and prints the
section to move it into - including launches running as some other identity,
which are one `[identity.default]` away from that refusal.
**A credential does not fall through.** An identity that names some and leaves
the rest to the section beneath it is refused, naming each one: an identity is
how one session's account stays separate from another's. "Set" means written
in the file, so a forge key carrying a schema default - `[forge.gitea]
token_file` - still answers for an identity that names none.
**A name with no section is refused.** `[core] identity = agent-gihub` does not
quietly use this machine's own credentials - it stops the launch and says so.
Pushing under the wrong key fails loudly; pushing under your default key when
you asked for another one does not fail at all, which is the direction worth
being careful about.
**`user_name` and `user_email` reach the container's git config**, appended to
the copy dejima already makes of your `~/.gitconfig`. Your real one is not
touched, which was already true and is what the copy exists for.
**Selection is the host's, not the project's.** There is no `.dejima/` kind
for an identity and a project cannot name one: a project's `.dejima/` is
committed and shared with everyone working on it, and both the credential and
which account this host holds are that host's own. `[core] identity` and
`--identity` are the whole of it today.
A project-side layer would be a different thing than it sounds - not "this
project's credentials", which cannot be shared, but "this project wants
whichever account you have called X". That does not exist yet, and this
document will say so until it does.
## Headless git push without agent forwarding
**The reason to do this is identity, not uptime.** A forwarded agent
@@ -442,15 +530,15 @@ the public half as a Gitea deploy key scoped to just the repos that need
push access, then set it in the config:
```
[core]
[identity.default]
ssh_key = ~/.ssh/dejima_ed25519
```
`entrypoint.sh` loads it into a container-local `ssh-agent` at startup and
deletes the plaintext copy immediately after - the sandbox user itself never
has a path to the raw key, only the agent socket (see
[security notes](security.md) for the full mount rationale). Setting
`[core] ssh_key` means a live forwarded agent is no longer required at all.
[security notes](security.md) for the full mount rationale). Setting it means
a live forwarded agent is no longer required at all.
## Using a different editor/application
@@ -552,6 +640,26 @@ colon is refused outright: both lists are colon-separated, so such a path
cannot be represented in one at all and would otherwise split into two
mounts that each fail the existence check.
## Suspending a session
Ctrl-Z backgrounds all of dejima - the container, the credential proxy, the
conversation shipper - and `fg` brings back a working session, with the terminal
put back the way the harness had it.
That takes doing, because a container run holds the terminal in raw mode, where
Ctrl-Z is not a signal at all: it is a byte, delivered to whatever is running
inside. A harness that implements Ctrl-Z itself then stops itself, and nothing
in a container can continue it - there is no job-control shell in there. So
dejima takes that one character back for the host while a session runs
(`bin/dejima-tty-suspend`). Ctrl-C and Ctrl-\ are deliberately left alone and
still reach the session, which is how a harness expects to be interrupted.
Suspending is not detaching. A suspended session is stopped, so it makes no
progress until you resume it, and it belongs to the terminal you started it
from. For a session that keeps working while you are away, or that you want to
pick up from somewhere else, run dejima inside tmux and use
[`dejima-attach`](remote-access.md).
## Shared scratch space
`~/shared` is a read-write bind mount of `[core] share_dir` (default
@@ -577,6 +685,54 @@ means anything you left in it is readable by every session after the one you
left it for. A credential dropped in there to get one job done, and then
forgotten, is a credential you have handed to every session since.
## Reading what a session left behind
When a session compacts, the summary it writes is the only durable account of
what a long session worked out - and it goes with the container. dejima keeps
a copy host-side, in `[epitaph] dir`, from `bin/dejima`'s exit trap. That
directory is never mounted into a container.
Three commands read it, all defaulting to the repository you are standing in:
```sh
dejima-epitaph list # what is kept, oldest first
dejima-epitaph search "dns proxy" # which one was it
dejima-epitaph show 12 # open it in $VISUAL or $EDITOR
```
`show` takes a position from the listing that `list` and `search` print, or
an epitaph's filename, and defaults to the newest. It opens an editor when
there is a terminal to open one on and writes to stdout when there is not, so
`dejima-epitaph show | grep` behaves. `--all` widens `list` and `search` to
every project on the machine, which is also how to see everything the store
holds.
**`list` says whether each session is still resumable.** Where it is, the
transcript the summary came from is still on disk, and `claude --resume
<session>` gives you the session itself rather than a summary of it. The
epitaph is what remains after the harness ages that transcript out.
**Nothing here hands an epitaph back to a session, and that is deliberate.**
A session can write its own transcript, so it can author what its own epitaph
says. Delivering that to a later session automatically would plant text in
its premises as established background, which is the channel the
never-mounted store exists to close. Reading one and deciding what to do with
it is the operator's, which is why `show` opens an editor rather than
injecting anything: the header it opens on records that this preserves what
was written rather than vouching for it.
**Kept until you delete them.** There is no rotation. Epitaphs are an
archive, and a count-based cap would delete oldest-first - the entries most
likely to hold reasoning nobody remembers. They are small (tens of KB each,
against transcripts a hundred times that), so the store is a housekeeping
question rather than a disk one: `[epitaph] dir` is a plain directory of
plain markdown, and `rm` is the current answer.
Setting `[epitaph] enabled = off` stops new ones being kept. It does not hide
what is already there: `list`, `search` and `show` keep working, because
turning the feature off is the point at which wanting to review and clear the
store is most likely.
## Testing frontend changes
Playwright (Chromium) is pre-installed at image build time, browser
+10 -2
View File
@@ -25,19 +25,25 @@ run them.
| `hooks/git-push-guard.sh` | `PreToolUse` hook: blocks pushes outside the allowed host(s)/branches |
| `hooks/deny-dangerous-bash.sh` | `PreToolUse` hook: blocks obviously destructive Bash commands |
| `hooks/merge-guard.sh` | `PreToolUse` hook: blocks the configured forge's merge command (`DEJIMA_MERGE_BLOCK`) in the sandbox: merging is a human decision |
| `hooks/dejima-notify.sh` | `Notification`/`Stop` hook: drops an empty `waiting`/`done` marker for a remote-enabled session - see [docs/remote-access.md](remote-access.md) |
| `hooks/dejima-notify.sh` | Drops an empty `waiting`/`done` marker for a remote-enabled session. Registered as a `Stop` hook only; `waiting` is there for a harness that can say it without having stopped - see [docs/remote-access.md](remote-access.md) |
| `bin/dejima-attach` | Lists live sessions and attaches to one; the shared core behind both remote front doors |
| `bin/dejima-audit` | Advisory exit-time diff of host-persistence surfaces (git config, `.claude/`, etc.). `dejima-audit show` / `dejima-audit ack` default to the enclosing repo |
| `bin/dejima-dep` | Review and approve project dependencies declared in `.dejima/dependencies` |
| `bin/dejima-config-lib.sh` | The config file's schema, parser and value validators, plus the dependency-spec grammar. The base layer: it sources nothing, so everything else can source it. Not on PATH |
| `bin/dejima-provider-lib.sh` | Parses forge/workflow/harness manifests and composes the session briefing; not on PATH: see [docs/providers.md](providers.md) |
| `forges/`, `workflows/`, `harnesses/` | Provider directories for the three axes selected by `[core] forge`/`workflow`/`harness` (data, never sourced or executed); see [docs/providers.md](providers.md) |
| `tools/` | Optional tools a launch can add to its image - not an axis, since a launch enables zero or more. Same manifest rules, and a bar for what may be added: see [docs/tools.md](tools.md) |
| `docs/release.md` | The pass a `development` -> `main` release goes through, and why each part of it is not a test |
| `forges/gitea/bin/` | The gitea forge's own CLIs, baked into the image so the sandbox can act on them: `gitea-pr` (create a PR, read its comments, merge it), `gitea-issue` (list, view, create, comment on, close and reopen issues) and `gitea-actions` (read CI runs and job logs) |
| `forges/github/bin/` | The github forge's one CLI: `github-pr comments`, which shows unresolved review threads. `gh` covers everything else, but thread resolution lives only in GitHub's GraphQL API, so `gh pr view --comments` cannot tell a closed thread from an open one |
| `bin/dejima-notify-watch` | Host-side watcher: turns markers into ntfy pushes composed entirely from host data |
| `bin/dejima-project` | Review and approve project domains and dependencies from the host |
| `bin/dejima-project-lib.sh` | Shared library sourced by `bin/dejima-dep`, `bin/dejima-project`, `bin/dejima` and the remote-access scripts; not on PATH |
| `bin/dejima-conversation-ship` | Host-side: ships session transcripts to Loki when `[logging] conversation` is on. Reads them where they already are, so the session is not in the path of its own oversight log |
| `bin/dejima-epitaph` | Host-side: keeps the summary a session leaves when its context is compacted away, in a store nothing mounts. Run from `bin/dejima`'s exit trap |
| `bin/dejima-color-lib.sh` | The one terminal palette, and the glyphs that ride the same switch. Indexed colour, not 24-bit: gruvbox is the brand and an ANSI slot **is** gruvbox on a gruvbox terminal, but hardcoding the hexes assumes a background dejima does not own. `web/src/page.css` is the exception, since the page sets its own. Not on PATH |
| `bin/dejima-tui-lib.sh` | The palette, heading and numbered prompt `dejima-attach` and `dejima-allow` share. Rows stay with each tool, which lists a different thing from a different source. Not on PATH |
| `bin/dejima-update-check` | Host-side: says at startup when this checkout is behind the branch it tracks. Reaches the remote by URL from outside the repository, so the checkout's own git config never names a command it runs |
| `bin/dejima-epitaph` | Host-side: keeps the summary a session leaves when its context is compacted away, in a store nothing mounts. `save` runs from `bin/dejima`'s exit trap; `list`/`search`/`show` are the operator's, and only ever read |
| `bin/dejima-harness-lib.sh` | Loads the selected harness's plugin and provides the `emit_*` functions it declares through; the guarded-store refusal runs in `emit_mount`. Not on PATH |
| `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 |
@@ -47,6 +53,7 @@ run them.
| `docs/providers.md` | The manifest contract for the forge/workflow/harness axes, key by key, with the shipped manifests as worked examples |
| `docs/ci.md` | What push access grants a session on your CI host, and how to bound it - runner hardening, and an egress allowlist read from `main` |
| `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 |
`bin/dejima` is the entry point: it resolves the project path,
@@ -494,6 +501,7 @@ harness-specific path resolving to nothing is the honest answer.
| State layout | the project key - cwd with every non-alphanumeric replaced by `-` - is Claude Code's scheme, and dejima reproduces it in four places | `bin/dejima-slug-lib.sh`, `bin/dejima-project-lib.sh`, `docker/log-shipper.sh` |
| Per-project scoping | `projects/` is mounted one slug at a time and `file-history/` one session at a time, the second resolved by pairing `projects/<slug>/<id>.jsonl` with `file-history/<id>` (#104). In the plugin | `bin/harness/claude-code.sh` |
| Transcript discovery | where this workspace's transcripts are, what they are called and how they are nested. `harness_transcript_files` in the plugin, answering one `<key>\t<path>` line per file; core takes the key as the store it files under and refuses one that is not a slug this workspace owns. Both `dejima-epitaph` and `dejima-conversation-ship` used to carry Claude Code's layout directly | `bin/harness/<name>.sh`, `bin/dejima-epitaph`, `bin/dejima-conversation-ship` |
| Live sessions | which sessions still have a transcript, as `<key>\t<session-id>` lines. `harness_live_sessions` in the plugin, because the id is in the filename for one harness and is the directory name for the next; `dejima-epitaph list` matches an epitaph's own session id against the answer to say whether resuming the real session is still on offer. Optional: a plugin that does not implement it leaves the column unknown rather than claiming a transcript is gone | `bin/harness/<name>.sh`, `bin/dejima-epitaph` |
| Transcript update model | whether a transcript is appended to or replaced wholesale. The manifest's `transcript_update`; the shipper implements both and neither is guessed | `harnesses/<name>/harness.conf` |
| Transcript format | the transform from transcript bytes to log lines. In the plugin; the discovery, shipping, batching and Loki labels stay core's | `bin/harness/<name>.sh` |
| Compaction format | what a compaction looks like in a transcript, given one file at a time. In the plugin; the store, which keys are legitimate, its modes and the provenance note stay core's | `bin/harness/<name>.sh` |
+9 -3
View File
@@ -25,9 +25,15 @@
# 2. Authentication. ttyd is started with no credential of its own (see
# bin/dejima-ttyd), because it is bound to 127.0.0.1 where only this
# host can reach it - this proxy is the only gate in front of it. A
# vhost without auth_basic (or something stronger in its place) is an
# unauthenticated host shell on your LAN. Put every listen behind it:
# a second server block without auth is the same open door.
# vhost that authenticates nothing is an unauthenticated host shell on
# your LAN. Put every listen behind it: a second server block without
# auth is the same open door.
#
# This file uses HTTP Basic because it is the shortest thing to show.
# One shared credential, no per-operator identity, no second factor,
# revocation by editing htpasswd. docs/remote-access.md, under
# "Authenticating the front door", is what that costs and what else
# meets the requirement.
#
# Authentication alone does not stop a page in your browser from
# opening a WebSocket to this vhost and having the browser attach your
+85 -13
View File
@@ -76,6 +76,7 @@ value never does:
| `export` | `NAME=key` or `NAME=@key` for the `env_export` forge key - see [Forge](#forge) below. |
| `template` | Prose that may contain `{{placeholder}}` markers; the placeholder names are checked against the briefing whitelist at composition time, not here. |
| `keyspec` | One element of a `keys` list - see [The `keys` field](#the-keys-field-a-providers-own-config-schema) below. |
| `repopath` | `<subcommand>:<first path segment>` for the `repo_paths` forge key - where a repository appears in a path rather than behind a flag. |
| `list:TYPE` | Space-separated (not colon-separated, unlike a config-file list) elements, each checked against `TYPE`. |
A leading `?` on any type (`?hostname`, `?list:identifier`, ...) makes the
@@ -123,6 +124,10 @@ operator can set, resolved the same way as any other config key
| `pr_command` | `?command` | (empty) | The PR-management command name the forge's briefing prose and `env_export`/`merge_block` refer to. |
| `issue_command` | `?command` | (empty) | The issue-management command name, same idea. |
| `merge_block` | `?command` | (empty) | The command prefix refused inside the sandbox - only when the workflow's `block_merge = on`. Enforced by `docker/forge-guard.sh`, which shadows the named CLI on PATH and compares argv, so it holds for a harness that cannot intercept a tool call; `hooks/merge-guard.sh` applies the same rule to a command string, for the harnesses that can. Matched as a run of consecutive non-flag arguments, so a flag before the subcommand does not slip past it. |
| `repo_flags` | `?list:flag` | (empty) | The option names this forge's CLI accepts for naming a repository other than the one the session was launched against - `--repo -R` for `gh`. `docker/forge-guard.sh` refuses a value that is not that repository, in every spelling of the flag itself: `--repo x`, `--repo=x`, `-R x`, `-Rx`, and the letter inside a short-flag cluster (`-wR x`, `-wRx`). Fast-fail, not a boundary, for the same reason `merge_block` is not one: the session holds the token. Leave it empty for a CLI that resolves the repo itself and refuses another host, as the gitea ones do - a forge is not required to be aimable. |
| `identity_keys` | `?list:identifier` | (empty) | Which of this forge's own `keys` name an **account** rather than the forge itself - the credential paths, which differ per session, where an API URL does not. One declaration, two rules: a key named here is one an `[identity.<name>]` section may set, and one a project may not declare in its own `.dejima/`. Core keeps no list of its own, so a forge whose credential is spelled differently needs no change here. The identity inherits the type from this forge's `keys` entry, not a second copy of it. |
| `repo_env` | `?identifier` | (empty) | The environment variable this forge's CLI reads for the same thing - `GH_REPO`. Declared separately because it takes no argument and would otherwise be the way around every flag above. |
| `repo_paths` | `?list:repopath` | (empty) | A repository this forge's CLI names in a path rather than in a flag, as `<subcommand>:<first segment>` - `api:repos` for `gh api repos/<owner>/<name>/...`. Scoped to a subcommand on purpose: comparing every positional argument that contains two slashes would refuse an issue title that happens to look like one, and a false refusal costs an honest agent a working command. Paths carrying `gh`'s own `{owner}`/`{repo}` placeholders are left alone, since those resolve to this repository by construction. |
| `disclose` | `?disclosures` | (empty) | Which of this forge's commands send a body the session authored, as comma-separated `kind:command prefix` entries - `pr:gh pr create, issue:gh issue create, ...`. `docker/forge-guard.sh` prepends a marker to the body of a matching command, so a PR, issue or comment an agent wrote says so. The three kinds are `pr`, `issue` and `comment`; the wording of each marker is core's, not the manifest's, so that dejima says one thing about agent authorship whichever forge is selected. Unlike `merge_block` this does not depend on the workflow: what a workflow permits is a policy, who wrote the text is a fact. |
| `keys` | `?list:keyspec` | (empty) | This forge's own `[forge.<name>]` config schema - see above. |
| `requires` | `?list:identifier` | (empty) | Which of this forge's own config keys an operator is expected to set for it to actually work - checked at launch by two refusal rules (partly-configured, and none-configured-with-a-fixed-host) when the workflow pushes; see [Validation](#validation). A key with a non-empty schema default never counts as missing for either check. |
@@ -169,7 +174,7 @@ as environment variables.
| `requires` | `?list:identifier` | (empty) | Forge capabilities this workflow needs (checked against the selected forge's `capabilities` at launch). |
| `push` | `bool` | `on` | Whether this workflow pushes at all. When `off`, `DEJIMA_PUSH_HOSTS` is empty regardless of the forge, so `git-push-guard.sh` refuses every push. |
| `blocked_branches` | `?list:identifier` | (empty) | Branch names `git-push-guard.sh` refuses a direct push to, even on an allowed host. |
| `block_merge` | `bool` | `off` | When `on`, resolves the selected forge's `merge_block` command into `DEJIMA_MERGE_BLOCK` for `hooks/merge-guard.sh`. |
| `block_merge` | `bool` | `on` | When `on`, resolves the selected forge's `merge_block` command into `DEJIMA_MERGE_BLOCK` for `hooks/merge-guard.sh`. Defaults on so a workflow that omits it refuses a merge rather than permitting one; every shipped workflow sets it anyway. |
| `requires_worktree` | `bool` | `off` | When `on`, this workflow's briefing fragment must contain a `<!-- core: worktree -->` marker, which the core worktree-containment paragraph is spliced into - see below. |
| `autonomy` | `enum:commit\|push-branch\|open-pr` | `commit` | Gates `<!-- requires: autonomy=VALUE -->` spans in this workflow's own briefing fragment (see below); also read by the forge/harness resolvers where autonomy-conditioned prose applies. |
| `briefing` | `?relpath` | (empty) | This workflow's briefing fragment file, relative to its own directory. |
@@ -257,6 +262,26 @@ resuming the session on the host is `claude --resume <session id>`, with
`{{session_id}}` filled in at composition time; and its briefing fragment is
`briefing.md`.
## Tool
Not an axis: a launch selects one forge, one workflow and one harness, and
enables zero or more tools. Everything else about it is the same - a manifest
that is data, validated by the same schema machinery, never sourced.
The keys are deliberately few. A tool declares what it is, what installs it,
what its environment needs and what the session should be told about it. There
is no key for a hook, a mount, a domain or a guard, because a tool that needed
one of those would be a change to the boundary rather than a program on PATH.
| Key | Type | Default | Meaning |
| --- | --- | --- | --- |
| `description` | `text` | *(required)* | One line, shown when the layer builds and in the session briefing. |
| `install` | `depspec` | *(required)* | The same grammar a dependency uses. In practice a `tarball:` spec, because a tool has to be pinned by digest - see [docs/tools.md](tools.md) for why that is a rule rather than a preference. |
| `install_layout` | `enum:prefix\|binary` | `prefix` | The archive's shape. `prefix` is one enclosing directory, stripped so `bin/` lands in the same place whatever the release called its top level. `binary` is an archive that IS the executable: stripping a component there would strip the binary and extract nothing, silently. |
| `image_env` | `?list:envpair` | (empty) | `NAME=VALUE` pairs the layer sets. **Every name must be namespaced to the tool** - `RTK_*` for `rtk` - and a launch refuses one that is not. This becomes a Dockerfile `ENV`, which every process in the container inherits, so a bare `NO_COLOR` or `DO_NOT_TRACK` changes the harness and everything else too. A harness manifest may use bare names there because a harness is the container's main process; a tool is one program among many. |
| `command` | `identifier` | *(required)* | What it puts on PATH. |
| `instructions` | `?relpath` | (empty) | A markdown fragment appended to the session briefing under a core-owned heading, so a tool describes itself once and every harness gets the same words. This is the whole of the per-harness wiring: dejima renders it rather than letting the tool's installer patch a harness config. |
## Session briefing composition
The text a `SessionStart` hook injects into the agent is assembled
@@ -346,6 +371,12 @@ itself:
- `DEJIMA_BLOCKED_BRANCHES` - the workflow's `blocked_branches`.
- `DEJIMA_MERGE_BLOCK` - the forge's `merge_block`, but only when the
workflow's `block_merge = on`; otherwise empty.
- `DEJIMA_FORGE_REPO_FLAGS` and `DEJIMA_FORGE_REPO_ENV` - the forge's
`repo_flags` and `repo_env`, with `DEJIMA_FORGE_REPO` holding the
`<owner>/<name>` of the workspace's `origin` remote. Empty when the forge
declares neither, and also when the workspace has no origin to read - in
which case a command naming any repository is refused, since there is
nothing it could legitimately be naming.
- Whatever the forge's own `env_export` names (e.g. `GITEA_API`,
`GITEA_SSH_HOST`, `GITEA_TOKEN`).
@@ -466,18 +497,46 @@ that immediately turned up a second thing, that `gh --repo owner/name pr merge
N` walked straight past the merge block. Neither was a hard bug to find once
anyone looked.
Two known differences remain, both filed rather than fixed:
Two differences from the gitea path remain, both now closed in code and worth
knowing about because they are what a second forge costs:
- **Review feedback is thinner.** `gitea-pr comments` shows unresolved review
threads and hides the rest; GitHub does not expose thread resolution through
the REST API `gh` uses for this, so working through PR feedback there
re-reads threads already dealt with, with no signal that it is doing so.
- **`gh --repo` reaches past the sandboxed repository.** The gitea CLIs refuse
an origin that is not on the configured host, so they can only act on the
repo the sandbox was launched against. `gh` takes `--repo` on nearly every
subcommand, and `forge = github` supplies a deliberately write-capable
token. A per-repository fine-grained PAT is the shape of `push_token_file`
that makes this moot.
- **Review feedback needs its own tool.** `gh pr view --comments` prints
reviews and comments with no resolution state anywhere in the output,
because the REST API it uses does not carry any - `isResolved` exists only
on `reviewThreads` in GraphQL. So this forge ships `github-pr comments`,
which does that one query and otherwise matches `gitea-pr comments`:
unresolved threads by default, `--all` to include the resolved ones marked
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`
on nearly every subcommand, reads `GH_REPO`, and names repositories in `gh
api` paths, so this forge declares all three (`repo_flags`, `repo_env`,
`repo_paths`) and `docker/forge-guard.sh` refuses a value that is not the
launched-against repository. Fast-fail, not a boundary, exactly as
`merge_block` is: the session holds the token and could call the API
directly. What actually bounds it is the token's scope, so a per-repository
fine-grained PAT is the expected shape of `push_token_file`.
**What it sees, and what it does not.** Worth stating both, because a guard
described only by what it catches reads like a boundary. It compares, case
-insensitively: `--repo x` and `--repo=x`; `-R x` and `-Rx`; the same letter
inside a short-flag cluster, `-wR x` and `-wRx`; `GH_REPO`; and
`gh api repos/<owner>/<name>/...`, with or without a leading slash. A launch
that resolved no repository at all refuses every one of those rather than
waving them through.
It does not see a GraphQL `mutation` sent through `gh api graphql`, a
repository named in an `--jq` expression or a `-F`/`-f` field, an API path
under a segment other than `repos/`, or `gh` invoked through something else
that passes the repository along. Nor can it know that the workspace's
`origin` is the repository you meant: it compares against whatever origin
the checkout has, so a workspace pointing somewhere unexpected shifts what
counts as "this repository" rather than being caught by this. That list is
not exhaustive either, and it is not meant to be - widening it further would
mean parsing each subcommand's flags, which is the coupling this guard
exists to avoid. The token's scope is what bounds the ones it misses.
So: if you have a choice, `gitea` is the better-worn path. Choose `github`
because that is where your repositories are, which is a good reason - and
@@ -509,6 +568,16 @@ Two things it does not cover. A command that sends no body has none added -
would be worse than leaving it. And running a forge CLI on the host, outside
a sandbox, marks nothing: there the person running it wrote the body.
**Once, whatever the body already says.** A body that already opens with the
marker gets no second one - the marker is a statement rather than a count, and
a body saying an agent wrote it has disclosed what this exists to disclose.
That matters because agents write it themselves: every issue on a project
worked on this way opens with that line, so one reading the existing issues
concludes it is the house style. The same words further down the body are
prose about disclosure rather than the disclosure, and still get one
prepended. The session is also told the marker is added for it, which is the
half that stops it being written at all.
**Selecting `forge = github` is a real posture change, not just a config
choice.** It opens `ssh.github.com:22` for push - the read-only GitHub access
every session gets regardless (clone, fetch, `gh` API calls over port 443)
@@ -516,7 +585,10 @@ never needs port 22 at all. It opens it through core's existing GitHub push
grant (`ALLOW_GITHUB_PUSH`, the same one `--allow-github-push` uses), which
its `hosts_allowlisted_by_core = on` is what asks for: port 22 to GitHub's
published ranges and nothing else, logged like every other allowed
connection and verified by the firewall's own startup self-check. Only when
connection and verified by the firewall's own startup self-check. The
port restriction is an IPv4 statement; what makes port 22 shut *at all*
without this grant is that the container blocks IPv6 as well, so an
allowlist naming one address family is not the whole of it. Only when
the selected workflow actually pushes - `forge = github` with
`workflow = local-only` leaves port 22 shut. And `[forge.github] push_token_file` is
deliberately a *separate*, write-capable token from `[github] token_file`
+117
View File
@@ -0,0 +1,117 @@
# Releasing: development to main
`main` is what an operator installs from, and what the CI hardener reads
`.dejima/allowed-domains` from. Work lands on `development` continuously; a
release is the deliberate act of moving it across, and this is what that act
consists of.
`test/release-pass.sh` runs the parts that can be run and prints the parts that
cannot, chosen from what the release actually changes. Start there.
## What is actually required, at this size
This project has one maintainer and few users, and a release gate nobody has
the bandwidth to complete is a gate that gets skipped rather than met. So the
bar is deliberately two things:
1. **An agentic security review of the whole diff**, against the invariants in
section 2. This is the gate. It is cheap, it is repeatable, and it is the
one thing that catches the class of mistake this project cannot afford.
2. **The maintainer runs the build.** Normal usage for a while, and a launch on
the other configured harness to confirm it starts. That is the smoke test,
and it is stated as what it is rather than dressed up as coverage.
Everything in section 4 is what a mature version of this project would verify
and this one does not. It is printed by `release-pass.sh` and kept in this
document because knowing what is unverified is worth more than pretending
otherwise - not because a release is blocked on it. When there are more users
than one person can apologise to, that changes.
## Why a pass at all
Every check below exists because something got through without it. The pattern
is always the same shape: the change was correct, the tests were green, and the
thing that broke was outside what any test could see - a container that could
not start, a body that arrived empty, a suite that passed in CI and failed on
the machine the work was done on.
A release is the last point where anyone looks at the whole diff rather than
one change at a time, which makes it the only place some of these are visible
at all.
## 1. The suites
`test/run-confined.sh` and `test/shellcheck.sh` run anywhere.
`test/runtime.bats` needs Docker, which a sandbox does not have. It is the only
thing that exercises the mounts, the firewall and the entrypoint end to end, so
a release is exactly when not running it stops being acceptable. Either run it
on the host, or **check that the privileged CI job ran** - it is pinned to one
self-hosted runner and queues silently when that runner is down, so a pull
request can sit with the runtime half neither green nor red.
"CI covers it" is not "it passed."
## 2. Security review
Not a generic checklist - this project's product is an argument that a boundary
holds, so the review is about that boundary. Over the whole `main...development`
diff:
- **Does a mount widen?** A path that was read-only, a scope that was one
project and is now the home directory.
- **Does a guard move after the thing it guards?** Order is the whole content
of a check.
- **Does a credential cross the boundary that did not before?** Including
indirectly: a variable that reaches the container, a file whose mask moved.
- **Does `.dejima/allowed-domains` gain a name?** That file is read from `main`
by dejima-ci-hardener, which treats what it finds there as reviewed. Adding a
domain widens every future CI job on that runner, from the moment it merges.
- **Did a refusal become a warning?** Or a hard failure become a fallback. Both
are how a boundary stops holding without anyone deciding it should.
- **Did a test stop asserting what its name claims?** A test that still passes
while the behaviour under it is gone is worse than no test.
The reviewer should be told to look for those specifically. A review briefed
only as "look for security problems" reliably returns generic advice about
input validation, because that is what a diff looks like without the context of
what this repository is defending.
## 3. Documentation review
- Every changed behaviour has its documentation changed in the same release.
- No document describes something that no longer exists.
- Nothing is claimed as working that has never been run. Where that is the
case, say so where a reader will meet it - the forge and harness maturity
sections in [providers.md](providers.md) are what this looks like.
## 4. Manual testing, which is mostly not done
`test/release-pass.sh` prints the list for the paths this release touches. It
is an inventory of what nothing has checked, not a checklist that must be
completed - see "What is actually required" above. The standing entries, and
why each is not a test:
| Touched | What to do, and why no test does it |
| --- | --- |
| `bin/dejima`, the entrypoint, compose | Launch a real session: mounts, proxy and exit audit only meet each other here |
| `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 |
| `harnesses/` | Launch the harness against a real model |
## 5. The release pull request
Say what was run and what was not, in the body. At this size that will mostly
be "not", and writing it down is the whole point: an unverified path that is
named is one somebody can reach for when a bug turns up, and an unverified
path that is implied to be tested costs a day of looking in the wrong place.
Worth its own line: **whether the release can be rolled back.** A config key
added in a release makes the previous `main` refuse to start once an operator
adopts it, because an unknown key fails validation - so the rollback that
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.
+482 -206
View File
@@ -36,9 +36,9 @@ attached. The two things holding that line are:
- **[The bind address](#the-bind-address-is-the-one-thing-to-get-right).**
`ttyd` binds `127.0.0.1` and your proxy must bind one specific LAN
address - never the wildcard, never a Docker bridge.
- **Authentication on the proxy.** `ttyd` carries no credential of its own,
so a `server` block without `auth_basic` is an unauthenticated host shell
for everyone on that network.
- **[Authentication on the proxy](#authenticating-the-front-door).** `ttyd`
carries no credential of its own, so a `server` block that authenticates
nothing is an unauthenticated host shell for everyone on that network.
What it does **not** add is a way *out* of a sandbox. Configured as
described below, a compromised session cannot reach `ttyd`, cannot reach
@@ -50,181 +50,8 @@ front door opening *inward*, and that is a full sandbox escape rather than a
degradation. [Verifying it](#verifying-it) is two commands, and worth
running.
## `[remote-access]`
A section of the single config file, at `~/.config/dejima/config`
(`$DEJIMA_CONFIG_DIR` relocates the whole config tree, same as it does for
approvals):
| Key | Default | Purpose |
| --- | --- | --- |
| `enabled` | `off` | the switch - nothing below runs until this is `on` |
| `ntfy_url` | unset | ntfy topic to push notifications to. `dejima-notify-watch` refuses to start without it; nothing else reads it |
| `base_url` | unset | public base URL of the web front door, used to build the notification's deep link. Absent, pushes still go out - just with no link on them |
| `ttyd_port` | `7681` | loopback port `ttyd` listens on. The reverse proxy has to `proxy_pass` to the same one |
| `ttyd_font_size` | unset | terminal font size in px, 8-72. Applies to *every* client, phone and desktop alike - see "Reading it on a phone". Unset passes no option at all and leaves `ttyd` on its own default |
| `ttyd_focus_on_tap` | unset | whether tapping the terminal raises the soft keyboard. Unset means no on a touch device and yes on a desktop, which is almost always what you want; `on` or `off` forces it. Overridable per client with `?focus=1` / `?focus=0` |
| `ttyd_tmux_prefix` | unset | the tmux prefix the terminal page's `PREFIX`, `◀W`, `W▶` and `ZOOM` buttons send, written as a control chord: `C-a`, `C-b`, `C-Space`, `C-@`, `C-_`, `C-?`. Unset leaves the page on stock tmux's `C-b`. See "Reading it on a phone" |
`ntfy_url` and `base_url` must carry an explicit `http://` or `https://`
scheme; a value without one is refused rather than guessed at, because
`curl` reads a leading `-K/path` as an *option* that takes the request's URL
from that file instead. Values are restricted to
`A-Za-z0-9:/._~?=%@+-`: no whitespace and nothing shell-active, since these
end up in an HTTP header and in a URL handed to a phone.
**A malformed config is a hard failure, not a warning.** The whole file is
validated on every read, not just the key being asked for: an unknown key, a
value outside that character set, or a line that doesn't parse makes
`dejima-ttyd` and `dejima-notify-watch` refuse to start and say which line
was wrong. A `ttyd_port` that isn't a number in 1-65535 is refused the same
way, as is a `ttyd_font_size` outside 8-72 - a size big enough to make one
glyph wider than the viewport renders a terminal that shows nothing, which
reads as a broken front door rather than as a typo three sections away. A
`ttyd_tmux_prefix` that is not a control chord is refused here too, rather
than on the phone, hours later, by whoever presses `PREFIX` and gets nothing.
`dejima-ttyd` and `dejima-notify-watch` are the two scripts that read a
*value* out of the config, so a typo anywhere in the file refuses their
start. `dejima` reads values too (`audit dir` among them) and refuses the
same way. `dejima-attach` alone only asks `remote_enabled` - deliberately
bypassing the whole-file validation, so a typo elsewhere in the config can't
lock you out of the terminal you'd fix it from.
```
# ~/.config/dejima/config - substitute your own topic and hostname
[remote-access]
enabled = on
ntfy_url = https://ntfy.example.invalid/your-topic-here
base_url = https://dejima.example.invalid
ttyd_port = 7681
# ttyd_font_size = 20 # optional, and applies to the desktop too
# ttyd_tmux_prefix = C-a # optional; the page assumes stock C-b without it
```
**Editing this changes nothing until the services restart.** Every value
is read once, at process start - `dejima-ttyd` reads `ttyd_port`,
`ttyd_font_size` and `ttyd_tmux_prefix` and then `exec`s `ttyd` with them
baked into its argv. So:
```sh
systemctl --user restart dejima-ttyd.service
systemctl --user restart dejima-notify-watch.service # if ntfy_url changed
pgrep -a ttyd # confirm the argv is what you just configured
```
Skipping this is quiet rather than loud: nothing warns, and the front door
keeps serving with the old settings, which reads as "the setting had no
effect" rather than as "the process is older than the file".
## How it works
Almost none of this is in the sandbox. The container writes one empty file
and knows nothing else about the feature; everything that turns that into a
notification, and everything that puts a terminal in front of you
afterwards, runs on the host as your own user.
### The session asks you something
```
┌─ the sandbox container ────────────────────────────────────────────┐
│ claude code, at a Notification or Stop hook │
│ │ │
│ │ hooks/dejima-notify.sh writes an empty file named │
│ ▼ `waiting` or `done` - no content, no stdin, no network │
└───────┼────────────────────────────────────────────────────────────┘
════════╪═══ container boundary - one bind-mounted directory ═════════
┌───────▼─── your machine, as your user ─────────────────────────────┐
│ ${XDG_RUNTIME_DIR}/dejima/notify/<run-id>/waiting │
│ │ │
│ │ inotify, or a ~6s poll where inotify-tools is absent │
│ ▼ │
│ dejima-notify-watch (systemd --user) │
│ │ consumes the marker by NAME; never reads its content │
│ │ every byte of the push is composed from host-side data │
│ ▼ │
└───────┼────────────────────────────────────────────────────────────┘
════════╪═══════════════ off your machine ════════════════════════════
│ curl
▼
your ntfy topic ──── push ────► your phone
"dejima: <project> needs input"
link: <base_url>/?arg=<run-id>
```
The marker directory is the sandbox's entire surface here, and it is
one-way: a name, out. Nothing the session writes reaches the push, which is
why the notification says a project needs input and never what it is asking.
[Notifications](#notifications) has the detail, including where that
guarantee actually lives.
### You come back
```
browser, on the link from the push any plain SSH client
│ │
│ https://<base_url>/?arg=<run-id> │ ssh dejima-box
│ HTTP Basic over TLS │ key with a
▼ ▼ forced command
════════╪══════════════════ your LAN ══════════════════╪══════════════
┌─ your machine, as your user ───────────────────────────────────────┐
│ nginx, listening on ONE LAN address sshd │
│ TLS + auth_basic, plus a default_server authorized_keys with │
│ that refuses every other name a forced command │
│ │ │ │
│ │ proxy_pass http://127.0.0.1:7681 │ │
│ ▼ │ │
│ ttyd --interface 127.0.0.1 --check-origin --url-arg │ │
│ │ │ │
│ └──────────────────────┬───────────────────────┘ │
│ ▼ │
│ dejima-attach [<run-id>] │
│ │ a grouped tmux session │
│ ▼ │
│ YOUR TMUX SERVER │
│ the sandbox's window - and every other │
│ window on it, one prefix key away │
└────────────────────────────────────────────────────────────────────┘
═════════════════════ container boundary ═════════════════════════════
┌─ the sandbox container ────────────────────────────────────────────┐
│ routes to the Docker bridge gateway - your host - and to nothing │
│ else of yours. Neither front door is on that address, which is │
│ the whole reason `ttyd` is on loopback and nginx is on one │
│ named LAN interface. A proxy on `0.0.0.0` or on a bridge puts a │
│ host shell in here. │
└────────────────────────────────────────────────────────────────────┘
```
The web door is the one the notification links into: the browser hits your
reverse proxy, which authenticates and forwards to a `ttyd` that only this
host can open a socket to, and `ttyd` runs `dejima-attach` with the run id
from `?arg=`. The SSH door skips all of that. It is a key in your own
`authorized_keys` pinned to a forced command, so a plain SSH app on the
phone runs `dejima-attach` and nothing else no matter what it asks to run,
and lands on a numbered picker of live sessions. It needs no proxy, no
`ttyd`, and no notification to have been sent.
Both converge on `dejima-attach`, and both end at *your* tmux server. That
convergence is the point: the controls worth spending effort on are the two
front doors, because behind them there is no further boundary to cross - a
grouped tmux session shares every window on the server, and the prefix key
opens new ones regardless.
### What `off` means
With `[remote-access] enabled` off (the default), `dejima` builds a
`docker compose` invocation with nothing remote-access in it: no
session state is written, no marker directory is created or mounted, and no
notification hook is registered. `dejima-attach`, `dejima-ttyd` and
`dejima-notify-watch` each refuse to run at all. `install.sh` symlinks those
three onto `PATH` and stops there - it runs no `systemctl`, copies no unit,
and starts nothing.
## The bind address is the one thing to get right
This is the step that carries the whole feature's security, and the only one
where a plausible-looking choice is a full sandbox escape.
`docker/init-firewall.sh` deliberately ACCEPTs container OUTPUT to the
container's default-route address - the Docker bridge gateway, which is your
host. That is the one host address a sandbox can reach at all. Combine it
@@ -307,6 +134,72 @@ asserts this same containment property against a live container (with a
positive control on the same path, so a passing result can't come from the
container reaching nothing), and it passes on a real Docker host.
## `[remote-access]`
A section of the single config file, at `~/.config/dejima/config`
(`$DEJIMA_CONFIG_DIR` relocates the whole config tree, same as it does for
approvals):
| Key | Default | Purpose |
| --- | --- | --- |
| `enabled` | `off` | the switch - nothing below runs until this is `on` |
| `ntfy_url` | unset | ntfy topic to push notifications to. `dejima-notify-watch` refuses to start without it; nothing else reads it |
| `base_url` | unset | public base URL of the web front door, used to build the notification's deep link. Absent, pushes still go out - just with no link on them |
| `ttyd_port` | `7681` | loopback port `ttyd` listens on. The reverse proxy has to `proxy_pass` to the same one |
| `ttyd_font_size` | unset | terminal font size in px, 8-72. Applies to *every* client, phone and desktop alike - see "Reading it on a phone". Unset passes no option at all and leaves `ttyd` on its own default |
| `ttyd_button_colors` | unset | whether the toolbar is colour-coded by what each button does, in the same gruvbox palette the session picker uses. Unset means yes; `off` restores the grey every key had before. See "Reading it on a phone" |
| `ttyd_focus_on_tap` | unset | whether tapping the terminal raises the soft keyboard. Unset means no on a touch device and yes on a desktop, which is almost always what you want; `on` or `off` forces it. Overridable per client with `?focus=1` / `?focus=0` |
| `ttyd_tmux_prefix` | unset | the tmux prefix the terminal page's `PREFIX`, `◀W`, `W▶` and `ZOOM` buttons send, written as a control chord: `C-a`, `C-b`, `C-Space`, `C-@`, `C-_`, `C-?`. Unset leaves the page on stock tmux's `C-b`. See "Reading it on a phone" |
```
# ~/.config/dejima/config - substitute your own topic and hostname
[remote-access]
enabled = on
ntfy_url = https://ntfy.example.invalid/your-topic-here
base_url = https://dejima.example.invalid
ttyd_port = 7681
# ttyd_font_size = 20 # optional, and applies to the desktop too
# ttyd_tmux_prefix = C-a # optional; the page assumes stock C-b without it
```
**Editing this changes nothing until the services restart.** Every value is
read once, at process start, and baked into `ttyd`'s argv. Skipping the restart
is quiet rather than loud: the front door keeps serving with the old settings,
which reads as "the setting had no effect".
```sh
systemctl --user restart dejima-ttyd.service
systemctl --user restart dejima-notify-watch.service # if ntfy_url changed
pgrep -a ttyd # confirm the argv is what you just configured
```
**If this host sits behind a firewall it does not control**, the push has to be
permitted outbound before any of this works. `dejima-notify-watch` runs on the
host, not in the container, so the container's own allowlist has nothing to do
with whether it can reach your ntfy server - and a self-hosted one on a private
address is exactly the case a host-level egress filter drops. That failure is
the quiet kind: nothing errors, `systemctl --user status dejima-notify-watch`
looks healthy, and the phone simply never buzzes.
**A malformed config is a hard failure, not a warning.** The whole file is
validated on every read, not just the key being asked for, so an unknown key or
an unparseable line makes `dejima-ttyd` and `dejima-notify-watch` refuse to
start and say which line was wrong. So does an out-of-range `ttyd_port`
(1-65535) or `ttyd_font_size` (8-72), or a `ttyd_tmux_prefix` that is not a
control chord - refused here rather than on the phone, hours later, by whoever
presses `PREFIX` and gets nothing.
`ntfy_url` and `base_url` must carry an explicit `http://` or `https://` scheme
and are restricted to `A-Za-z0-9:/._~?=%@+-`. A value without a scheme is
refused rather than guessed at, because `curl` reads a leading `-K/path` as an
*option* that takes the request's URL from that file instead; the character set
is because these end up in an HTTP header and in a URL handed to a phone.
`dejima` itself refuses the same way, since it reads values too.
`dejima-attach` alone reads only `remote_enabled`, deliberately bypassing the
whole-file validation, so a typo elsewhere in the config can't lock you out of
the terminal you'd fix it from.
## SSH front door
Generate a key that exists only for this, and give it a forced command:
@@ -428,8 +321,10 @@ and forgotten. What matters in it:
- **Authentication on every server block.** `ttyd` is started with no
credential of its own, because the loopback bind means only this host can
reach it - the proxy is the entire gate. A block without `auth_basic` is
an unauthenticated host shell on your network.
reach it - the proxy is the entire gate. A block that authenticates nothing
is an unauthenticated host shell on your network. The example uses HTTP
Basic; [Authenticating the front door](#authenticating-the-front-door) is
what that costs and what else meets the requirement.
- **A `default_server` that refuses.** nginx picks a server block by
SNI/`Host`, and when nothing matches it serves the **default** for that
listen address - which, if the front door is the only block on it, is the
@@ -452,6 +347,163 @@ and forgotten. What matters in it:
- **A long `proxy_read_timeout`.** An attached terminal sits idle for hours;
nginx's default is 60 seconds.
**The page carries its own colours.** dejima's terminal output uses indexed
colour so your terminal's theme fills the slots - but a browser has no theme
to inherit, so the front door is handed gruvbox explicitly: all sixteen ANSI
slots plus background, foreground and cursor. Without it xterm.js draws with
its own defaults, and the same session looks one way over SSH and another in
the browser. `bin/dejima-ttyd` sets it, and `web/src/page.css` carries the
same values for the chrome around it.
## Authenticating the front door
`ttyd` binds loopback and carries no credential, so whatever sits in front of
it is the entire gate. The requirement is that it authenticates **every
request to every server block, the default one included** - not that it is any
particular software. These are the shapes that meet it, roughly in order of
how much they change.
**A network the public internet cannot reach.** Tailscale, WireGuard, or any
VPN that puts the listener on an interface nothing outside can route to.
First because it removes the exposure rather than authenticating it, because
it composes with everything below, and because for a lot of readers it is both
the simplest answer and the strongest. It does not replace [the bind
address](#the-bind-address-is-the-one-thing-to-get-right): a VPN interface is
still one specific address to bind, and the wildcard is still wrong.
**HTTP Basic**, which is what the example config shows. One shared credential,
no per-operator identity, no second factor, revocation that means editing
`htpasswd`, and an access log that cannot tell two people apart. For a solo
operator that is a reasonable trade, and worth saying that it is one; for
anyone else it is the first thing to replace. It also sends the password on
every request, which is why TLS is not optional here.
**Client certificates.** `ssl_client_certificate` and `ssl_verify_client on`
in the same block. Per-device identity, revocable through a CRL, and nothing
worth stealing sent on every request. The cost is distribution and renewal,
and a phone that has to have the certificate installed - the part that will
bite, and the part that differs per platform.
**A forward-auth proxy.** oauth2-proxy or Authelia via nginx `auth_request`:
OIDC, per-user identity, and a session cookie rather than a credential on
every request. Also where passkeys realistically arrive, since the proxy
handles WebAuthn and `ttyd` never learns about it. The cost is another moving
piece in the path to a host shell, which is worth naming given what is on the
other side of it.
**Something you already run.** An identity-aware gateway, or an
authenticating proxy already in the path. The requirement above is the whole
of what this document asks of it.
**None of these stops a browser that has already authenticated.** Whatever is
in front, a page loaded in a session that has passed it can act - which is the
same property that makes the front door usable from a phone you unlocked an
hour ago. Authentication decides who gets a shell, not what an already-open
tab does with one.
## How it works
Almost none of this is in the sandbox. The container writes one empty file
and knows nothing else about the feature; everything that turns that into a
notification, and everything that puts a terminal in front of you
afterwards, runs on the host as your own user.
### The session asks you something
```
┌─ the sandbox container ────────────────────────────────────────────┐
│ claude code, at a Stop hook │
│ │ │
│ │ hooks/dejima-notify.sh writes an empty file named │
│ ▼ `done` - no content, no stdin, no network │
└───────┼────────────────────────────────────────────────────────────┘
════════╪═══ container boundary - one bind-mounted directory ═════════
┌───────▼─── your machine, as your user ─────────────────────────────┐
│ ${XDG_RUNTIME_DIR}/dejima/notify/<run-id>/done │
│ │ │
│ │ inotify, or a ~6s poll where inotify-tools is absent │
│ ▼ │
│ dejima-notify-watch (systemd --user) │
│ │ consumes the marker by NAME; never reads its content │
│ │ every byte of the push is composed from host-side data │
│ ▼ │
└───────┼────────────────────────────────────────────────────────────┘
════════╪═══════════════ off your machine ════════════════════════════
│ curl
▼
your ntfy topic ──── push ────► your phone
"dejima: <project> finished"
link: <base_url>/?arg=<run-id>
```
The marker directory is the sandbox's entire surface here, and it is
one-way: a name, out. Nothing the session writes reaches the push, which is
why the notification says a project has stopped and never what it is asking.
[Notifications](#notifications) has the detail, including where that
guarantee actually lives.
### You come back
```
browser, on the link from the push any plain SSH client
│ │
│ https://<base_url>/?arg=<run-id> │ ssh dejima-box
│ HTTP Basic over TLS │ key with a
▼ ▼ forced command
════════╪══════════════════ your LAN ══════════════════╪══════════════
┌─ your machine, as your user ───────────────────────────────────────┐
│ nginx, listening on ONE LAN address sshd │
│ TLS + auth, plus a default_server authorized_keys with │
│ that refuses every other name a forced command │
│ │ │ │
│ │ proxy_pass http://127.0.0.1:7681 │ │
│ ▼ │ │
│ ttyd --interface 127.0.0.1 --check-origin --url-arg │ │
│ │ │ │
│ └──────────────────────┬───────────────────────┘ │
│ ▼ │
│ dejima-attach [<run-id>] │
│ │ a grouped tmux session │
│ ▼ │
│ YOUR TMUX SERVER │
│ the sandbox's window - and every other │
│ window on it, one prefix key away │
└────────────────────────────────────────────────────────────────────┘
═════════════════════ container boundary ═════════════════════════════
┌─ the sandbox container ────────────────────────────────────────────┐
│ routes to the Docker bridge gateway - your host - and to nothing │
│ else of yours. Neither front door is on that address, which is │
│ the whole reason `ttyd` is on loopback and nginx is on one │
│ named LAN interface. A proxy on `0.0.0.0` or on a bridge puts a │
│ host shell in here. │
└────────────────────────────────────────────────────────────────────┘
```
The web door is the one the notification links into: the browser hits your
reverse proxy, which authenticates and forwards to a `ttyd` that only this
host can open a socket to, and `ttyd` runs `dejima-attach` with the run id
from `?arg=`. The SSH door skips all of that. It is a key in your own
`authorized_keys` pinned to a forced command, so a plain SSH app on the
phone runs `dejima-attach` and nothing else no matter what it asks to run,
and lands on a numbered picker of live sessions. It needs no proxy, no
`ttyd`, and no notification to have been sent.
Both converge on `dejima-attach`, and both end at *your* tmux server. That
convergence is the point: the controls worth spending effort on are the two
front doors, because behind them there is no further boundary to cross - a
grouped tmux session shares every window on the server, and the prefix key
opens new ones regardless.
### What `off` means
With `[remote-access] enabled` off (the default), `dejima` builds a
`docker compose` invocation with nothing remote-access in it: no
session state is written, no marker directory is created or mounted, and no
notification hook is registered. `dejima-attach`, `dejima-ttyd` and
`dejima-notify-watch` each refuse to run at all. `install.sh` symlinks those
three onto `PATH` and stops there - it runs no `systemctl`, copies no unit,
and starts nothing.
## Reading it on a phone
`dejima-ttyd` launches `ttyd` with `--index web/index.html`, which replaces
@@ -461,15 +513,13 @@ there is nothing to turn on.
`ttyd`'s own page is close to unusable on a phone for anything beyond typing,
and not because of a setting anyone missed. xterm.js turns a tap into a mouse
click, but a touch *drag* is consumed by the browser as viewport scrolling and
never becomes a mouse drag - so nothing downstream ever sees one. tmux binds
`MouseDrag1Border` to `resize-pane -M` by default and nvim resizes splits from
separator drags whenever `mouse` is on; both have always worked, and simply
never received a drag event from a touch client. The same missing drag is why
selecting text is impossible, and scrolling an application's output with it.
Then there is no Ctrl key, the tmux status bar's window names are a few pixels
wide, and `ttyd` serves no viewport meta tag at all - so a phone lays the page
out at a virtual desktop width and scales the result down, which no font size
can rescue.
never becomes a mouse drag, so nothing downstream ever sees one: tmux's
`resize-pane -M` and nvim's split-separator drags have always worked and simply
never received the event. The same missing drag is why selecting text and
scrolling an application's output are impossible. Then there is no Ctrl key,
the tmux status bar's window names are a few pixels wide, and `ttyd` serves no
viewport meta tag at all, so a phone lays the page out at a virtual desktop
width and scales the result down, which no font size can rescue.
### Gestures
@@ -512,7 +562,10 @@ PREFIX ◀W W▶ SESS ZOOM DRAG PASTE KBD
without closing the tab. It reloads the page without the run-id, which is
what makes `dejima-attach` show the list again. The session you leave is
unaffected: each client gets its own tmux session grouped onto the real one,
and only that view is reaped when you detach.
and only that view is reaped when you detach. The picker arrives on a
cleared screen, scrollback included - it is a page load rather than a
scroll, so otherwise it draws over the tail of the session you just left and
a swipe up lands in the middle of it.
- `ZOOM` is prefix + `z`, toggling tmux's pane zoom - the fastest way to make
one pane usable on a small screen.
- `PASTE` reads the phone's clipboard and sends it, wrapped in bracketed-paste
@@ -524,6 +577,23 @@ PREFIX ◀W W▶ SESS ZOOM DRAG PASTE KBD
- `KBD` raises the soft keyboard, which is otherwise awkward to get back once
it has been dismissed.
**The buttons are colour-coded by what they are for**, in the same gruvbox
palette `dejima-attach` uses for the session picker - the two sit either side
of one attach and should not look like different programs.
The colours are a hierarchy, not decoration. `KBD` is filled orange because on
a phone it is how you type at all, and it is the one key that has to be
findable without looking for it. `⏎` is filled green, being the key a session
asks for most. The window and arrow keys are outlined in blue: reached for
constantly, but never the thing you are hunting for. Everything else is
furniture and stays grey. A latched `CTRL` or `DRAG` still overrides whichever
tier its button is in, because the state a key is in matters more than the job
it does.
Set `ttyd_button_colors = off` for the grey every key had before - for a
terminal colour scheme this clashes with, or if you would rather it were
quiet.
**Tapping the terminal does not raise the keyboard.** On a phone, tapping is
also how you scroll and select, so every one of those taps used to summon the
keyboard and cost a second tap to put it away. `KBD` is the deliberate way to
@@ -675,8 +745,8 @@ cost of losing the correction everywhere else.
Terminating TLS somewhere else and forwarding here is a supported shape, but
read this first: it moves the front door out of the LAN threat model
everything above is written against. If the outer proxy is internet-facing,
`auth_basic` over TLS is now the only thing between the public internet and
a host shell. Put something stronger in front of it, and take the
whatever authenticates it is now the only thing between the public internet
and a host shell, and HTTP Basic is a thin thing for that to be. Put something stronger in front of it, and take the
[security notes](security.md) as the floor rather than the ceiling.
The mechanical part has four requirements at the outer hop, and each one
@@ -728,14 +798,24 @@ what the outer proxy sends.
## Notifications
`dejima` registers `hooks/dejima-notify.sh` as a `Notification` and `Stop`
hook for a remote-enabled session, into the throwaway settings override layer
it already generates - never into your real `~/.claude/settings.json`. Your
own hooks keep running alongside it, since Claude Code merges hooks across
layers rather than replacing. The layer is mounted read-only, so a session
cannot unregister its own hook. The hook writes one empty file named
`waiting` or `done` into this run's marker directory: no content, no stdin,
no network.
`dejima` registers `hooks/dejima-notify.sh` as a `Stop` hook for a
remote-enabled session, into the throwaway settings override layer it already
generates - never into your real `~/.claude/settings.json`. Your own hooks
keep running alongside it, since Claude Code merges hooks across layers rather
than replacing. The layer is mounted read-only, so a session cannot
unregister its own hook. The hook writes one empty file named `done` into
this run's marker directory: no content, no stdin, no network.
**One push per stop, and not a second one a minute later.** The hook and the
watcher both understand a `waiting` event as well, and Claude Code's
`Notification` hook is where it would come from - but that hook fires when
Claude needs permission to run something and when the prompt has sat idle,
and a dejima session runs with permission prompts off. Only the timer is
left, and it expires shortly after the Stop it follows, so registering both
sent two pushes for one event with the second saying nothing the first had
not. What is given up is being told about a session that has gone idle
without having stopped first, which Claude Code cannot currently distinguish;
a harness that can may register `waiting` and get its own notification.
On the host, `dejima-notify-watch` turns those markers into ntfy pushes.
Same installation shape as above:
@@ -781,8 +861,9 @@ than a new exposure, but a RAM-backed filesystem fails harder and sooner.
There is deliberately no quota here; `rm -rf` on that run's marker directory (or
logging out, which discards the tmpfs) is the recovery.
**What a notification contains.** A title of `dejima: <project> needs input`
(or `dejima: <project> finished`), a fixed one-line body, and - when
**What a notification contains.** A title of `dejima: <project> finished`
(or `dejima: <project> needs input`, for a harness that registers `waiting`),
a fixed one-line body, and - when
`base_url` is set - a click link of `<base_url>/?arg=<run-id>` that opens the
web front door already attached to that session. `<project>` is the
workspace directory's name, taken from the host-side registry and reduced to
@@ -808,7 +889,20 @@ last point is what the enum is really for: ntfy's own headers include
issue an arbitrary HTTP request, so a single attacker-controlled header
would turn it into a confused deputy inside your network.
**When no notification arrives.** Five things have to happen in order, and a
**Where that guarantee stops is the container boundary.** `ntfy_url` is a
credential for a channel your phone trusts, sitting in a file on this host.
Anyone with your account on this machine can read it, publish whatever they
like to that topic, drown it until you mute it, or subscribe to it if it is
not write-only. None of that goes through the sandbox, so none of the
reasoning above applies to it.
Scope the topic accordingly: a token that can only write the topic dejima
uses, and a **separate** topic for anything you want to trust more than you
trust this host. Anything monitoring this machine's own health belongs in that
second topic, with its credential not on this machine at all - a box that can
forge alerts about itself is not reporting on itself.
**When no notification arrives.** Six things have to happen in order, and a
break in any of them looks identical from the phone - which is to say, like
nothing at all. Walk them from the inside out; each step tells you whether
the next one is worth checking.
@@ -817,9 +911,9 @@ the next one is worth checking.
and `echo $DEJIMA_NOTIFY_DIR`. Both empty means `dejima` started this
container with remote access off - `[remote-access] enabled` was off at
launch, and no later change affects a session already running.
2. **Is the hook registered?** The `Notification` and `Stop` entries live in
the throwaway override layer, not in your real `settings.json`:
`grep -c dejima-notify /etc/dejima-settings.json` from inside should be 2.
2. **Is the hook registered?** The `Stop` entry lives in the throwaway
override layer, not in your real `settings.json`:
`grep -c dejima-notify /etc/dejima-settings.json` from inside should be 1.
That file is passed to the harness by `harness-wrapper.sh`, as whatever
its manifest's `settings_args` names (`--settings` for Claude Code);
if it is missing entirely, this container was not started by `dejima`.
@@ -840,8 +934,17 @@ the next one is worth checking.
with a 404 usually means `ntfy_url` names the server but no topic; a 401
or 403 means the instance wants credentials, which the URL can carry as
`https://<user>:<token>@<host>/<topic>` - nothing here sends an auth
header of its own.
5. **Is the phone subscribed to the topic the config names?** Last in the
header of its own. That URL is handed to `curl` on stdin rather than as an
argument, because every argument of every process on a host is readable by
any local account through `/proc/<pid>/cmdline`, and a token in a URL is a
token in `ps` output for as long as the request runs. Worth knowing if you
reach for `curl -sv "$ntfy_url"` yourself in the next step: that one does
put it there, briefly, on a machine you are already logged into.
5. **Can this host reach the ntfy server at all?** `curl -sv "$ntfy_url"`
from the host, not from a session. A host-level egress filter this machine
does not control drops the push with nothing in any log here, and every
step above still passes - see the note under `ntfy_url` above.
6. **Is the phone subscribed to the topic the config names?** Last in the
list and first to check in practice, since it is the only link with no
log anywhere. The topic in `ntfy_url` and the one in the app have to be
the same string.
@@ -907,3 +1010,176 @@ from shrinking the desktop's view, and the option's only scopes are the
window - which a grouped session *shares* with yours, so setting it there
would permanently overwrite your choice with nothing to restore afterwards -
and global. An operator who deliberately set `smallest` keeps it.
## Mounting the box's files
The front doors above get you a terminal. The other half of working from
another machine is the files - the screenshots, specs and scratch output a
session leaves behind - and `sshfs` puts those directories in your own file
manager and editor over the SSH access you already have.
Read the next section before setting it up. What it costs is not obvious from
the fact that it is only a filesystem mount, and it is not a cost dejima can
carry for you: nothing dejima runs is in the path.
### What mounting it costs you
**Every byte and every name in those directories was written by a sandboxed
session.** That is what they are for - a handoff directory a session cannot
write to is not a handoff directory - so this is not a defect to fix but the
surface to know about. dejima sanitises session-authored bytes before
printing them to your terminal, in `dejima-audit` and `dejima-epitaph`,
because that is the one surface it controls. A file manager on another
machine is not that surface, and nothing carries the sanitising there.
**Mounting removes the deciding.** Copying a file off the box when you want
it is a decision per file. A mount, and especially one a login unit brings up
for you, means the directory is simply present whenever you are logged in -
and a desktop does not wait to be asked. A file indexer walks new directories and
opens what it finds to extract text from PDFs, archives and office documents.
A thumbnailer decodes images and video frames. A previewer parses on
selection. Those are image and document parsers, running as you, unsandboxed,
reached with no click. dejima's whole premise is that the session may be
hostile; none of that software was written with that assumption about a
directory on your own disk. This is the [threat
model](threat-model.md#ranked-by-what-is-actually-likely)'s fifth-ranked item
with its one bound removed - it is still the operator's side of the handoff,
but no person decides per file any more.
**Names are session-chosen too, and a desktop renders names.** Control
characters, bidi overrides, and a long name whose visible tail is not its real
extension all survive into a file listing. The rule for a terminal applies
unchanged to a file manager: what is displayed is not evidence of what it is.
**Opening the directory as a project is code execution.** Editors act on
configuration they find beside the files - `.envrc` for direnv, a `.vscode`
directory, a project-local `exrc`, a `.git/config` that any later `git`
command run in that directory honours. A session that can write the directory
chooses those files as freely as it chooses the screenshots. Pull files out of
the mount; do not point a project-opening tool at it.
**The mount is read-write, and your desktop uses that.** Thumbnail caches,
trash directories, editor swap and undo files land in the share, where every
later session reads them. An editor swap file can hold buffer content from
the file you had open, and deleting something in a file manager usually moves
it to a trash directory inside the same mount rather than removing it.
**Symlinks resolve on your machine.** `sshfs` passes a symlink through as a
symlink, so one a session planted resolves against *your* filesystem: a
backup job, an indexer, `cp -r` or `rsync -L` pointed at the mount can be led
out of it and into your own files. `-o follow_symlinks` inverts that by
resolving them on the box instead, which hands you the box's files rather
than yours. Neither is what you want, so do not add that option and do not
point recursive tools at the mount.
**What it does not add is a route inward.** A session cannot see the mount,
cannot cause one, and gains nothing from the fact that one exists. The ssh
credential involved already gets you a shell on that box, so it is not a new
grant either. Everything above is about where session-authored bytes end up,
which is a machine that appears nowhere in dejima's blast-radius table.
**Keeping it small** is most of the answer, and unmounting is the biggest
lever: none of the above happens to a directory that is not currently there.
Then mount the handoff directories rather than the box's home, add `-o ro` for
one you only ever pull from, exclude the mount point from your indexer's and
your backup tool's scope, and [clear `share_dir` out
periodically](configuration.md#shared-scratch-space) - which now means stale
files on two machines rather than one.
### Setting it up
**Use your ordinary key, not the front-door one.** A `command=` in
`authorized_keys` replaces whatever the client asked to run, and a
`subsystem sftp` request is not an exception, so a client offering the phone
key gets `dejima-attach` where it expected the SFTP protocol and fails
without explaining itself. Do not relax that key to make this work: the
forced command is the whole of what makes it safe to leave on a phone. The
key that already gets you a normal shell on the box is the right one, and it
grants nothing new here - you had the shell.
**Find the host path first.** `~/shared` is where the *container* sees it; on
the host it is `[core] share_dir`, which defaults to
`~/.local/share/dejima/shared` and is not named `shared` at all.
`dejima --print-config | grep share_dir` on the box prints the resolved path.
Anything in `[mounts] read_write` needs no lookup, because those mount at the
host path they name: `~/specs` in the container is `~/specs` on the box.
```sh
sshfs box:.local/share/dejima/shared ~/mnt/dejima-shared \
-o IdentityFile=/home/<you>/.ssh/id_ed25519,IdentitiesOnly=yes \
-o PasswordAuthentication=no \
-o reconnect,ServerAliveInterval=15,ServerAliveCountMax=3 \
-o Compression=no
```
`IdentitiesOnly=yes` stops ssh offering every key your agent holds ahead of
the one you named, which can exhaust the server's `MaxAuthTries` and fail as
though the key were wrong. `PasswordAuthentication=no` makes an unattended
mount fail immediately rather than block on a prompt nobody is there to
answer. The keepalives are what make `reconnect` worth having: they give up
after about 45 seconds, where a laptop that slept or changed network
otherwise leaves every `ls` hanging on the kernel's TCP timeout.
`Compression=no` because screenshots and PDFs are already compressed.
Two additions worth knowing rather than copying. `-o
Ciphers=aes128-gcm@openssh.com` is faster when both ends have AES-NI, and
slower when they do not - ssh's default puts `chacha20-poly1305` first
precisely because it wins on hardware without it. And `-o idmap=user` if your
account has a different UID on the two machines, or every file arrives owned
by whoever holds that number locally.
A `Host box` block in `~/.ssh/config` carrying the identity and keepalive
options keeps all of this to `sshfs box:... ~/mnt/...`, which is worth doing
once rather than pasting the long form wherever you need it.
**Mount it when you want it, and `fusermount3 -u` when you are done.** Two
commands is not much friction, and it buys back the thing the section above
says a mount costs: while it is unmounted there is no directory for an
indexer to walk or a previewer to open, so the window in which your machine
acts on session output is one you opened on purpose. Every automatic form
below trades that away. There is deliberately no `/etc/fstab` line here -
those run as root, which means a key root can read, root's `~/.ssh/config`
instead of yours, and `allow_other` before your own account can see inside
the mount, at which point every account on the machine can too. That is a
worse deal in every direction than typing `sshfs`.
### On login
**Prefer mounting on demand.** This is here because a mount you have to
remember is a mount you stop using, not because it is the better answer: an
always-present directory is exactly the case the cost section is about. Worth
it for a directory you genuinely work out of, like `~/specs`; not worth it
for one you look at twice a week.
A **user** service runs as you, so the key in your home directory works
without being made readable by anyone else. In
`~/.config/systemd/user/dejima-shared.service`:
```ini
[Unit]
Description=dejima shared directory
Wants=network-online.target
After=network-online.target
[Service]
ExecStart=/usr/bin/sshfs -f box:.local/share/dejima/shared %h/mnt/dejima-shared \
-o IdentityFile=%h/.ssh/id_ed25519,IdentitiesOnly=yes \
-o PasswordAuthentication=no \
-o reconnect,ServerAliveInterval=15,ServerAliveCountMax=3
ExecStop=/bin/fusermount3 -u %h/mnt/dejima-shared
Restart=on-failure
RestartSec=10
[Install]
WantedBy=default.target
```
`-f` is the load-bearing flag: without it `sshfs` daemonises, systemd watches
the parent exit and calls the mount a failure. `fusermount3` is the libfuse3
name, `fusermount` the libfuse2 one. Then
`systemctl --user enable --now dejima-shared`, and one unit per directory -
`%h` expands to your home, so the same file with two paths changed covers
`~/specs`.
`systemctl --user` needs `loginctl enable-linger <you>` if you want the mount
to survive you logging out, the same requirement `dejima-notify-watch` has.
+173
View File
@@ -0,0 +1,173 @@
# Reviewing the state of the art
[← dejima README](../README.md)
dejima's first month, from late July 2026, was spent reasoning from its own
threat model without checking it against anyone else's. The first sweep, in
August, turned up two things worth having found sooner than that:
- **A parser-differential bypass class against egress allowlists**, disclosed
against another vendor's agent network sandbox: a null byte in a SOCKS5
hostname, so the policy check read `attacker-host\x00.google.com` and matched
a `.google.com` suffix while the resolver read `attacker-host`. dejima's
matcher refused that shape and every neighbouring one, for structural reasons
now recorded beside it - it matches whole domains rather than suffixes, and
the decision and the dial are bound by the ipset being filled from the
answer the resolver returned. One laxity turned up and was fixed.
- **The closest comparable project ships the opposite default.** Trail of Bits'
Claude Code devcontainer lists network under "not sandboxed" and has full
outbound access unless an operator opts in. Working out why was worth more
than the comparison: their scope is the host filesystem for an auditor
watching a session, and network was never the thing they were containing.
Neither changed a control, which is the point. A month is not long to have gone
unchecked, and finding out was cheap - which is the argument for doing it on a
schedule rather than when something goes wrong.
This is the process. It is deliberately small: the failure mode of a research
review is producing a reading list nobody acts on.
## When to run one
**Before locking a control.** This repository's rule is to measure the premise
before designing on it. Extended one step: ask what measurement would falsify
the control, *and* whether someone has already run it - or already bypassed it.
- Before adding or changing a boundary: a mount, an egress path, a guard, a
credential that crosses.
- When a control is being justified by reasoning rather than by a test. That is
the moment to find out whether the reasoning is known to be wrong.
- When a bypass is published for anything in the same class. Another project's
egress allowlist bypass is a test case for this one.
- Quarterly as a standing sweep, whether or not anything is pending. A threat
model written eighteen months ago describes a different attacker.
That last one is the reason the schedule exists at all. Reported model
capability on offensive security tasks has moved fast enough that a sandbox
designed against a 2024 agent is not obviously adequate against a 2026 one, and
nothing else here re-examines that assumption on any schedule.
## The standing questions
The agenda. Each entry is a live claim dejima rests on, phrased so the field can
contradict it. Keep it current: a question nobody would answer "no" to is not
doing any work.
1. **Is a hardened container an adequate boundary for an agent assumed
hostile?** The field says no for untrusted code, and tiers gVisor and
per-workload VM kernels above it. dejima's answer is that escape moves a
layer - true where the layer exists. See
[Which deployment this describes](threat-model.md#which-deployment-this-describes).
2. **Does a name-based egress allowlist hold?** It holds against an agent that
must resolve a name. It does not hold against exfiltration through an
allowed domain or against DNS query-name channels, both of which are listed
in [What dejima does not sandbox](security.md#what-dejima-does-not-sandbox).
The open part is parser differentials between the matcher and the resolver.
3. **Where must egress enforcement sit?** Below the harness: a subprocess with
its own HTTP client bypasses anything the harness mediates. dejima enforces
at the container's network layer, which satisfies this. Settled unless the
architecture changes.
4. **Is "the operator merges" the right shape for human-in-the-loop?** The
field's phrasing is "human approval on irreversible actions", which is what
the merge and push guards implement. Corroborated; stop re-deriving it.
5. **What is the current bypass literature for agent sandboxes?** The most
valuable question and the one that ages fastest.
6. **Does anyone else treat the previous session's output as hostile input to
the next one?** The positioning question, and as of the first sweep the
answer is no.
7. **What does the field consider baseline that dejima does not do?** The
cheapest question to ask. It found the absence of timeouts, which is now a
[stated non-goal](security.md#why-there-are-no-timeouts) rather than a gap.
8. **Is a control argued in one layer honoured in the others?** The three
projects are three repositories with three vocabularies, and nothing checks
that a claim made in one reaches the next. The first sweep produced the
worked example: dejima blocks IPv6 outright and says why, `dejima-ci-hardener`
makes the same argument for its job network - and both sibling projects'
node-side firewalls were IPv4-only, written later, by people who had already
made that argument twice. Each project's tests pass, because each tests its
own layer against its own template. This question is the only thing looking
at the seams.
## How to run one
**1. Sweep, do not read.** Search broadly and read only summaries. This pass
produces a shortlist, not understanding. Search the standing questions, not
dejima's vocabulary - our terms are ours, and "guarded store" finds nothing.
**2. Read in full only what could change a control.** A source that agrees
changes nothing and can stay a citation. Read the ones describing a bypass of a
control that exists here, quantifying something asserted here, or occupying
ground assumed empty.
**3. Get the mechanism, not the headline.** "Egress allowlists are bypassable"
is not actionable. "The policy check saw one hostname and the resolver saw
another" is a test to write.
**4. Sort every finding into a bucket.**
| bucket | what it means | what to do |
| --- | --- | --- |
| **Prior art** | Someone built this | Read it properly, and start from where they ended. |
| **Corroboration** | They hold the same premise | Cite it, stop re-deriving it, and stop treating it as a differentiator. |
| **Their control** | They defend something dejima does not | Take the *distinction* if it is real. Do not take the *control* without asking what threat model produced it. |
| **Contradiction** | Their default is the opposite | The most valuable bucket. Work out whether the threat models differ before conceding or dismissing. |
| **Open ground** | Nobody does this | Say so, and say *why*. Usually a constraint others do not have; occasionally it means everyone else knows something. |
**5. Write down the contradiction even when it resolves in dejima's favour.**
The default-network difference above is the worked example: it reads as dejima
being ahead, and the reason turned out to be scope rather than rigour.
Recording the reason is what stops a different scope being mistaken for a
weaker one - by us, in six months, reading our own note.
**6. Turn every finding that changes a control into an issue.** A review that
ends in a document has not finished.
## What to record, and where it does not go
**A sweep is not committed here.** It is written to the shared mount on the
operator's machine, alongside the specs, plans and design notes that live
there for the same reason: a review is ephemeral, and what belongs in this
repository is the *action* it produced, not the reasoning that produced it.
What lands here is the outcome - an issue, a pull request, a paragraph in the
documentation section the finding actually bears on. This page is itself an
example: the August 2026 sweep produced a matcher fix, a
[not-sandboxed list](security.md#what-dejima-does-not-sandbox), a
[statement of which deployment the threat model describes](threat-model.md#which-deployment-this-describes),
and a stated non-goal on timeouts. The sweep that produced them stayed on the
operator's machine.
The sweep itself is one page, dated and sourced, in the house style, written
where the other ephemeral documents go. Two rules specific to reviews, because
both distinctions vanish within a week of writing:
- **Mark what was read in full and what came from a search summary.** A summary
of a bypass is enough to write a test case from and not enough to claim
dejima is safe from it.
- **Quote claims with their conditions.** "Containers are inadequate" is false
without "for untrusted code, where the host kernel is the boundary". The
honesty rules here apply to what is read as much as to what is written.
## The standing test queue
A bypass from the literature is a hypothesis about *their* implementation until
it is run against this one. dejima differs in ways that plausibly change the
answer: enforcement is `iptables` and a DNS proxy rather than an in-process
proxy, the matcher is an anchored shell regex rather than a string-suffix test,
the allowlist matches whole domains rather than suffixes, and the ipset is
populated from the answer the resolver returned rather than from a second parse
of a string.
Each entry states a prediction that can fail.
- **Hostile hostnames against `valid_domain`.** Null bytes, percent-encoding,
trailing dots, embedded newlines, IDNA, empty labels. Prediction: all refused.
*Run 2026-08-25: all refused except an empty label, which was accepted and
has since been fixed.*
- **A published SOCKS-style parser differential, run end to end** rather than
against the validator alone. Prediction: no path, because nothing in dejima
speaks SOCKS and the decision and the dial are bound by the ipset. Not yet
run.
- **Redirect chains to a host outside the allowlist.** Prediction: fail at the
ipset, because dejima does not mediate HTTP and the connection is judged by
address. Not yet run.
+83 -117
View File
@@ -2,9 +2,8 @@
[← dejima README](../README.md)
The README states each of these ten rules in a sentence or two. This is the
part that didn't fit: why each one matters, what goes wrong without it, and
the detail behind the three the README could only gesture at.
The README states each of these rules in a sentence or two. This is the part
that didn't fit: why each one matters, and what goes wrong without it.
## Check whether they want it first.
@@ -33,45 +32,37 @@ The concrete workflow, step by step:
6. Open the upstream pull request by hand from there, once you would have
been glad to have written it yourself.
The reason for the extra hop is what happens without it. An auto-push
workflow aimed straight at someone else's repository puts a session's raw
output directly into that project's pull request queue, unreviewed by
An auto-push workflow aimed straight at someone else's repository puts a
session's raw output into that project's pull request queue, unreviewed by
anyone, the moment it opens. Maintainers already spend real time triaging a
flood of low-effort AI-generated pull requests; a Godot maintainer's account
of what that actually costs is worth reading in full:
<https://bsky.app/profile/akien.bsky.social/post/3meyerixvhs2p>. Forking and
reviewing first doesn't change what the session is capable of writing. It
changes who reads the output before anyone else has to: you, not a stranger
who owns the project.
flood of low-effort AI-generated pull requests; a Godot maintainer's account of
what that costs is worth reading in full:
<https://bsky.app/profile/akien.bsky.social/post/3meyerixvhs2p>. Forking first
doesn't change what the session is capable of writing. It changes who reads the
output before anyone else has to: you, not a stranger who owns the project.
## Don't point it at proprietary code.
dejima narrows the paths code has to leave through; it doesn't close them,
and "point it only at code you could survive losing" is the practical test
for that gap. Ask it plainly before you start a session, not after: if this
checkout's contents ended up somewhere they shouldn't, would that be a bad
week or the end of the business? Code you could survive losing may be worth
the convenience of unattended agentic work. Code you couldn't shouldn't go in
at all.
dejima narrows the paths code has to leave through; it doesn't close them.
Ask it plainly before you start a session, not after: if this checkout's
contents ended up somewhere they shouldn't, would that be a bad week or the end
of the business? Code you could survive losing may be worth the convenience of
unattended agentic work. Code you couldn't shouldn't go in at all.
That second one is not a judgement about how good the containment is, and it
does not get better with a version bump. There is no containment that fully
prevents exfiltration: a determined attacker with code execution and any
outbound path will find a way. So the question is not whether you trust
dejima enough, but whether you can accept the risk of the contents leaving.
If you cannot, do not hand them to an agent that could send them.
That is not a judgement about how good the containment is, and it does not get
better with a version bump. No containment fully prevents exfiltration: a
determined attacker with code execution and any outbound path will find a way.
The question is not whether you trust dejima enough, but whether you can accept
the risk of the contents leaving.
## Look at what else is in the directory.
The bind mount doesn't know which files you meant to hand over. It exposes
the whole checkout, and everything a working tree accumulates over its life
sits there with the same reachability as the source files you actually care
about: a `.env` a teammate left uncommitted, a database dump pulled down for
local debugging and never deleted, a credential baked into a fixture file,
an old key still sitting in the history of a branch that got checked out.
None of that is code in any sense that matters to the agent; all of it is
just bytes in a directory the session can read and any tool call it makes
can touch.
The bind mount doesn't know which files you meant to hand over. Everything a
working tree accumulates sits there with the same reachability as the source
you actually care about: a `.env` a teammate left uncommitted, a database dump
pulled down for local debugging and never deleted, a credential baked into a
fixture, an old key on a branch that got checked out. All of it is bytes in a
directory the session can read and any tool call it makes can touch.
Auditing a working tree by eye is not a minute's work, and it is the kind of
check that succeeds every time until the once it matters. **Point dejima at a
@@ -82,25 +73,22 @@ directory not to hand over.
## An allowlist entry is a grant, not a convenience.
For every allowlist entry you give a dejima agent, consider what an attacker
could do with it. The answer varies enormously between entries that look
similar, and what it turns on is what happens if the call is wrong, whether
wrong means a prompt-injected instruction or an ordinary agent mistake.
For every allowlist entry, consider what an attacker could do with it. The
answer varies enormously between entries that look similar, and what it turns
on is what happens if the call is wrong, whether wrong means a prompt-injected
instruction or an ordinary agent mistake.
A package registry or a docs site can be wrong without being expensive. A
session that fetches the wrong package version, or scrapes a stale or
misleading page, produces bad input to more code, code that still has to
pass through your own review before any of it ships. The mistake is
contained by the same diff you were always going to read.
session that fetches the wrong package version, or scrapes a misleading page,
produces bad input to more code that still has to pass your own review. The
mistake is contained by the same diff you were always going to read.
A deploy webhook, a production database API, and a payment provider are a
different shape entirely. A single call against any of them is the action,
not a step toward one you'll review later: the deployment already ran, the
row is already written or gone, the charge already happened. There is no
diff downstream that catches it, because nothing about it passes through
your repository at all. Allowlisting one of these gives an unattended process
standing authority to act on the world, the same authority you would think
twice about handing a person.
A deploy webhook, a production database API and a payment provider are a
different shape entirely. A single call against any of them *is* the action,
not a step toward one you'll review later: the deployment already ran, the row
is already gone, the charge already happened. Nothing about it passes through
your repository, so no diff downstream catches it. Allowlisting one gives an
unattended process standing authority to act on the world.
Ask, before adding an entry: if this call happened by mistake, is the result
something I fix in the next commit, or something I have to explain to
@@ -114,21 +102,14 @@ contained perfectly and still produce something that should not be merged,
and the sandbox has no opinion either way.
This point is about the merge, not about code quality in general. What makes
`main` (or `master`, or whatever your default branch is called) different
from any other branch is that other things read it and believe it. It is
worth being precise about why, rather than saying "the released version" in
some vaguer sense. In this repository, dejima-ci-hardener resolves the
CI-side egress
allowlist by reading `.dejima/allowed-domains` from `origin/main`, never
from a branch or a local checkout, and treats whatever it finds there as
already reviewed. That trust is load-bearing: the only way content lands on
`main` is through a pull request you merged, so reading from `main` is how
the tool avoids asking a branch what it's allowed to do. So merging onto
`main` ships it to users and feeds a trust anchor that other tooling, this
one and potentially future ones, is entitled to rely on without re-checking.
Keeping insecure or broken code off `main` is what makes that reliance
justified, and that job is yours; nothing about running the agent in a
sandbox does it for you.
your default branch different from any other is that other things read it and
believe it. Concretely: dejima-ci-hardener resolves the CI-side egress
allowlist by reading `.dejima/allowed-domains` from `origin/main`, never from a
branch or a local checkout, and treats whatever it finds there as already
reviewed. That trust is load-bearing, and it is only justified because the one
way content lands on `main` is through a pull request you merged. Keeping
insecure or broken code off it is your job; nothing about running the agent in
a sandbox does it for you.
## Give it its own identity, then own what you merge.
@@ -152,32 +133,27 @@ it accordingly.
## Understand what you ship.
The risk here is habituation, not a single lapse. dejima makes iterating
cheap enough that reading every diff starts to feel like the slow part of
the loop, and skipping it works fine, repeatedly, until the one time it
doesn't. A reviewer who reads every change catches most of what's wrong with
it. A reviewer who only reads the ones that felt risky catches none of the
ones that didn't feel risky, which is the shape a subtle bug, or a
prompt-injected change dressed up as something ordinary, takes. Nothing
about a faster loop changes what engineering judgment is for.
The risk here is habituation, not a single lapse. dejima makes iterating cheap
enough that reading every diff starts to feel like the slow part of the loop,
and skipping it works fine, repeatedly, until the one time it doesn't. A
reviewer who only reads the changes that felt risky catches none of the ones
that didn't feel risky, which is the shape a subtle bug, or a prompt-injected
change dressed up as something ordinary, takes. Nothing about a faster loop
changes what engineering judgement is for.
## This is not a blastproof box.
Read this rule as a statement about what kind of failure to plan for.
dejima's containment is Linux namespaces and a small capability set around
the session, the same kind of isolation a container gives any process, not a
virtual machine boundary; it shares the host kernel with everything else on
the machine.
That buys real things (see [docs/security.md](security.md) for the specific
list: no working `sudo`, a deny-by-default firewall, read-only mounts over
the surfaces that matter most), and every one of them assumes the container
itself stays intact. A determined attacker, or a single container-escape
vulnerability, doesn't erode that protection gradually; it removes every
layer described in that document at once, because they all sit on the same
kernel. So the assumption to hold is not that the walls will probably hold.
It is that if this container is ever compromised at the kernel level,
everything reachable from this host account should be treated as compromised
with it, which is a very different thing to plan around than an ordinary bug.
This is a statement about what kind of failure to plan for. dejima's
containment is Linux namespaces and a small capability set around the session,
not a virtual machine boundary; it shares the host kernel with everything else
on the machine. That buys real things (see [docs/security.md](security.md): no
working `sudo`, a deny-by-default firewall, read-only mounts over the surfaces
that matter most), and every one of them assumes the container stays intact. A
container-escape vulnerability doesn't erode that protection gradually, it
removes every layer at once, because they all sit on the same kernel. So the
assumption to hold is not that the walls will probably hold: it is that if this
container is ever compromised at the kernel level, everything reachable from
this host account should be treated as compromised with it.
What "everything reachable from this host account" amounts to is not fixed,
and it is the one part of this that can be narrowed from outside. Bounding
@@ -190,34 +166,24 @@ would have to be bounded.
## dejima's security says nothing about your software's.
These are different axes, not different degrees of the same thing. dejima's
threat model is about the session: what a process running inside the
sandbox can reach, exfiltrate, or execute while it runs. None of that has
anything to say about the software that session writes once it's on disk.
dejima can contain a session perfectly and that session can still write a
SQL injection, hand-roll a broken authentication check, hardcode a secret,
or pull in a compromised dependency, because those are properties of the
code itself, decided at the moment it's written, not something a sandbox
sitting around the process that wrote it can retrofit afterward. If what got
built inside dejima turns out to be insecure, containment was never the
control that was supposed to catch it.
threat model is about the session: what a process running inside the sandbox
can reach, exfiltrate, or execute while it runs. It can contain a session
perfectly and that session can still write a SQL injection, hand-roll a broken
authentication check, hardcode a secret, or pull in a compromised dependency.
Those are properties of the code, decided when it is written.
**It is not something a future agent can retrofit either.** "We will do a
security pass later" was already a weak plan before agents made it cheap to
say. Early architectural decisions
close off the fixes for whole classes of problem: where trust boundaries sit,
what is authenticated and where, what data crosses which process, what a
single compromised component reaches. A later pass can find a missing bounds
check. It cannot move a boundary you built a system on top of. And insecure
implementation decisions sit in software for years without announcing
themselves, which is exactly why they are not found by a pass that goes
looking once.
**It is not something a future agent can retrofit either.** Early
architectural decisions close off the fixes for whole classes of problem: where
trust boundaries sit, what is authenticated and where, what data crosses which
process, what a single compromised component reaches. A later pass can find a
missing bounds check. It cannot move a boundary you built a system on top of.
And insecure implementation decisions sit in software for years without
announcing themselves, which is exactly why a pass that goes looking once does
not find them.
So security has to be considered at every point in the work, not scheduled
as a phase, and dejima will not help you do that. It makes writing a lot of
code fast, which makes writing a lot of insecure code fast if nobody is
holding that concern. That responsibility is yours at every step, and it is
the one thing here no tooling in this repository is aiming at.
The same practices you'd apply to code from any other source, review,
linting, dependency scanning, tests aimed at the failure modes you care
about, still have to happen here. dejima does not stand in for any of them.
So security has to be considered at every point in the work rather than
scheduled as a phase, and dejima will not help you do that. It makes writing a
lot of code fast, which makes writing a lot of insecure code fast if nobody is
holding that concern. Review, linting, dependency scanning and tests aimed at
the failure modes you care about all still have to happen; dejima stands in for
none of them.
+310 -166
View File
@@ -4,8 +4,8 @@
**Threat model, honestly stated:** this reduces the blast radius of a
misbehaving or prompt-injected coding agent - it does not make it safe to
point at arbitrary untrusted or adversarial input, and it does not turn
`--dangerously-skip-permissions` into something without real risk. Point it
point at arbitrary untrusted or adversarial input, and it does not make
running an agent with its permission prompts off safe. Point it
at repositories you already trust. Everything below is about how bad a
compromised session *inside* that trust boundary can get, not a claim that
one can't happen.
@@ -16,6 +16,69 @@ person thought to check, and no outside audit or adversarial testing has
happened. Read what follows as claims awaiting scrutiny rather than claims
that have survived it. See [SECURITY.md](../SECURITY.md).
## What dejima does not sandbox
Every item here is documented somewhere below, and a document organised by
control makes a reader assemble the negative space themselves. Most will not.
So it is collected here, at the top, where it is the first thing read rather
than the thing inferred from silence.
- **Exfiltration through a domain that is allowed.** The egress allowlist
decides *where* a session may connect, not what it may put in the request. A
session that can reach a host can encode data into a path or a query it
sends there. Allowing a domain at all creates that channel.
- **Data encoded into DNS query names.** The DNS proxy answers for allowlisted
domains; the names asked for are a channel of their own.
- **Anything below the container, where nothing is arranged there.** dejima's
blast-radius table has rows beneath the container, and their controls belong
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).
- **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
(see the audit surfaces below) and does not constrain what that server then
does. Per-tool granularity would need a second sandbox inside this one.
- **Resource exhaustion.** No time limit, no memory limit, no CPU limit. A
session that loops until someone notices is not prevented, and this is a
deliberate omission rather than an oversight - see
[Why there are no timeouts](#why-there-are-no-timeouts).
- **A suspended job. Ctrl-Z stops the observer, not the session.** It
suspends `bin/dejima` on the host; the container keeps running, with its
mounts, its firewall grants and its forge token, and keeps making requests
while the terminal looks stopped. That is deliberate - SIGSTOP mid-tool-call
leaves a session wherever it was, and the feature exists to get the terminal
back rather than to pause the work - but a stopped job reads as a paused
agent, and the difference matters most to whoever suspends one because they
are uneasy about what it is doing.
- **The first layer's controls against an agent that has stopped
cooperating.** The briefing and `.dejima.md` are advice. They shape what a
cooperating agent does and stop nothing that has decided otherwise. Only the
layers below them are enforcement.
## Why there are no timeouts
Every published checklist for sandboxing an agent lists timeouts alongside
non-root, egress filtering and read-only mounts. dejima has none, and the
reason is a judgement about what dejima is for rather than an item not yet
reached.
**A runaway session costs money and does not cross the boundary.** It burns
tokens and holds a CPU until someone looks. Nothing about it reaches the host,
widens the allowlist, or writes where a session may not write - the guarantees
dejima makes are all still held while it happens. A timeout would protect a
budget, which dejima has never claimed to protect.
**And "unattended" here does not mean "unobserved".** The sessions this is
built for run for days, and are checked at every issue and every pull request.
They need less babysitting, not none. A wall clock tuned for that is either so
long it prevents nothing or short enough to kill work in progress, and killing
a session mid-commit is a failure mode of its own.
If this changes it will be because a resource limit starts protecting the
boundary rather than the bill.
## Blast radius by layer
Where each layer's compromise stops, with and without the thing arranged
@@ -195,13 +258,27 @@ checkout itself and needs no symlink to edit, so a session with write access
to this repo's own checkout can change what the next launch composes and
hands to whichever session starts against it.
The provider directories are in the exit-time audit's TCB list for that
reason, alongside `bin/`, `hooks/`, `docker/` and `test/`. They hold no code
and nothing executes them, but what they hold decides what the next launch
permits: a forge's `fixed_hosts` reaches `DEJIMA_FORGE_HOSTS`, which
`entrypoint.sh` joins into the domains `init-firewall.sh` allowlists, so a
line added to a manifest widens the egress boundary on the next run. An
ordinary edit is a diff you are about to read and is not reported; one git
**Which requires dejima's own checkout to be the workspace, and almost never
is.** Nothing mounts `forges/`, `workflows/`, `harnesses/`, `tools/` or the
rest of the checkout into a container - the resolved values go in, the files
do not - so for every launch pointed at some other project, a session cannot
read these paths, let alone write them. The rest of this section is about the
one configuration where it can: dejima sandboxing the repository it is itself
installed from, which is how dejima is developed. `bin/dejima` says so at
launch when it notices.
In that configuration the provider directories are in the exit-time audit's
TCB list, alongside `bin/`, `hooks/`, `docker/`, `test/` and `tools/`. They
hold no code and nothing executes them, but what they hold decides what the
next launch permits: a forge's `fixed_hosts` reaches `DEJIMA_FORGE_HOSTS`,
which `entrypoint.sh` joins into the domains `init-firewall.sh` allowlists, so
a line added to a manifest widens the egress boundary on the next run.
`tools/*/tool.conf` is the same class, and is read by the host rather than the
container: it names a tarball URL, the sha256 checked against it and the
layout it is unpacked with, all consumed by the image build - so a session
that edits one chooses what the next launch downloads and installs as root.
An ordinary edit is a diff you are about to read and is not reported; one git
cannot see - hidden by `.gitignore`, `.git/info/exclude`, `assume-unchanged`
or `skip-worktree` - is a CRITICAL finding. It is still a change a session
can make; it is one the audit reports. See
@@ -209,8 +286,8 @@ can make; it is one the audit reports. See
## The mounted project is fully writable
- `--dangerously-skip-permissions` removes your chance to review tool calls
before they run. Only point this at trusted repositories.
- Running the harness with its permission prompts off removes your chance to
review tool calls before they run. Only point this at trusted repositories.
- The mounted project itself has **no write protection**. `deny-dangerous-
bash.sh`, `git-push-guard.sh` and `merge-guard.sh` are regex
pattern-matching on the literal Bash command string - a fast-fail guard
@@ -242,169 +319,180 @@ can make; it is one the audit reports. See
either, but only after the session ends - it doesn't stop them.
## The exit-time audit
- `bin/dejima-audit` snapshots host-persistence surfaces before each
run and diffs them after the container exits: `.git/config` (and worktree
`config.worktree`) keys that run a command on the next `git` -
`core.hooksPath`, `core.fsmonitor`, `core.sshCommand`,
`credential.helper`, `core.pager`, `pager.*`, `core.editor`,
`sequence.editor`, `gpg.program`, `diff.external`, `merge.*.driver`,
`diff.*.command`, `filter.*.smudge`/`clean`/`process`, `diff.*.textconv`,
`include.path`/`includeif.*.path` (config indirection - these can point at
a planted file that itself sets an exec key, so the exec key never
appears in `.git/config` directly), `!`-shell aliases - plus content
changes under the workspace's `.claude/`, `.mcp.json`, `.envrc`,
`.vscode/`, and `.git/hooks/` (symlinks included - a repointed symlink is
hashed by its target, not just its content). When the mounted workspace is
this repo itself, it also hashes the sandbox's own trusted computing base -
`bin/`, `hooks/`, `docker/`, `install.sh`, `test/`.
`bin/dejima-audit` snapshots host-persistence surfaces before each run and
diffs them after the container exits. It is **advisory**: it fires after the
session ended, so a planted `core.hooksPath` is something to clean up before
the next `git` or `claude` in that repo, not something that was blocked. It is
silent when nothing changed and never affects the session's exit code.
Worth knowing about that first surface, because the sandbox's own workflow
makes it easy to get wrong: **a git worktree isolates the working tree, not
`.git/config`.** Config is shared with the parent checkout, so a bare
`git config <key> <value>` inside a worktree writes the real repository's
config, including any exec key above. The briefing now says so, and gives
`git -C <path> config`. A guard against an unaware agent, not a boundary:
`.git/config` is in the read-write project mount and cannot be read-only
without breaking `git push -u`, which writes `branch.<name>.merge` on every
push. Detection covers the rest, and did - this was found by the audit
reporting a `.git/config` change.
**Its diff is time-windowed, not session-attributed:** a finding means a path
changed while the session ran, not that the session changed it. A concurrent
session, another host process, or your own editing all land in the same diff,
reported identically.
**Advisory only.** It fires after the session ended, so a planted
`core.hooksPath` is something to clean up before the next `git` or
`claude` in that repo, not something that was blocked. Silent when nothing
changed, and never affects the session's exit code. It does not cover
`[core] share_dir` (see
[Shared scratch space](configuration.md#shared-scratch-space)), and its
coverage is a fixed list rather than a general integrity check, so a config
surface it has not been taught about goes unreported.
### What it watches
**Its diff is time-windowed, not session-attributed:** a finding means a
path changed while the session ran, not that the session changed it. A
concurrent session, another host process, or your own editing all land in
the same diff, reported identically.
- **Approving and ignoring are different decisions, and the difference is
what you are promising about next time.** Approving records the exact
content: `safe <id> <sha>`, so this file with these bytes is fine and any
other content re-fires. Ignoring records the surface: `spam <id>`, so this
path or key is fine whatever it later contains. Approve a hook you have
read; ignore a key some tool rewrites on every run and which you have
decided you will never care about. The review offers both, and says which
is which rather than leaving you to infer it:
`.git/config` (and worktree `config.worktree`) keys that run a command on the
next `git` - `core.hooksPath`, `core.fsmonitor`, `core.sshCommand`,
`credential.helper`, `core.pager`, `pager.*`, `core.editor`, `sequence.editor`,
`gpg.program`, `diff.external`, `merge.*.driver`, `diff.*.command`,
`filter.*.smudge`/`clean`/`process`, `diff.*.textconv`,
`include.path`/`includeif.*.path` (config indirection - these can point at a
planted file that itself sets an exec key, so the exec key never appears in
`.git/config` directly), `!`-shell aliases - plus content changes under the
workspace's `.claude/`, `.mcp.json`, `.envrc`, `.vscode/`, and `.git/hooks/`
(symlinks included - a repointed symlink is hashed by its target, not just its
content). When the mounted workspace is this repo itself, it also hashes the
sandbox's own trusted computing base: `bin/`, `hooks/`, `docker/`,
`install.sh`, `test/`.
```
[a] approve this exact content [i] ignore this key from now on [s] skip (ask again next time) [q] quit
```
Coverage is a fixed list rather than a general integrity check, so a config
surface it has not been taught about goes unreported. It does not cover
`[core] share_dir` (see
[Shared scratch space](configuration.md#shared-scratch-space)).
A finding whose file has since been deleted is **cleared** rather than
approved: there is no content left to pin an approval to, and a surface
that does not exist does not run. Nothing is recorded, so if it comes back
it comes back as a new finding. Refusing would leave the report
unresolvable: `ack` opens CRITICAL reports first, so every other report
would hide behind one no decision could clear.
**A git worktree isolates the working tree, not `.git/config`.** Config is
shared with the parent checkout, so a bare `git config <key> <value>` inside a
worktree writes the real repository's config, including any exec key above. The
briefing says so, and gives `git -C <path> config`. That is a guard against an
unaware agent, not a boundary: `.git/config` is in the read-write project mount
and cannot be read-only without breaking `git push -u`, which writes
`branch.<name>.merge` on every push. Detection covers the rest, and did - this
was found by the audit reporting a `.git/config` change.
Skipping asks again next session, the right default when you are unsure.
It matters for keys some tools rewrite on every run: with no decision
recorded, one re-fires every session and pushes real findings out of the
capped list.
### Approving and ignoring are different decisions
Severity is not negotiable by either verb. An exec-capable key or shell
alias is CRITICAL whatever the baseline says, and an `ignore` entry is
refused against a CRITICAL surface when the baseline is read, not only when
written - so neither decision can quietly downgrade something that matters.
The difference is what you are promising about next time. Approving records the
exact content (`safe <id> <sha>`), so this file with these bytes is fine and any
other content re-fires. Ignoring records the surface (`spam <id>`), so this path
or key is fine whatever it later contains. Approve a hook you have read; ignore
a key some tool rewrites on every run and which you have decided you will never
care about. The review offers both, and says which is which:
- `dejima-audit ack` shows what it is asking you to approve. For a surface
that changed during the session it prints a unified diff of the
pre-session content against the current content; for one created during
the session it prints the whole current content. To make that possible,
`snapshot` stages a copy of each monitored surface's bytes for the
duration of the session, and `compare` turns those into **bounded
diffs** stored in a `<report>.d/` directory beside the report. What
persists after the session is only the diff of a *modified* surface -
never a whole file. A created surface stores a marker (its content is
live on disk); a removed one stores nothing at all, because a deletion
diff *is* the whole former file.
```
[a] approve this exact content [i] ignore this key from now on [s] skip (ask again next time) [q] quit
```
Staging and retained diffs live under `$AUDIT_DIR`
(`~/.local/share/dejima/audit`), never `/tmp`. That is the boundary the
scheme rests on: nothing in `docker-compose.yml` covers it, so the
container cannot read earlier runs' retained content, and `static.bats`
pins that. A store configured *inside* the workspace is readable and
deletable from the sandbox, so nothing is retained in that case - only a
note saying why.
Skipping asks again next session, the right default when you are unsure. It
matters for keys some tools rewrite on every run: with no decision recorded, one
re-fires every session and pushes real findings out of the capped list.
A `SIGKILL`ed session runs no EXIT trap and leaves its staging directory;
the next run against that workspace reaps it, identifying dead owners by
pid and process start time from an `.owner` file so a concurrent session is
never mistaken for dead. A missing or unreadable owner record falls back to
a 24-hour age check rather than guessing.
A report walked to the end stops being counted as waiting, but stays on disk as
the record of what that session did. `[audit] keep` decides how many are
retained; rotation evicts one, `ack` never does.
Content is withheld from the store in several further cases, and in
every one `ack` prints the reason in place of the diff - so an absent
diff is never mistaken for an absent change - and still shows the live
current content on your terminal, because withholding is about the
persistent store, not about what you are shown while deciding. Surfaces
that hold credentials by convention are never staged in the first
place, so their bytes are never even copied transiently: `.envrc`,
`.mcp.json` and `.claude/settings.local.json` by exact path, and
`.env`/`.env.*` by *basename*, at any depth - so a nested `.claude/.env`
is caught too, not just one at the workspace root. A surface that
existed before the session but was too large to have been staged (over
1 MiB) withholds with "no pre-session copy", not a false claim that it
is new. The *current* side is bounded by that same 1 MiB cap, and - like
the staged side - checked *before* any copy is taken: a file the session
grew past it withholds with "change too large to retain" rather than
being copied through `$TMPDIR` first. The ordering is the point, not the
cap. `compare` runs from the exit trap on every exit and the size is the
session's own choice, so copying first would turn "I filled my own
workspace" into that many bytes written to `$TMPDIR` - often a tmpfs, so
host RAM - and into a whole copy left there if the exit was a `SIGKILL`.
A generated diff is withheld if it is binary, over 200 lines
or 64 KiB (and is bounded to that as it is generated, for the same
reason), or matches a best-effort credential screen - vendor prefixes
including `sk-ant-`, `sk-proj-`, `ghp_`, `gho_`, `github_pat_`,
`glpat-`, Slack's `xox[baprs]-`, `AIza`, `AKIA`, `_authToken`,
`Authorization: Bearer`, and PEM private-key headers, matched
case-insensitively, since `.claude/settings.json`'s `env` block - the one
high-value surface this is the only net for - conventionally holds its keys
upper-case. The screen is best-effort with real false negatives: the
denylist above is the reliable half.
A finding whose file has since been deleted is **cleared** rather than approved:
there is no content left to pin an approval to, and a surface that does not
exist does not run. Nothing is recorded, so if it comes back it comes back as a
new finding. Refusing would leave the report unresolvable, since `ack` opens
CRITICAL reports first.
A git config key with more than one value on either side is not compared.
Git resolves such a key to its last value in FILE order; the audit parses
and sorts, so lexicographically-last is not what git runs, and the
pre-session side is every value the key had. Comparing anyway produced a
diff over values git does not use - two pre-session values read as a
deletion that never happened, while the appended value git now executes
appeared nowhere. It withholds instead, and must be compared by hand.
`include.path` is legitimately multi-valued, so this is an ordinary shape.
Severity is not negotiable by either verb. An exec-capable key or shell alias is
CRITICAL whatever the baseline says, and an `ignore` entry is refused against a
CRITICAL surface when the baseline is read, not only when written - so neither
decision can quietly downgrade something that matters.
A diff can also come out empty when the config file's TEXT changed but the
parsed value did not, e.g. a re-quoted value. That withholds too, saying so
rather than showing nothing - an empty diff for a reported change reads as
a bug.
### What `ack` shows you
A per-report **retention budget** (`RETAIN_BUDGET`, 1 MiB) bounds the total
diff bytes one report keeps. Once spent, the rest withhold and both the
report and the stderr summary add an alarm line: that many changed
host-persistence surfaces in one session is a signal in itself, not a limit
worth raising. `diffs = off` in `[audit]` disables retention and staging.
For a surface that changed during the session it prints a unified diff of the
pre-session content against the current content; for one created during the
session it prints the whole current content. To make that possible, `snapshot`
stages a copy of each monitored surface's bytes for the duration of the session,
and `compare` turns those into **bounded diffs** in a `<report>.d/` directory
beside the report. What persists afterwards is only the diff of a *modified*
surface, never a whole file. A created surface stores a marker (its content is
live on disk); a removed one stores nothing, because a deletion diff *is* the
whole former file.
What it prints is chosen by what changed, which is not always what you need in
order to judge it: three changed lines in a hook script are only judgeable by
reading the script. `[o]` at the prompt opens the file in `$VISUAL`/`$EDITOR`,
read-only where the editor understands how, and returns to the same decision. It
opens the file **as it is now**, for the same reason an approval pins the content
on disk at the moment it is recorded: approving something nobody read would be
the one outcome worse than not asking.
Staging and retained diffs live under `$AUDIT_DIR`
(`~/.local/share/dejima/audit`), never `/tmp`. That is the boundary the scheme
rests on: nothing in `docker-compose.yml` covers it, so the container cannot read
earlier runs' retained content, and `static.bats` pins that. A store configured
*inside* the workspace is readable and deletable from the sandbox, so nothing is
retained in that case, only a note saying why.
A `SIGKILL`ed session runs no EXIT trap and leaves its staging directory; the
next run against that workspace reaps it, identifying dead owners by pid and
process start time from an `.owner` file so a concurrent session is never
mistaken for dead. A missing or unreadable owner record falls back to a 24-hour
age check rather than guessing.
### When content is withheld from the store
In every case below `ack` prints the reason in place of the diff - so an absent
diff is never mistaken for an absent change - and still shows the live current
content on your terminal, because withholding is about the persistent store, not
about what you are shown while deciding.
Surfaces that hold credentials by convention are never staged in the first
place, so their bytes are never even copied transiently: `.envrc`, `.mcp.json`
and `.claude/settings.local.json` by exact path, and `.env`/`.env.*` by
*basename*, at any depth - so a nested `.claude/.env` is caught too.
A surface that existed before the session but was too large to have been staged
(over 1 MiB) withholds with "no pre-session copy", not a false claim that it is
new. The *current* side is bounded by that same cap, and checked *before* any
copy is taken: a file the session grew past it withholds with "change too large
to retain" rather than being copied through `$TMPDIR` first. The ordering is the
point, not the cap. `compare` runs from the exit trap on every exit and the size
is the session's own choice, so copying first would turn "I filled my own
workspace" into that many bytes written to `$TMPDIR` - often a tmpfs, so host
RAM - and into a whole copy left there if the exit was a `SIGKILL`.
A generated diff is withheld if it is binary, over 200 lines or 64 KiB (bounded
as it is generated, for the same reason), or matches a best-effort credential
screen: vendor prefixes including `sk-ant-`, `sk-proj-`, `ghp_`, `gho_`,
`github_pat_`, `glpat-`, Slack's `xox[baprs]-`, `AIza`, `AKIA`, `_authToken`,
`Authorization: Bearer`, and PEM private-key headers, matched case-insensitively
since `.claude/settings.json`'s `env` block - the one high-value surface this is
the only net for - conventionally holds its keys upper-case. The screen has real
false negatives; the denylist is the reliable half.
A git config key with more than one value on either side is not compared. Git
resolves such a key to its last value in FILE order; the audit parses and sorts,
so lexicographically-last is not what git runs, and the pre-session side is every
value the key had. Comparing anyway produced a diff over values git does not use:
two pre-session values read as a deletion that never happened, while the appended
value git now executes appeared nowhere. It withholds instead, and must be
compared by hand. `include.path` is legitimately multi-valued, so this is an
ordinary shape.
A diff can also come out empty when the config file's TEXT changed but the parsed
value did not, e.g. a re-quoted value. That withholds too, saying so rather than
showing nothing, since an empty diff for a reported change reads as a bug.
A per-report **retention budget** (`RETAIN_BUDGET`, 1 MiB) bounds the total diff
bytes one report keeps. Once spent, the rest withhold and both the report and the
stderr summary add an alarm line: that many changed host-persistence surfaces in
one session is a signal in itself, not a limit worth raising. `diffs = off` in
`[audit]` disables retention and staging.
Two residual limits on top of the screen's false negatives. The prompt truncates
at 40 lines, naming the file so you can read the rest, so a large planted change
is cut exactly where you would want it whole. And the `SIGKILL` case is an
exposure window, not just a cleanup mechanic: until the next run reaps it, the
staging directory holds whole pre-session file copies of every monitored surface,
for as long as the workspace goes unused.
### Findings reach your terminal as untrusted text
They name a path or config key the session may have chosen. One containing an
ASCII or C1 control or a Unicode bidi override - which can scroll, blank or
reorder what you read, and split a name across the audit's own records - is
written hex-encoded behind a `{hex}` marker and decodes with
`printf '%b' "$(sed 's/../\\x&/g' <<< HEX)"`. Ordinary names print as-is, so
the marker appearing is itself worth a look. The encoded form is what `ack`
records, so what you acknowledge is the file on disk rather than a readable
approximation of its name.
Two residual limits on top of the screen's false negatives. The prompt
truncates at 40 lines, naming the file so you can read the rest - a large
planted change is cut exactly where you would want it whole. And the
`SIGKILL` case is an exposure window, not just a cleanup mechanic: until
the next run reaps it, the staging directory holds whole pre-session file
copies of every monitored surface, for as long as the workspace goes
unused.
- Findings name a path or config key the session may have chosen, so they
reach your terminal as untrusted text. One containing an ASCII or C1
control or a Unicode bidi override - which can scroll, blank or reorder
what you read, and split a name across the audit's own records - is written
hex-encoded behind a `{hex}` marker and decodes with
`printf '%b' "$(sed 's/../\\x&/g' <<< HEX)"`. Ordinary names print as-is,
so the marker appearing is itself worth a look. The encoded form is what
`ack` records, so what you acknowledge is the file on disk rather than a
readable approximation of its name.
## One project's session cannot reach another's
- `~/.claude/projects` (session transcripts and per-project memory) is
@@ -696,11 +784,29 @@ session, where the credential deliberately never goes.
the README) has no `sudo`/setuid path back to root once the container is up:
`entrypoint.sh` runs as root, sets up the firewall directly, then drops to
that user via `gosu` before execing the configured command (`[core] command`,
`claude` by default). A compromised session can't
or the selected harness's own if that is unset). A compromised session can't
re-run `init-firewall.sh` with a wider allowlist to open its own
exfiltration path. Verified: capabilities (`NET_ADMIN`/`NET_RAW`) are
cleared from the effective/permitted sets on the `gosu` drop, and that
user gets a permission error running `iptables` directly.
- A forge credential reaches the container as `-e NAME`, never
`-e NAME=value`. The value comes from the launcher's own environment, so it
sits in `/proc/<pid>/environ` (mode 0400, owner only) rather than in
`/proc/<pid>/cmdline`, which is world-readable and which `ps` prints to
anyone. `docker inspect` still shows the resolved value to whoever can
reach the daemon - that is daemon access rather than any process at all,
and it is not something the launcher can change.
- The container runs with `no-new-privileges` and an explicit capability
floor - `cap_drop: ALL` plus the ones it actually uses: what the
entrypoint's root phase needs, and `KILL`. `KILL` is there for
`dejima-allow --container`, not for the entrypoint - adding a domain to a
running session signals dnsmasq, and dnsmasq runs as its own user. The list
itself is in `docker/docker-compose.yml`, which is where it stays correct. The first means a setuid binary or a file capability cannot raise
privileges on exec, which is the shape any escalation from the session user
would have to take; the second means the capabilities nothing here uses were
never in the bounding set to be regained. Both are asserted against a running
container rather than against the compose file, and the capability list the
test uses is read out of that file so the two cannot drift.
- The approval store (`${DEJIMA_CONFIG_DIR:-$HOME/.config/dejima}`,
see
[Global and per-project domains and dependencies](../README.md#global-and-per-project-domains-and-dependencies)
@@ -846,6 +952,14 @@ strips AAAA answers): there's no IPv6 equivalent of the allowlist above, so
leaving it open would bypass the whole firewall on any host with IPv6
egress.
Where `ip6tables` cannot be used at all, the firewall does not conclude that
IPv6 is therefore absent - it asks the kernel, and treats an address on any
interface other than loopback as the family being live. It then turns IPv6
off in the container's own network namespace, and **refuses to start** if it
cannot. 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.
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.
@@ -892,12 +1006,15 @@ is precisely when nothing is running to probe in the first place. Pass
`dejima-allow --global --container <id> crates.io` records the approval and
patches that session immediately, in that order, so the approval is never
lost to a live-session failure. If more than one dejima container is
running, the bare form above refuses and asks you to disambiguate with
`--container <name>` - `docker ps` finds it, or use the
container's own ID, which `sandbox-context.sh` already hands the agent in
its startup context (and which is always available inside the sandbox as
`cat /etc/hostname`), so there's no need for it to guess one and risk
"successfully" updating someone else's session instead.
running, the bare form above refuses rather than guessing. On a terminal it
offers the running sessions to pick from, by project; anywhere else - a
script, a pipe, a session asking its operator to run this - it prints the same
rows and stops, because a prompt nobody can answer is worse than a refusal.
Either way the answer is in front of you instead of in a separate `docker ps`.
A session has its own ID regardless: `sandbox-context.sh` hands it to the
agent in its startup context, and `cat /etc/hostname` has it inside the
sandbox, so there is no need for it to guess one and risk "successfully"
updating someone else's session instead.
A domain allowed this way stays allowed for the rest of the session even
if its CDN later rotates edge IPs: `dns-proxy.sh` tops up the firewall's
@@ -978,6 +1095,33 @@ name. It belongs in that harness's worked example in
[development.md](development.md#adding-a-new-harness) alongside the rest of
what it can and cannot do.
## The one request dejima itself makes
Everything above bounds what a *session* reaches. dejima's own host side
reaches one place, once an hour at most: `bin/dejima-update-check` asks the
remote your checkout already tracks for the tip of the branch it already
follows, so that a launch can say when the checkout is behind. It is
`ls-remote`, so no objects are downloaded, and `[core] update_check = off`
stops it entirely. There is no other endpoint and no telemetry - a domain
you have not already configured dejima to push to is never contacted.
It reaches that remote by URL, from outside the repository, and never by
remote name. `git ls-remote origin` reads the checkout's own config for how
to get there, and `core.sshCommand`, `credential.helper` and
`url.<base>.insteadOf` each name a command git then runs on the host. dejima
mounts its own checkout writable when that checkout is the workspace, so a
session that wrote `.git/config` there would otherwise get host execution out
of the next launch. That is the same class as the `core.hooksPath` route
above, with one difference worth stating: `hooksPath` waits for the operator
to run a git command, and a check that runs at every startup does not wait
for anything. Handing the URL to a git started outside the repository leaves
none of that config in the path, and `protocol.allow` holds the transports to
ones that cannot name a command.
What still crosses is the URL itself, which a session with that same write can
point at a host of its choosing. That costs one outbound request and no
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
+96 -5
View File
@@ -2,16 +2,16 @@
[← dejima README](../README.md)
What dejima defends, what it does not, and where its boundary passes out of
its hands. [security.md](security.md) is the control-by-control account; this
is the shape of the thing those controls are arranged against.
**And none of it has been reviewed by anyone else.** This is early
development: the reasoning here is one person's, the tests assert what that
person thought to check, and no outside audit or adversarial testing has
happened. Read what follows as claims awaiting scrutiny rather than claims
that have survived it. See [SECURITY.md](../SECURITY.md).
What dejima defends, what it does not, and where its boundary passes out of
its hands. [security.md](security.md) is the control-by-control account; this
is the shape of the thing those controls are arranged against.
## What this tool assumes
**The agent is hostile, or will be.** Not badly behaved: captured, mid-session,
@@ -62,12 +62,47 @@ to instructions, because it arrives as instructions.
4. **Cross-project context poisoning.** A session writes another project's
agent memory or transcripts, and a later session there reads planted text
as established background.
5. **Container escape.** Real, and it recurs, but it needs an unpatched host
5. **The operator running something the session asked them to run.** dejima
tells agents to hand host-side work over rather than attempt it - a domain
to allow, a dependency to approve, a command only the host can run. That is
a designed, legitimate, frequent workflow, which is exactly what makes it a
delivery mechanism: a script that does what it says and one thing more is
indistinguishable at the moment of running it. Nothing here inspects what
crosses that boundary, and nothing can - the whole point of the handoff is
that a person decides.
6. **Container escape.** Real, and it recurs, but it needs an unpatched host
or a working zero-day, and the two rows it moves between are both already
in the table below.
The ordering is the point. Nothing above the last item requires an exploit.
Item 5 is not an argument for making the handoff harder; a session that cannot
ask is a session that works around you instead. It is an argument for being
able to answer, afterwards, what that actually did to the machine - which is
the layer below this repository's, and the reason the sister project is
looking at detection rather than at more enforcement.
**The handoff directories are item 5 with the person taken out.** `[core]
share_dir` and everything in `[mounts] read_write` exist so that session
output survives the container, so every byte and every filename in them is
session-authored by design. On the box that is bounded by how rarely anyone
looks. Mount one on a workstation - over sshfs, or by any sync that runs on
its own - and the directory is simply present, at which point a desktop opens
what appears in it without being asked: an indexer parsing documents and
archives to extract text, a thumbnailer decoding images, a previewer, an
editor honouring project configuration it finds beside the files. Each is a
parser running as the operator, outside the container, reached with no
decision per file. Item 5's last sentence is that a person decides; here
nobody does.
It stays item 5 rather than becoming a row of its own, because the shape is
the same: the session initiates none of it. It cannot see the mount, cannot
cause one, and the ssh credential involved already gave that operator a shell
on the box. What changes is where session-authored bytes get opened, and the
machine they get opened on is not a row in the table below.
[remote-access.md](remote-access.md#what-mounting-it-costs-you) has the
specifics and what shrinks them.
## Blast radius by layer
Read the last two columns together: the middle one is what the layer reaches
@@ -127,6 +162,58 @@ has the two tiers and where each one's boundary sits.
Neither is required to run dejima, and the container row holds without them.
What they change is what the row below it costs.
**A survey of comparable projects found none with rows below the container at
all** - their boundary is between the agent and the host, and what is under
the host is outside their threat models. The same survey found nothing
treating a forge or a CI runner as part of an agent's blast radius, which is
what the second sister project exists for: a session that can open a pull
request can execute code on a machine dejima never touches.
Recorded as an observation from one day of secondary sources rather than as a
claim to have surveyed the field exhaustively. The reason it is here at all is
that it changes what to defend: if the layers below and beside the container
are ones nobody else is examining, they are also ones where nobody else's
mistakes are available to learn from.
## Which deployment this describes
The table above describes dejima **as deployed on a devbox VM with the two
sister projects arranged around it**. Say that plainly, because the README
also invites running dejima on a laptop, and there the table is shorter than
it looks: the container row holds exactly as described, and the row below it
is the operator's own machine with nothing arranged against it.
That matters more than it used to. The field's own ordering for isolating an
agent whose contents are assumed hostile puts a hardened container below
gVisor and below a per-workload VM kernel, on the grounds that a container
shares the host kernel and that container *runtime* vulnerabilities bypass
syscall filtering rather than being caught by it. dejima's answer to that
ordering is not to dispute it. It is that escape moves a layer instead of
ending the argument - which is a better answer where the next layer exists,
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".
**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
kernel is reached for elsewhere as a *containment* boundary. That is not what
the row below the container buys here. `dejima-devbox-hardener`'s own threat
model states in three places that its firewall bounds where a compromise can
**spread**, not whether it can phone home: an escape reopens internet egress,
deliberately, because deny-by-default earns its keep where the protected set
is enumerable and the internet is not.
Read the row as written, then: it cuts LAN reach to what is declared, and it
is enforced from outside the guest so root in the VM cannot flush it. It does
not restore the egress control the container layer provides. Anyone writing
this sentence more confidently than that is making a promise on another
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.
## What this repository does not claim
- **That the egress allowlist bounds what reaches the model.** It bounds what
@@ -146,6 +233,10 @@ What they change is what the row below it costs.
- **That a finding from the exit-time audit was caused by the session.** It
reports that a surface changed while the session ran. A concurrent session
or the operator's own editing lands in the same diff.
- **That what a session leaves in a handoff directory is safe for another
machine to open.** `share_dir` and `[mounts] read_write` carry
session-authored bytes and session-chosen filenames out of the container on
purpose. Sanitising happens where dejima does the printing, and stops there.
- **That the allowlist bounds exfiltration once a session has push access.**
A commit is an outbound channel to a host that is on the list by definition.
See [ci.md](ci.md).
+130
View File
@@ -0,0 +1,130 @@
# Optional tools
A tool is a program dejima can put in a session's image that is not the
harness and not a project dependency: something an agent uses to work, that
dejima has looked at first.
None is enabled by default.
```ini
# ~/.config/dejima/config - on this machine, always
[core]
tools = rtk
```
```
# .dejima/tools in the project - shared, and approved before it takes effect
rtk
```
```sh
dejima --include rtk # or -i rtk, repeatable, for one launch
```
The three are a union rather than a precedence chain: they answer different
questions - what this machine always wants, what this project needs, what this
run is for - and none is a correction of another. The project's half is a
declaration like every other file in `.dejima/`: it grants nothing until
`dejima-project approve` records it in a store outside the container's reach.
## What "optional tool" does not mean
**It does not mean a bare install.** This is the part worth stating first,
because it is the whole difference between this and adding a line to
`.dejima/dependencies`.
A dependency is something a project says it needs and an operator agrees to.
Nobody claims to have read it. A tool is something **dejima ships**, and the
bar for adding one is that dejima has hardened it. That bar applies to every
tool added this way, not only the first:
- **Pinned and checksummed.** A published artifact at a fixed version with a
`sha256` in the manifest, verified before it is unpacked. Never the
project's own `curl | sh` installer, which fetches whatever its default
branch points at today - an image build that produces a different binary on
different days is one nothing has reviewed.
- **Its network behaviour established, not assumed.** Read the source for
outbound calls, then check the artifact you are actually shipping, because
the two can differ. Anything it wants to reach is off unless the firewall
allowlists it, and a tool that needs a domain allowlisted to function is one
to argue about rather than to add.
- **Telemetry off at the tool's own switch as well as at the firewall.** Two
independent offs, because either one can be wrong.
- **Wired up by dejima, not by the tool's installer.** Most of these ship an
`init` that patches a harness's config file, and what it patches is often
the same hook surface dejima's guards live on. dejima renders the wiring
itself so the ordering, and what the file ends up containing, stay dejima's.
- **The guards still hold with it enabled**, and a test says so.
- **It sets only its own environment.** A tool's `image_env` becomes a
Dockerfile `ENV` that every process in the container inherits, so a bare
name reaches the harness too. Names must be namespaced to the tool and a
launch refuses one that is not. This is the bar's newest line, and it is
here because rtk broke it: it shipped with `NO_COLOR=1`, and a session with
the tool enabled lost colour in the harness, git and everything else.
If a tool cannot clear that, it is not an optional tool. It might still be a
dependency a project declares and an operator approves, where the claim being
made is much smaller.
## What a tool may declare
Deliberately little. A tool's manifest names what it is, what installs it,
what its environment needs, and what the session should be told about it.
There is no key for a hook, a mount, a domain or a guard - a tool that needed
one of those would be a change to the boundary rather than a program on PATH,
and would have to be argued for rather than declared.
See `docs/providers.md` for the key-by-key table.
## rtk
[rtk](https://github.com/rtk-ai/rtk) (Apache-2.0) compresses command output
before it reaches the model: `rtk git status` runs git and prints a shorter
form of the result. It makes no model calls of its own - it is a deterministic
filter, so nothing about it sends your code anywhere.
What was checked, since the list above is only worth as much as its worst
entry:
- **Pinned** to the published `x86_64-unknown-linux-musl` release by digest,
taken from the artifact and confirmed against a second download.
- **Telemetry exists and the project's README does not mention it.** An HTTPS
ping is compiled into the release binary - visible in the shipped artifact's
strings, not only in the source - gated on a consent flag that defaults to
unset. It is off three ways here: consent unset, `RTK_TELEMETRY_DISABLED=1`
in the image environment, and the endpoint is not allowlisted, so the
request cannot leave the container even if the first two were wrong.
- **It resolves wrapped commands through PATH.** Measured: `rtk git status`
with a stub `git` earlier on PATH runs the stub. So dejima's PATH shadows -
the git guard and the forge guard - still apply to a command run through it,
which is the property that matters most here.
- **Its Claude Code integration installs a `PreToolUse` hook that rewrites
commands**, in the same slot dejima's guards occupy. dejima does not install
that hook. See below.
### Why the hook is not installed
`rtk init` registers a hook that rewrites a Bash command before it runs -
`git push origin main` becomes `rtk git push origin main`. That is the same
`PreToolUse`/`Bash` slot as `deny-dangerous-bash.sh`, `git-push-guard.sh` and
`merge-guard.sh`, and the ordering between a third-party rewrite and dejima's
guards is not something dejima would control once the tool's installer had
written it.
So dejima tells the session the tool exists instead, which is exactly what
rtk's own Cline integration does. That is weaker: the agent chooses when to
use it rather than getting it automatically. It is also the conservative first
position for the first tool of this kind, and the thing that would change it
is a measurement - establishing how Claude Code merges hooks across settings
layers, and whether a guard sees the original command or the rewritten one -
rather than a preference.
Note what is not being claimed: a session can run `rtk git push` itself
whether or not dejima installs the hook. The guards hold because they hold at
the PATH and hook layers, not because the tool is absent.
### Limitation
x86_64 only. The tarball spec names one architecture (issue #97), so an arm64
host cannot use this entry.
+25 -1
View File
@@ -233,10 +233,34 @@ case "$subcommand" in
exit 1
fi
# Where the base comes from when --base was not given, most specific
# first. A project whose work does not target its default branch - one
# that merges through a development branch, say - can say so once with
#
# git config gitea.prBase development
#
# rather than every caller remembering a flag. Without that, the
# repository's default branch is the only thing there is to go on.
base_source="--base"
if [ -z "$base" ]; then
base="$(git config --get gitea.prBase 2>/dev/null)" || base=""
base_source="git config gitea.prBase"
fi
if [ -z "$base" ]; then
base=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's#refs/remotes/origin/##' || true)
base="${base:-main}"
base_source="the repository's default branch"
fi
if [ -z "$base" ]; then
base=main
base_source="a fallback, because nothing else said"
fi
# Said out loud, every time. A base that is wrong is not visible in
# anything this prints afterwards - the URL looks the same - so it was
# possible to open a run of pull requests against the wrong branch,
# report them as targeting the right one in good faith, and have
# someone else quietly retarget each one. Naming it here is what makes
# the mistake cost one line instead of a fortnight.
echo "gitea-pr: opening against '$base' ($base_source)." >&2
api_all_pages "check for an existing PR" "${GITEA_API}/repos/${owner}/${repo}/pulls?state=open"
existing_url=$(echo "$json" | jq -r --arg head "$current_branch" --arg base "$base" \
+6
View File
@@ -6,6 +6,12 @@ merge_block = gitea-pr merge
# marker is core's (docker/forge-guard.sh); this only says which commands carry
# one and what each of them is writing.
disclose = pr:gitea-pr create, pr:gitea-pr edit, issue:gitea-issue create, issue:gitea-issue edit, comment:gitea-issue comment
# Which of the keys above belong to a PERSON rather than to the project. A
# project's .dejima/ is committed and shared; a token path is per-user and
# per-machine, so a project may not declare one and an [identity.<name>]
# section may set one. One declaration drives both rules, so they cannot
# disagree about which key is which.
identity_keys = token_file
keys = ssh_host:?hostname api:?url token_file:path=~/.config/gitea/token
requires = ssh_host api token_file
host_keys = ssh_host
+215
View File
@@ -0,0 +1,215 @@
#!/bin/bash
# The one thing `gh` cannot do (#186).
#
# `gh pr view --comments` prints reviews and comments with no resolution state
# anywhere in the output, because the REST API it uses does not carry any.
# `isResolved` exists only on `reviewThreads` in the GraphQL API. So an agent
# or an operator working through PR feedback re-reads and re-answers threads
# that were closed a week ago, with nothing saying so - quiet in the direction
# that wastes the most work.
#
# Everything else `gitea-pr` does has a `gh` subcommand already, which is why
# this ships one command rather than a second CLI. It reads and never writes,
# so there is no authorship disclosure to apply: docker/forge-guard.sh shadows
# `gh`, not this.
set -euo pipefail
function usage() {
echo "Usage: github-pr comments PR_NUMBER [--all]"
echo ""
echo "Shows a pull request's comments, reviews and inline review threads."
echo "Unresolved threads only by default; --all includes the ones already"
echo "resolved, marked as such."
echo ""
echo "The repository is the 'origin' remote of the checkout you are in, and"
echo "must be on github.com. Authentication is gh's own - GH_TOKEN, or"
echo "whatever 'gh auth status' reports."
}
# Same shape as gitea-pr's: resolve from origin and refuse another host, so
# this tool cannot be pointed at a repository the checkout is not for. Covers
# ssh, scp-style and https remotes by taking the last two path components
# rather than parsing any of them as a URL.
function github_owner_repo() {
local remote_url stripped name rest owner
remote_url=$(git remote get-url origin 2>/dev/null) || {
echo "No 'origin' remote found in this repository" >&2
exit 1
}
case "$remote_url" in
*github.com[:/]*) ;;
*) echo "origin remote ($remote_url) is not on github.com; github-pr only supports this host" >&2
exit 1 ;;
esac
stripped="${remote_url%/}"
stripped="${stripped%.git}"
name="${stripped##*/}"
rest="${stripped%/*}"
owner="${rest##*[:/]}"
if [ -z "$owner" ] || [ -z "$name" ] || [ "$owner" = "$stripped" ]; then
echo "Could not read an owner/name out of the origin remote ($remote_url)" >&2
exit 1
fi
printf '%s/%s' "$owner" "$name"
}
# The number reaches gh as a typed GraphQL variable rather than being spliced
# into a path, so this is not the URL-traversal check gitea-pr's is. It is
# here so a mistyped argument fails with something a person can act on instead
# of a GraphQL type error.
function require_number() {
if ! [[ "${1:-}" =~ ^[0-9]+$ ]]; then
echo "Expected a PR number, got '${1:-}'" >&2
exit 1
fi
}
if [ "$#" -lt 1 ]; then
usage
exit 1
fi
subcommand="$1"
shift
case "$subcommand" in
-h|--help|help) usage; exit 0 ;;
comments) ;;
*) echo "Unknown subcommand: $subcommand" >&2; usage >&2; exit 1 ;;
esac
require_number "${1:-}"
pr_number="$1"
shift || true
show_all=0
while [ "$#" -gt 0 ]; do
case "$1" in
--all) show_all=1 ;;
*) echo "Unknown argument: $1" >&2
echo "Usage: github-pr comments PR_NUMBER [--all]" >&2
exit 1 ;;
esac
shift
done
owner_repo="$(github_owner_repo)"
owner="${owner_repo%%/*}"
repo="${owner_repo##*/}"
# gh's own message, then one line saying what was being asked for. Captured
# rather than piped straight into jq: on a 404 gh prints "Not Found" and exits,
# and jq then fails on the message text, so the last thing on the terminal is
# "Cannot index string with string" rather than the fact that the PR does not
# exist.
api_or_die() {
local what="$1"; shift
local out
if ! out="$("$@" 2>&1)"; then
printf '%s\n' "$out" >&2
echo "github-pr: could not $what for ${owner_repo}#${pr_number}" >&2
exit 1
fi
printf '%s' "$out"
}
echo "=== Issue comments ==="
api_or_die "fetch issue comments" \
gh api --paginate "repos/${owner}/${repo}/issues/${pr_number}/comments" \
| jq -r '.[] | "[\(.user.login) @ \(.created_at)]\n\(.body)\n"'
echo "=== Reviews ==="
api_or_die "fetch reviews" \
gh api --paginate "repos/${owner}/${repo}/pulls/${pr_number}/reviews" \
| jq -r '.[] | select(.body != "") | "[\(.user.login) @ \(.submitted_at) — \(.state)]\n\(.body)\n"'
echo "=== Inline review comments ==="
# reviewThreads, not the REST review comments, for the whole reason this tool
# exists: `isResolved` is only here. `isOutdated` comes with it and is worth
# printing - a thread against a hunk that no longer exists is usually already
# handled, and reads as outstanding otherwise.
#
# Paginated by hand rather than with `gh api --paginate`: that flag emits one
# JSON document per page for GraphQL, which would have to be reassembled here
# anyway, and the bound below is the point. A server that ignored `after`
# would otherwise loop forever, and this runs unattended.
PAGE_SIZE=50
PAGE_MAX=20
read -r -d '' QUERY <<'GRAPHQL' || true
query($owner: String!, $name: String!, $number: Int!, $size: Int!, $cursor: String) {
repository(owner: $owner, name: $name) {
pullRequest(number: $number) {
reviewThreads(first: $size, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
isResolved
isOutdated
path
line
comments(first: 100) {
pageInfo { hasNextPage }
nodes { author { login } body diffHunk createdAt }
}
}
}
}
}
}
GRAPHQL
threads='[]'
cursor=""
page=1
while [ "$page" -le "$PAGE_MAX" ]; do
if [ -z "$cursor" ]; then
json="$(api_or_die "fetch review threads" gh api graphql -f query="$QUERY" \
-f owner="$owner" -f name="$repo" -F number="$pr_number" -F size="$PAGE_SIZE")"
else
json="$(api_or_die "fetch review threads" gh api graphql -f query="$QUERY" \
-f owner="$owner" -f name="$repo" -F number="$pr_number" -F size="$PAGE_SIZE" \
-f cursor="$cursor")"
fi
# GraphQL answers a PR number that does not exist with a null pullRequest
# and no error at all, so this is the check that catches it rather than
# adding null to an array and failing with jq's wording.
if [ "$(jq -r '.data.repository.pullRequest // "null"' <<< "$json")" = "null" ]; then
echo "github-pr: no pull request ${owner_repo}#${pr_number}" >&2
exit 1
fi
threads="$(jq -n --argjson a "$threads" --argjson b "$json" \
'$a + $b.data.repository.pullRequest.reviewThreads.nodes')"
if [ "$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage' <<< "$json")" != "true" ]; then
break
fi
cursor="$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.endCursor' <<< "$json")"
page=$((page + 1))
done
if [ "$page" -gt "$PAGE_MAX" ]; then
echo "warning: stopped reading review threads after $PAGE_MAX pages - some may be missing" >&2
fi
# A thread whose replies did not all fit is reported rather than silently
# truncated, for the same reason the page bound is: a feedback tool that drops
# the comment someone is waiting on is worse than one that says it might have.
truncated="$(jq '[.[] | select(.comments.pageInfo.hasNextPage)] | length' <<< "$threads")"
[ "$truncated" -eq 0 ] || \
echo "warning: $truncated thread(s) have more replies than were fetched" >&2
resolved_count="$(jq '[.[] | select(.isResolved)] | length' <<< "$threads")"
if [ "$show_all" -eq 1 ]; then
visible="$threads"
else
visible="$(jq '[.[] | select(.isResolved | not)]' <<< "$threads")"
fi
# The hunk, not just the path, and the same layout gitea-pr prints: whoever
# reads one of these tools should not have to learn the other's output.
jq -r '.[]
| . as $t
| .comments.nodes[]
| "[\(.author.login // "?") @ \($t.path):\($t.line // "?")\(if $t.isResolved then " (resolved)" else "" end)\(if $t.isOutdated then " (outdated)" else "" end)]\n\($t.comments.nodes[0].diffHunk // "(no hunk recorded)")\n--- comment ---\n\(.body)\n"' \
<<< "$visible"
if [ "$show_all" -ne 1 ] && [ "$resolved_count" -gt 0 ]; then
echo "($resolved_count resolved thread(s) hidden. Re-run with --all to include them.)"
fi
+30 -1
View File
@@ -24,7 +24,30 @@ capabilities = pr issues
pr_command = gh pr
issue_command = gh issue
merge_block = gh pr merge
# How this CLI can be pointed at a repository other than the one the session
# was launched against. `gh` takes --repo/-R on essentially every subcommand
# and reads GH_REPO from the environment, so the ordinary path can file an
# issue or leave a comment anywhere the token reaches (#187). The gitea CLIs
# have no equivalent - they resolve the repo from origin and refuse another
# host - which is why these keys are empty for that forge rather than absent
# from the schema.
#
# Fast-fail, not a boundary, exactly as merge_block is: the session holds the
# token and could call the API directly. What actually bounds this is the
# scope of push_token_file, which is why docs/providers.md says a
# repository-scoped fine-grained PAT is the expected shape.
repo_flags = --repo -R
repo_env = GH_REPO
# `gh api repos/<owner>/<name>/...` names a repository with no --repo anywhere
# in it, and is the ordinary way to reach an endpoint the subcommands do not
# cover. Scoped to `api` so the guard is not comparing every positional
# argument that contains two slashes (#243).
repo_paths = api:repos
disclose = pr:gh pr create, pr:gh pr edit, issue:gh issue create, issue:gh issue edit, comment:gh issue comment
# See the gitea manifest's note: this is the key an identity supplies and a
# project may not. The read-only [github] token_file is core's rather than
# this forge's, and is not here for that reason.
identity_keys = push_token_file
keys = push_token_file:?path
requires = push_token_file
fixed_hosts = github.com ssh.github.com api.github.com
@@ -33,5 +56,11 @@ hosts_allowlisted_by_core = on
# declared: the entrypoint links the merge shadow over a command only when it
# is behind /usr/local/lib/dejima/forge, and refuses to start otherwise rather
# than leave a merge block that looks enforced and is not.
install = system:gh
#
# github-pr is dejima's, and exists for one thing gh cannot do: thread
# resolution lives only in the GraphQL API, so `gh pr view --comments` shows
# resolved and unresolved review threads alike and an agent working through
# feedback re-answers what was closed a week ago (#186). It reads and never
# writes, so nothing is shadowed over it.
install = system:gh bin/github-pr
env_export = GH_TOKEN=@push_token_file
+1 -1
View File
@@ -207,7 +207,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:
bin/dejima-allow --container $container_id <domain>)"
dejima-allow --container $container_id <domain>)"
fi
fi
+4 -4
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env bash
# Notification/Stop hook. bin/dejima registers it, per session, into the
# throwaway settings.json copy it generates for a session that has remote
# access enabled, and nowhere else. Every sandbox gets such a copy, derived
# from the operator's real settings.json; registering it in that real file
# Notification hook. The selected harness's plugin registers it, per session,
# into the throwaway settings.json copy bin/dejima generates for a session
# that has remote access enabled, and nowhere else. Every sandbox gets such a
# copy, derived from the operator's real settings.json; registering it there
# instead would put this hook into all of them - and into the host's own
# Claude Code, which reads that file directly - where there is no marker
# directory to write into. The copy is mounted read-only (see
+1
View File
@@ -76,6 +76,7 @@ link_bin dejima-project
# operator to run `dejima-audit ack`, which is only true once it is on PATH.
link_bin dejima-audit
link_bin dejima-epitaph
link_bin dejima-update-check
# Remote access (opt-in, see docs/remote-access.md). All three are inert
# without [remote-access] enabled = on - they refuse to run at all - so
+129 -1
View File
@@ -651,6 +651,129 @@ EOF
[[ "$output" == *"settings.json"* ]] # the second report, same run
}
@test "ack retires a report it has finished, and reaches the ones behind it" {
# #195, as reported: "5 reports waiting" followed immediately by "nothing
# new - 32 finding(s) already dealt with", and the five stay waiting
# however many times ack is run. Selection kept returning a report it had
# already finished, so every other report was unreachable.
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
snap; mkdir -p "$WS/.claude"; echo '{}' > "$WS/.claude/settings.json"; cmp_ >/dev/null 2>&1
[ "$(find "$AUDITDIR/$slug" -name '*.txt' | wc -l)" -eq 2 ]
# Approve through both, in one run.
run bash -c "printf 'a\ny\na\n' | '$AUDIT' ack '$WS'"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *".envrc"* ]] && [[ "$output" == *"settings.json"* ]] \
|| { echo "did not reach both reports: $output"; return 1; }
# Nothing outstanding now. The reports stay on disk - they are the record
# of what those sessions did, and rotation evicts them, not ack - but
# neither ack nor show may claim work is waiting.
[ "$(find "$AUDITDIR/$slug" -name '*.txt' | wc -l)" -eq 2 ]
run bash -c "'$AUDIT' ack '$WS' 2>&1"
[ "$status" -eq 0 ]
[[ "$output" == *"nothing waiting"* ]] || { echo "ack: $output"; return 1; }
[[ "$output" != *"reports waiting"* ]] || { echo "still claims work: $output"; return 1; }
run bash -c "'$AUDIT' show '$WS' 2>&1"
[[ "$output" == *"nothing waiting"* ]] || { echo "show: $output"; return 1; }
# And it says what is still there, rather than "no reports" over a
# directory holding two.
[[ "$output" == *"2 report(s) kept"* ]] || { echo "show: $output"; return 1; }
}
@test "a report still holding an undecided finding is not retired with the rest" {
# The marker retires the report, not the decision. A finding the operator
# skipped is unbaselined and must come back - otherwise "ask again next
# time" silently becomes "never ask again".
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
run bash -c "printf 's\n' | '$AUDIT' ack '$WS'"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
run bash -c "printf 's\n' | '$AUDIT' ack '$WS' 2>&1"
[[ "$output" == *".envrc"* ]] \
|| { echo "a skipped finding did not come back: $output"; return 1; }
}
@test "a file already shown this run is not asked about again from an older report" {
# Two reports about the same file, walked in one invocation. Skipping it in
# the first leaves nothing in the baseline, so the second used to ask again
# - about content that is no longer what is on disk, which is a question
# with no useful answer.
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
snap; echo 'export X=2' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
[ "$(find "$AUDITDIR/$slug" -name '*.txt' | wc -l)" -eq 2 ]
# skip, continue, then the second report must not re-ask.
run bash -c "printf 's\ny\ns\n' | '$AUDIT' ack '$WS'"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$(grep -c '\[CRITICAL\]\|\[WARNING\]' <<< "$output")" -eq 1 ] \
|| { echo "asked more than once about one file:"; echo "$output"; return 1; }
[[ "$output" == *"already dealt with"* ]] \
|| { echo "dropped it silently instead of saying why: $output"; return 1; }
# Skipping is still skipping: it comes back on the next run.
run bash -c "printf 's\n' | '$AUDIT' ack '$WS' 2>&1"
[[ "$output" == *".envrc"* ]] \
|| { echo "a skipped finding did not come back: $output"; return 1; }
}
@test "ack opens the current file in the operator's editor, and comes back to the decision" {
# The report shows the part that changed. What a person needs in order to
# judge it is the file, and there was no way to get at it from the prompt.
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap; printf 'export X=1\n' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
local ed="$BATS_TEST_TMPDIR/fake-editor"
cat > "$ed" <<'EOF'
#!/bin/bash
# Records what it was asked to open, and what was in it at the time.
printf '%s\n' "$*" >> "$FAKE_EDITOR_LOG"
tail -n +1 -- "${@: -1}" >> "$FAKE_EDITOR_LOG"
EOF
chmod +x "$ed"
export FAKE_EDITOR_LOG="$BATS_TEST_TMPDIR/editor.log"
# open, then approve.
run bash -c "printf 'o\na\n' | VISUAL='$ed' '$AUDIT' ack '$WS'"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ -f "$FAKE_EDITOR_LOG" ] || { echo "the editor was never run: $output"; return 1; }
# The file on disk, not the text the report captured.
grep -q "$WS/.envrc" "$FAKE_EDITOR_LOG" \
|| { echo "opened something else: $(cat "$FAKE_EDITOR_LOG")"; return 1; }
grep -q 'export X=1' "$FAKE_EDITOR_LOG" \
|| { echo "did not open the current content: $(cat "$FAKE_EDITOR_LOG")"; return 1; }
# Opening is not a decision: the approve that followed it is what recorded.
[ "$(grep -c '^safe .envrc' "$BASEDIR/$slug")" -eq 1 ] \
|| { echo "opening did not return to the decision"; cat "$BASEDIR/$slug"; return 1; }
}
@test "ack offers no editor for a file that has since been removed" {
# The surface is gone, so there is nothing to read - and offering to open
# it would either fail or, worse, open an empty buffer that looks like the
# file's current content and gets approved on that basis.
snap; printf 'export X=1\n' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
rm -f "$WS/.envrc"
run bash -c "printf 's\n' | '$AUDIT' ack '$WS'"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *".envrc"* ]] || { echo "no finding to test: $output"; return 1; }
[[ "$output" != *"open the file"* ]] \
|| { echo "offered to open a file that is gone: $output"; return 1; }
}
@test "ack offers no editor for a finding with no file to open" {
# A git config key has no path. Offering to open one, and then failing to,
# is worse than not offering.
snap
git -C "$WS" config core.pager 'sh -c cat'
cmp_ >/dev/null 2>&1
run bash -c "printf 's\n' | '$AUDIT' ack '$WS'"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"git config"* ]] || { echo "no gitconfig finding to test: $output"; return 1; }
[[ "$output" != *"open the file"* ]] \
|| { echo "offered to open a file that does not exist: $output"; return 1; }
}
@test "ack stops at one report when told not to continue" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
@@ -834,7 +957,12 @@ REPORT
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[[ "$output" != *".envrc"* ]]
[[ "$output" == *"already dealt with"* ]]
# The second run does not re-walk a report it finished: it says the work
# is done and stops. That message used to come from walking the report
# again and finding every finding covered, which is what left a finished
# report selectable forever and the ones behind it unreachable (#195).
[[ "$output" == *"nothing waiting"* ]] || { echo "$output"; return 1; }
[[ "$output" == *"has been dealt with"* ]] || { echo "$output"; return 1; }
# And no second entry for the same decision.
[ "$(grep -c '^safe .envrc' "$BASEDIR/$slug")" -eq 1 ]
}
+813 -7
View File
@@ -27,7 +27,7 @@ setup() {
}
teardown() {
rm -rf "$CONFIG_DIR" ${STUB_DIR:+"$STUB_DIR"}
rm -rf "$CONFIG_DIR" ${STUB_DIR:+"$STUB_DIR"} ${UPD_DIR:+"$UPD_DIR"}
}
lib() { bash -c 'source "$1"; shift; "$@"' _ "$LIB" "$@"; }
@@ -487,6 +487,145 @@ write_config() {
|| { echo "the refusal does not name the key: $output"; return 1; }
}
# --- identities: the half of a project's config that belongs to a person -----
#
# A project's .dejima/ is committed and shared; an ssh key path and a token
# path are per-user and per-machine. An identity is where those live, so a
# project can name an account without naming a path.
_identity_fixture() {
ID_DIR="$(mktemp -d)"
: > "$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' \
"token_file = $ID_DIR/default-token" \
'[identity.work]' "ssh_key = $ID_DIR/work-key" 'user_name = A Name' \
'user_email = name@work.example' "token_file = $ID_DIR/work-token" \
'[identity.oss]' "ssh_key = $ID_DIR/oss-key" \
'[identity.keyless]' "token_file = $ID_DIR/work-token"
}
@test "an identity supplies the ssh key and the forge credential" {
_identity_fixture
run "$REPO_ROOT/bin/dejima" --identity work --print-config
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qx "core.ssh_key=$ID_DIR/work-key" <<< "$output" || { echo "$output"; return 1; }
grep -qx "forge.gitea.token_file=$ID_DIR/work-token" <<< "$output" \
|| { echo "$output"; return 1; }
rm -rf "$ID_DIR"
}
@test "an identity that leaves a credential to the section beneath it is refused" {
# This used to fall through: oss names no token_file, so [forge.gitea]
# answered for it, and a session launched as oss talked to the forge as
# whichever account owns that token. An identity is how one session's
# account is kept separate from another's, so a credential crossing that
# line is the feature not working rather than a convenience it offers.
_identity_fixture
run "$REPO_ROOT/bin/dejima" --identity oss --print-config
[ "$status" -ne 0 ] || { echo "the borrowed token was accepted: $output"; return 1; }
# Which credential, by name - "something is wrong with your identity" is
# not something an operator can act on.
[[ "$output" == *"[forge.gitea] token_file"* ]] \
|| { echo "the refusal does not name the credential: $output"; return 1; }
# And nothing resolved is printed, for the same reason the missing-section
# refusal prints nothing: it would show this machine's token under oss.
[[ "$output" != *"forge.gitea.token_file=$ID_DIR/default-token"* ]] \
|| { echo "printed the borrowed credential anyway: $output"; return 1; }
rm -rf "$ID_DIR"
}
@test "an identity that leaves the ssh key to [core] is refused" {
# The other half, and the one the ambiguity was about: [core] ssh_key is
# the key a session gets when no identity is named, so an identity that
# names none is asking to push as itself with the default account's key.
_identity_fixture
run "$REPO_ROOT/bin/dejima" --identity keyless --print-config
[ "$status" -ne 0 ] || { echo "the borrowed key was accepted: $output"; return 1; }
[[ "$output" == *"[core] ssh_key"* ]] \
|| { echo "the refusal does not name the key: $output"; return 1; }
rm -rf "$ID_DIR"
}
@test "a credential belonging to a forge this launch does not use is not borrowed" {
# Only the selected forge's keys. Refusing over a value nothing on this
# launch reads would make an unrelated section able to block a launch.
_identity_fixture
printf '[forge.github]\npush_token_file = %s\n' "$ID_DIR/default-token" \
>> "$CONFIG_DIR/config"
run "$REPO_ROOT/bin/dejima" --identity work --print-config
[ "$status" -eq 0 ] \
|| { echo "an unused forge's credential blocked the launch: $output"; return 1; }
rm -rf "$ID_DIR"
}
@test "with no identity named, the config's own credentials are the ones used" {
# The unnamed default, which is what [core] ssh_key means. Nothing about
# the refusals above touches a host that never names an identity.
_identity_fixture
run "$REPO_ROOT/bin/dejima" --print-config
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qx "core.ssh_key=$ID_DIR/default-key" <<< "$output" || { echo "$output"; return 1; }
grep -qx "forge.gitea.token_file=$ID_DIR/default-token" <<< "$output" \
|| { echo "$output"; return 1; }
rm -rf "$ID_DIR"
}
@test "an identity that names nothing is refused, not ignored" {
# The whole point of the refusal: falling through to this machine's own
# credentials would push under the wrong account with nothing saying so.
# Pushing under the wrong key fails loudly; pushing under the default key
# when you asked for another does not fail at all.
_identity_fixture
run "$REPO_ROOT/bin/dejima" --identity wrok --print-config
[ "$status" -ne 0 ] || { echo "a nonexistent identity was accepted"; return 1; }
[[ "$output" == *"wrok"* ]] || { echo "$output"; return 1; }
[[ "$output" != *"core.ssh_key="* ]] \
|| { echo "printed a resolved config under an identity that does not exist"; return 1; }
rm -rf "$ID_DIR"
}
@test "a flag beats the identity, and the identity beats the file" {
_identity_fixture
run "$REPO_ROOT/bin/dejima" --identity work --print-config
grep -qx "core.ssh_key=$ID_DIR/work-key" <<< "$output" || { echo "$output"; return 1; }
# [core] identity in the file, overridden by the flag.
write_config '[core]' 'identity = work' "ssh_key = $ID_DIR/default-key" \
'[identity.work]' "ssh_key = $ID_DIR/work-key" \
'[identity.oss]' "ssh_key = $ID_DIR/oss-key"
run "$REPO_ROOT/bin/dejima" --print-config
grep -qx "core.ssh_key=$ID_DIR/work-key" <<< "$output" || { echo "$output"; return 1; }
run "$REPO_ROOT/bin/dejima" --identity oss --print-config
grep -qx "core.ssh_key=$ID_DIR/oss-key" <<< "$output" \
|| { echo "--identity did not beat [core] identity: $output"; return 1; }
rm -rf "$ID_DIR"
}
@test "an identity may only set what a person owns" {
# Not a second config file with different rules about what may be in it.
_identity_fixture
write_config '[core]' 'forge = gitea' '[identity.work]' 'firewall = off'
run "$REPO_ROOT/bin/dejima" --identity work --print-config
[ "$status" -ne 0 ] || { echo "an identity set [core] firewall"; return 1; }
[[ "$output" == *"firewall"* ]] || { echo "$output"; return 1; }
rm -rf "$ID_DIR"
}
@test "an identity's git name reaches the container's gitconfig copy" {
# Appended to the COPY dejima already makes, so the real ~/.gitconfig is
# untouched - which was already true and is what the copy is for.
_identity_fixture
grep -q 'identity_git_field' "$REPO_ROOT/bin/dejima" \
|| { echo "the launcher no longer reads the identity's git name"; return 1; }
# Written with git itself rather than printf: the value is the operator's
# own text, and a name with a quote or a backslash in it has to survive
# into a file git reads back the same way.
grep -q 'git config -f "$SANDBOX_GITCONFIG_DIR/.gitconfig"' "$REPO_ROOT/bin/dejima" \
|| { echo "the git name is no longer written with git"; return 1; }
rm -rf "$ID_DIR"
}
@test "--harness selects a harness for one launch without changing the default" {
# A host keeps one harness as its default and reaches another when it wants
# to. Everything else was already in place - harnesses configure side by
@@ -639,6 +778,88 @@ write_config() {
[[ "$output" == *"core.command=nvim"* ]]
}
@test "the narrowing flags only ever narrow" {
# Each of these turns something off for one launch and has no counterpart
# that turns it on. A config saying off is a decision; a flag that could
# undo it in passing would make the file's answer the weaker one.
write_config '[logging]' 'loki_url = https://loki.example.com' 'conversation = on' \
'[epitaph]' 'enabled = on' \
'[audit]' 'strict = off'
run "$REPO_ROOT/bin/dejima" --print-config
[[ "$output" == *"logging.conversation=on"* ]] || { echo "$output"; return 1; }
run "$REPO_ROOT/bin/dejima" --no-conversation --print-config
[[ "$output" == *"logging.conversation=off"* ]] || { echo "$output"; return 1; }
# and only that: the DNS and firewall log still ships.
[[ "$output" == *"logging.loki_url=https://loki.example.com"* ]] \
|| { echo "--no-conversation took the whole log with it"; return 1; }
run "$REPO_ROOT/bin/dejima" --no-log --print-config
grep -qx 'logging.loki_url=' <<< "$output" || { echo "$output"; return 1; }
# --no-log implies --no-conversation: shipping the transcript needs an
# endpoint, and leaving the key on while clearing the endpoint would read
# as conversation logging still being in force.
[[ "$output" == *"logging.conversation=off"* ]] || { echo "$output"; return 1; }
run "$REPO_ROOT/bin/dejima" --no-epitaph --print-config
[[ "$output" == *"epitaph.enabled=off"* ]] || { echo "$output"; return 1; }
run "$REPO_ROOT/bin/dejima" --strict-audit --print-config
[[ "$output" == *"audit.strict=on"* ]] || { echo "$output"; return 1; }
# None of them takes a value, so none can be turned back on by passing one.
local f
for f in --no-log --no-conversation --no-epitaph --strict-audit; do
run "$REPO_ROOT/bin/dejima" "$f" on --print-config
[ "$status" -ne 0 ] || { echo "$f accepted a value: $output"; return 1; }
done
}
@test "a harness-key flag sets the selected harness's key" {
# --model is one flag over harnesses that spell the setting differently or
# do not have it at all, so what it resolves to depends on the selection.
write_config '[core]' 'harness = cline' \
'[harness.cline]' 'model = from-the-file'
run "$REPO_ROOT/bin/dejima" --print-config
[[ "$output" == *"harness.cline.model=from-the-file"* ]] || { echo "$output"; return 1; }
run "$REPO_ROOT/bin/dejima" --model from-the-flag --base-url http://127.0.0.1:8080 \
--context-window 60000 --print-config
[[ "$output" == *"harness.cline.model=from-the-flag"* ]] || { echo "$output"; return 1; }
[[ "$output" == *"harness.cline.base_url=http://127.0.0.1:8080"* ]] || { echo "$output"; return 1; }
[[ "$output" == *"harness.cline.context_window=60000"* ]] || { echo "$output"; return 1; }
}
@test "a harness-key flag is resolved after every flag, not where it appears" {
# --harness may come after --model, so which harness the key belongs to is
# not known until the whole line has been read. Resolving eagerly would
# bind the flag to whatever the config happened to say.
write_config '[core]' 'harness = claude-code'
run "$REPO_ROOT/bin/dejima" --model chosen --harness cline --print-config
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"harness.cline.model=chosen"* ]] || { echo "$output"; return 1; }
}
@test "a harness-key flag is refused for a harness without that key" {
# Silently doing nothing is the failure worth avoiding: --model against a
# harness with no model setting reads as having chosen one.
write_config '[core]' 'harness = claude-code'
run "$REPO_ROOT/bin/dejima" --model x --print-config
[ "$status" -ne 0 ] || { echo "--model was accepted for claude-code"; return 1; }
[[ "$output" == *"claude-code"* ]] || { echo "the refusal does not name the harness: $output"; return 1; }
[[ "$output" == *"model"* ]] || { echo "the refusal does not name the key: $output"; return 1; }
}
@test "a harness-key flag names itself in a validation failure" {
# _set_override reports the key by default, and --context-window's key is
# context_window - an option nobody can type.
write_config '[core]' 'harness = cline'
run "$REPO_ROOT/bin/dejima" --context-window 5 --print-config
[ "$status" -ne 0 ]
[[ "$output" == *"--context-window"* ]] || { echo "$output"; return 1; }
[[ "$output" != *"--context_window"* ]] || { echo "reported the key, not the flag: $output"; return 1; }
}
@test "a flag value is validated against the same schema type" {
write_config '[core]' 'firewall = on'
run "$REPO_ROOT/bin/dejima" --cmd 'rm -rf /' --print-config
@@ -1199,6 +1420,12 @@ exit 0
EOF
chmod +x "$STUB_DIR/docker"
docker_log="$(mktemp)"
# A launch runs dejima-update-check, which would ls-remote this checkout's
# own origin. Off here rather than in each fixture's config, because the
# launch is what triggers it. Its cache needs nothing further: it lands
# under $HOME, which every launch fixture already points somewhere of its
# own.
printf '[core]\nupdate_check = off\n' >> "$CONFIG_DIR/config"
HOME="$RW_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK="${RW_AGENT_SOCK-}" \
SANDBOX_TEST_DOCKER_LOG="$docker_log" \
run "$REPO_ROOT/bin/dejima" "$1"
@@ -1319,6 +1546,8 @@ exit 0
EOF
chmod +x "$STUB_DIR/docker"
docker_log="$(mktemp)"
# Same as _rw_launch: the launch would otherwise reach the network.
printf '[core]\nupdate_check = off\n' >> "$CONFIG_DIR/config"
HOME="$RW_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
SANDBOX_TEST_DOCKER_LOG="$docker_log" \
run "$REPO_ROOT/bin/dejima" "$@" "$ws"
@@ -1755,21 +1984,34 @@ _epitaph_fixture() {
EPI_SLUG="$(printf '%s' "$EPI_WS" | sed 's/[^a-zA-Z0-9]/-/g')"
mkdir -p "$EPI_TR/$EPI_SLUG"
# The transcripts directory reaches the tool through the harness's own
# config key now, not as an argument: where transcripts live is the
# harness's, and dejima-epitaph no longer knows the shape of it.
# config key, not as an argument: where transcripts live is the harness's,
# and dejima-epitaph no longer knows the shape of it. `list` needs the same
# key to report whether the session behind an epitaph is still resumable.
write_config '[epitaph]' "dir = $EPI_STORE" \
'[harness.claude-code]' "projects_dir = $EPI_TR"
}
# One compact_boundary carrying the metadata, then the summary itself.
# One compact_boundary carrying the metadata, then the summary itself. The
# session id matches the transcript's filename, as Claude Code's own do -
# that pairing is what `list` resolves to decide a session is still there.
_epitaph_transcript() {
local f="$EPI_TR/$EPI_SLUG/$1.jsonl" text="${2:-worked out X}" uid="${3:-aaaabbbb}"
local ts="${4:-2026-01-02T03:04:05.000Z}"
{
printf '{"type":"system","subtype":"compact_boundary","compactMetadata":{"trigger":"manual","preTokens":900,"postTokens":90}}\n'
printf '{"type":"user","isCompactSummary":true,"uuid":"%s","sessionId":"s-1","timestamp":"2026-01-02T03:04:05.000Z","message":{"content":"%s"}}\n' "$uid" "$text"
printf '{"type":"user","isCompactSummary":true,"uuid":"%s","sessionId":"%s","timestamp":"%s","message":{"content":"%s"}}\n' "$uid" "$1" "$ts" "$text"
} > "$f"
}
# Three epitaphs in a known order, so an index means something to assert on.
_epitaph_three() {
_epitaph_fixture
_epitaph_transcript s1 "the firewall drops IPv6" aaaa1111 2026-01-02T03:04:05.000Z
_epitaph_transcript s2 "the DNS proxy rewrote resolv" bbbb2222 2026-02-03T04:05:06.000Z
_epitaph_transcript s3 "worked out the audit baseline" cccc3333 2026-03-04T05:06:07.000Z
"$REPO_ROOT/bin/dejima-epitaph" save "$EPI_WS" >/dev/null 2>&1
}
@test "an epitaph is written out of the container's reach" {
_epitaph_fixture
_epitaph_transcript s1 "the thing this session worked out"
@@ -1842,6 +2084,128 @@ _epitaph_transcript() {
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
}
@test "list shows one row per epitaph, oldest first" {
_epitaph_three
run "$REPO_ROOT/bin/dejima-epitaph" list "$EPI_WS"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$(grep -cE '^ +[0-9]+ [0-9]{4}-' <<< "$output")" -eq 3 ] || { echo "$output"; return 1; }
# Chronological by the timestamp in the name, which is what makes an
# index stable enough for `show <n>` to mean anything.
grep -q '^ *1 2026-01-02' <<< "$output" || { echo "$output"; return 1; }
grep -q '^ *3 2026-03-04' <<< "$output" || { echo "$output"; return 1; }
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
}
@test "list says whether the session behind an epitaph is still resumable" {
# Resuming the real session beats reading a summary of it, so the listing
# has to say which of the two is still on offer.
_epitaph_three
run "$REPO_ROOT/bin/dejima-epitaph" list "$EPI_WS"
[ "$(grep -c 'resumable' <<< "$output")" -eq 3 ] || { echo "$output"; return 1; }
rm -f "$EPI_TR/$EPI_SLUG/s2.jsonl"
run "$REPO_ROOT/bin/dejima-epitaph" list "$EPI_WS"
[ "$(grep -c 'resumable' <<< "$output")" -eq 2 ] || { echo "$output"; return 1; }
grep -q '^ *2 .*aged out' <<< "$output" || { echo "$output"; return 1; }
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
}
@test "reading the store is not gated on [epitaph] enabled" {
# Turning epitaph off must not strand what it already kept: the operator
# who switches it off is the one most likely to want to look at, and get
# rid of, what is there.
_epitaph_three
write_config '[epitaph]' "dir = $EPI_STORE" 'enabled = off' \
'[harness.claude-code]' "projects_dir = $EPI_TR"
run "$REPO_ROOT/bin/dejima-epitaph" list "$EPI_WS"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$(grep -cE '^ +[0-9]+ [0-9]{4}-' <<< "$output")" -eq 3 ] || { echo "$output"; return 1; }
# Writing still is.
_epitaph_transcript s4 "should not be kept" dddd4444 2026-04-05T06:07:08.000Z
"$REPO_ROOT/bin/dejima-epitaph" save "$EPI_WS" >/dev/null 2>&1
[ "$(find "$EPI_STORE" -name '*.md' | wc -l)" -eq 3 ]
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
}
@test "list says the resumable state is unknown rather than guessing it" {
# Which sessions still have a transcript is the harness's answer. With no
# harness there is nobody to ask, and "aged out" would be a claim this
# cannot support - the transcripts may be sitting right there. The config
# is written directly because write_config seeds a harness otherwise.
_epitaph_three
printf '[epitaph]\ndir = %s\n' "$EPI_STORE" > "$CONFIG_DIR/config"
run "$REPO_ROOT/bin/dejima-epitaph" list "$EPI_WS"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$(grep -c 'resumable' <<< "$output")" -eq 0 ] || { echo "$output"; return 1; }
[ "$(grep -c 'aged out' <<< "$output")" -eq 0 ] || { echo "$output"; return 1; }
[ "$(grep -cE '^ +[0-9]+ [0-9]{4}-' <<< "$output")" -eq 3 ] || { echo "$output"; return 1; }
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
}
@test "search finds an epitaph by content and reports its listing index" {
# The names are a timestamp and a uuid, so nothing about a filename says
# which session it was. Search is the only way in.
_epitaph_three
run "$REPO_ROOT/bin/dejima-epitaph" search "resolv" "$EPI_WS"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -q '1 of 3' <<< "$output" || { echo "$output"; return 1; }
# The index it prints is the position in the same listing `list` shows,
# so `show <n>` straight after a search opens what the search found.
grep -qE '^ *2 20260203' <<< "$output" || { echo "$output"; return 1; }
run "$REPO_ROOT/bin/dejima-epitaph" show 2 "$EPI_WS"
grep -q 'the DNS proxy rewrote resolv' <<< "$output" || { echo "$output"; return 1; }
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
}
@test "search matching nothing says so without failing" {
_epitaph_three
run "$REPO_ROOT/bin/dejima-epitaph" search "nothing-in-any-of-them" "$EPI_WS"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -q 'no epitaph matches' <<< "$output" || { echo "$output"; return 1; }
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
}
@test "show writes to stdout when there is no terminal to open an editor on" {
# `dejima-epitaph show | grep` must not try to launch an editor, and a
# test is the same case as a pipe.
_epitaph_three
run env VISUAL=/bin/false EDITOR=/bin/false \
"$REPO_ROOT/bin/dejima-epitaph" show 1 "$EPI_WS"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -q 'the firewall drops IPv6' <<< "$output" || { echo "$output"; return 1; }
# Newest by default.
run env VISUAL=/bin/false "$REPO_ROOT/bin/dejima-epitaph" show "$EPI_WS"
grep -q 'worked out the audit baseline' <<< "$output" || { echo "$output"; return 1; }
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
}
@test "show opens the editor and leaves the epitaph at 0600" {
command -v script >/dev/null 2>&1 || skip "no script(1) to allocate a pty"
_epitaph_three
local ed; ed="$(mktemp -d)"
# Rewrites through a temp file and a rename, which is what an editor
# does and what drops the mode to the umask's.
printf '#!/bin/bash\nprintf "%%s\\n" "$1" > %s/got\nprintf edited > "$1.t" && mv "$1.t" "$1"\n' \
"$ed" > "$ed/ed"
chmod +x "$ed/ed"
local f; f="$(find "$EPI_STORE" -name '*.md' | sort | head -1)"
chmod 600 "$f"
run env VISUAL="$ed/ed" EDITOR="$ed/ed" script -qec \
"$REPO_ROOT/bin/dejima-epitaph show 1 $EPI_WS" /dev/null
[ -f "$ed/got" ] || { echo "the editor was never run: $output"; return 1; }
[ "$(cat "$ed/got")" = "$f" ] || { echo "opened $(cat "$ed/got"), wanted $f"; return 1; }
[ "$(stat -c %a "$f")" = "600" ] \
|| { echo "left at $(stat -c %a "$f") after the editor rewrote it"; return 1; }
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS" "$ed"
}
@test "an unknown epitaph subcommand is refused" {
_epitaph_fixture
run "$REPO_ROOT/bin/dejima-epitaph" delete-everything "$EPI_WS"
[ "$status" -eq 2 ] || { echo "$output"; return 1; }
grep -q 'usage:' <<< "$output" || { echo "$output"; return 1; }
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
}
@test "the epitaph store is not mounted into the container" {
# The whole point: a session must not be able to read what earlier
# sessions concluded, or edit what this one is about to record.
@@ -2154,12 +2518,12 @@ STUB
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"harness-claude-code"* ]] \
|| { echo "no harness layer was built:"; echo "$output"; return 1; }
grep -qxF "dejima:$(id -un)-harness-claude-code" "$docker_log" \
grep -qxF "localhost/dejima:$(id -un)-harness-claude-code" "$docker_log" \
|| { echo "the harness layer was not tagged as expected:"; cat "$docker_log"; return 1; }
# The LAST compose invocation is the run; the first builds the base.
local ran
ran="$(tail -1 "$image_log")"
[ "$ran" = "dejima:$(id -un)-harness-claude-code" ] \
[ "$ran" = "localhost/dejima:$(id -un)-harness-claude-code" ] \
|| { echo "the session ran on '$ran', not the harness layer"; cat "$image_log"; return 1; }
rm -rf "$workspace" "$RW_HOME" "$docker_log" "$image_log"
}
@@ -2185,6 +2549,53 @@ STUB
rm -rf "$workspace" "$RW_HOME" "$root" "${docker_log:-}"
}
@test "a forge credential never reaches the docker command line" {
# /proc/<pid>/cmdline is world-readable, so a secret passed as
# `-e NAME=value` is visible to every process on the host via ps - and
# from there into whatever captures ps output. That is how #226 was
# found: an operator ran ps while debugging something unrelated and
# pasted the result.
#
# `-e NAME` takes the value from the launcher's own environment instead,
# where it lives in /proc/<pid>/environ - mode 0400, owner only.
#
# This repository already made the argument one layer down: gitea-pr
# feeds curl its auth header on stdin rather than as an argument, and
# says why. The test is here so the two cannot drift apart again.
_rw_setup
workspace="$(mktemp -d)"
local tokfile="$RW_HOME/tok" secret="tok-$RANDOM-do-not-leak"
printf '%s\n' "$secret" > "$tokfile"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = gitea' 'workflow = local-only' \
'[forge.gitea]' 'ssh_host = git.example' 'api = https://git.example/api/v1' \
"token_file = $tokfile"
STUB_DIR="$(mktemp -d)"
local argv_log; argv_log="$(mktemp)"
cat > "$STUB_DIR/docker" <<'STUB'
#!/bin/bash
printf '%s\n' "$*" >> "$SANDBOX_TEST_ARGV_LOG"
exit 0
STUB
chmod +x "$STUB_DIR/docker"
HOME="$RW_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
SANDBOX_TEST_DOCKER_LOG=/dev/null SANDBOX_TEST_ARGV_LOG="$argv_log" \
run "$REPO_ROOT/bin/dejima" "$workspace"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
local argv; argv="$(cat "$argv_log")"
[ -n "$argv" ] || { echo "the stub captured no docker invocation"; return 1; }
# The whole point.
[[ "$argv" != *"$secret"* ]] \
|| { echo "the token is in the docker command line"; return 1; }
# And it is still passed, by name, or the forge simply has no credential.
[[ "$argv" == *"-e GITEA_TOKEN"* ]] \
|| { echo "GITEA_TOKEN is not passed at all: $argv"; return 1; }
# Specifically the bare form. `-e GITEA_TOKEN=` would also match above.
[[ "$argv" != *"-e GITEA_TOKEN="* ]] \
|| { echo "passed as NAME=value after all: $argv"; return 1; }
rm -rf "$workspace" "$RW_HOME" "$STUB_DIR" "$argv_log"
}
@test "the harness layer creates the harness's own state directories and environment" {
# These were in the base image, imposed on every image dejima builds. They
# are the selected harness's now, and have to actually reach its layer -
@@ -2226,3 +2637,398 @@ STUB
|| { echo "the declared environment is missing from the layer:"; echo "$df"; return 1; }
rm -rf "$workspace" "$RW_HOME" "$dockerfile_log"
}
# --- dejima-update-check: is this checkout behind the branch it tracks? ---
#
# A real checkout and a real repository for it to track, neither of them this
# one. bin/ and the provider trees are copied rather than symlinked because
# the script finds its own repository through readlink -f "$0", which would
# follow a symlink straight back to the repository running the tests.
#
# No `git push` anywhere below: dejima's own forge guard refuses a push to a
# path, so a fixture built that way passes on a host and fails inside a
# sandbox. The remote is an ordinary repository committed to directly.
# GIT_CONFIG_GLOBAL and _SYSTEM neutralised alongside the identity this
# already pins: a host with commit.gpgsign on, or an insteadOf rewrite, would
# otherwise reach into a fixture that is supposed to depend on nothing.
_upd_git() {
local d="$1"; shift
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
git -C "$d" -c user.email=t@t -c user.name=t -c commit.gpgsign=false "$@"
}
_upd_commit() {
_upd_git "$1" commit -q --allow-empty -m "$2"
}
_upd_fixture() {
UPD_DIR="$(mktemp -d)"
UPD_ORIGIN="$UPD_DIR/origin"
UPD_CHECKOUT="$UPD_DIR/checkout"
# Stated, not inherited: the cache lands under $HOME, and a test that left
# that alone would write into whoever ran it.
export HOME="$UPD_DIR/home"
# -b trunk so the fixture does not depend on the host's
# init.defaultBranch, and so no guard has an opinion about the name.
git init -q -b trunk "$UPD_ORIGIN"
_upd_commit "$UPD_ORIGIN" first
git clone -q "$UPD_ORIGIN" "$UPD_CHECKOUT"
cp -r "$REPO_ROOT/bin" "$REPO_ROOT/forges" "$REPO_ROOT/harnesses" \
"$REPO_ROOT/workflows" "$REPO_ROOT/tools" "$UPD_CHECKOUT/"
printf '[core]\nharness = claude-code\n' > "$CONFIG_DIR/config"
}
_upd_check() { "$UPD_CHECKOUT/bin/dejima-update-check" 2>&1; }
# Where _cache_file puts it, named once rather than at each assertion.
_upd_cache() { printf '%s/.local/share/dejima/update-check' "$HOME"; }
@test "a checkout level with its remote says nothing" {
_upd_fixture
run _upd_check
[ "$status" -eq 0 ]
[ -z "$output" ] || { echo "said: $output"; return 1; }
}
@test "a checkout behind its remote is told, and told how to update" {
_upd_fixture
_upd_commit "$UPD_ORIGIN" second
run _upd_check
[ "$status" -eq 0 ]
[[ "$output" == *"origin/trunk"* ]] \
|| { echo "does not name what it is behind: $output"; return 1; }
# The path, because the checkout that is behind is not necessarily the
# directory the operator is standing in when they read this.
[[ "$output" == *"git -C $UPD_CHECKOUT pull"* ]] \
|| { echo "does not say how to update: $output"; return 1; }
}
@test "the commit count is given only when the commits are actually here" {
_upd_fixture
_upd_commit "$UPD_ORIGIN" second
# Nothing fetched, so the tip is a commit this checkout has never seen and
# there is no number to give. Counting what it can reach would report the
# commits it already has, which is zero, not the one it is missing.
run _upd_check
[[ "$output" != *" 0 "* ]] || { echo "invented a count: $output"; return 1; }
[[ "$output" == *"has commits this checkout does not"* ]] \
|| { echo "$output"; return 1; }
git -C "$UPD_CHECKOUT" fetch -q origin
rm -f "$(_upd_cache)"
run _upd_check
[[ "$output" == *"has 1 commit this checkout does not"* ]] \
|| { echo "no count once the commit is present: $output"; return 1; }
}
@test "a checkout ahead of its remote says nothing" {
_upd_fixture
_upd_commit "$UPD_CHECKOUT" local-work
run _upd_check
[ -z "$output" ] || { echo "said: $output"; return 1; }
}
@test "a detached HEAD is silence, not a git error on the terminal" {
_upd_fixture
_upd_commit "$UPD_ORIGIN" second
git -C "$UPD_CHECKOUT" checkout -q --detach HEAD
run _upd_check
[ "$status" -eq 0 ]
[ -z "$output" ] || { echo "said: $output"; return 1; }
}
@test "[core] update_check off reaches the remote not at all" {
_upd_fixture
_upd_commit "$UPD_ORIGIN" second
printf '[core]\nharness = claude-code\nupdate_check = off\n' > "$CONFIG_DIR/config"
run _upd_check
[ -z "$output" ] || { echo "said: $output"; return 1; }
# Silence alone would also be produced by asking and saying nothing. The
# cache is written on every lookup, including a failed one, so its absence
# is what shows no lookup happened.
[ ! -e "$(_upd_cache)" ] \
|| { echo "it asked anyway"; return 1; }
}
@test "the remote is asked once, and the cached answer is compared to HEAD" {
_upd_fixture
_upd_commit "$UPD_ORIGIN" second
run _upd_check
[[ "$output" == *"commits this checkout does not"* ]] || { echo "$output"; return 1; }
# Unreachable now. A second run inside the interval still reports, which
# is what makes this a throttle on the network call rather than on the
# notice - told once and then silence would be worse than never telling.
mv "$UPD_ORIGIN" "$UPD_ORIGIN.gone"
run _upd_check
[[ "$output" == *"commits this checkout does not"* ]] \
|| { echo "the notice lasted one launch: $output"; return 1; }
# And it stops the moment HEAD catches up, from the same cached answer,
# without waiting for the interval to expire.
mv "$UPD_ORIGIN.gone" "$UPD_ORIGIN"
git -C "$UPD_CHECKOUT" fetch -q origin
git -C "$UPD_CHECKOUT" reset -q --hard origin/trunk
run _upd_check
[ -z "$output" ] || { echo "still complaining after updating: $output"; return 1; }
}
@test "the remote is not reached through the checkout's own git config" {
# The whole reason this tool talks to a URL from outside the repository.
# dejima mounts its own checkout writable when that is the workspace, so
# .git/config there is session-writable - and core.sshCommand names a
# command git runs on the host. Reaching the remote by name would turn a
# session's config write into host execution on the next launch, with no
# operator action at all.
_upd_fixture
# No port in the URL: git refuses to pass one to an sshCommand it classes
# as "simple", which would fail the control for a reason unrelated to what
# it tests. .invalid never resolves, so the mitigated path fails at once
# instead of reaching for an sshd that may exist on whoever runs this.
git -C "$UPD_CHECKOUT" remote set-url origin ssh://git@example.invalid/x.git
# A file rather than a message: the tool sends git's stderr to /dev/null,
# so an assertion on what it printed would hold whether the command ran or
# not. Execution has to be observed by its effect.
local ran="$UPD_DIR/repo-config-ran"
git -C "$UPD_CHECKOUT" config core.sshCommand \
"sh -c 'touch $ran; exit 1' --"
git -C "$UPD_CHECKOUT" config credential.helper "!sh -c 'touch $ran'"
run _upd_check
[ ! -e "$ran" ] || { echo "the checkout's own config ran"; return 1; }
# The control, so this cannot pass because the threat quietly went away.
# If a future git stops honouring a repository's core.sshCommand here,
# that is worth knowing rather than reading as a mitigation working.
run timeout 20 git -C "$UPD_CHECKOUT" ls-remote origin
[ -e "$ran" ] \
|| { echo "reaching it by name no longer runs repo config: $output"; return 1; }
}
@test "a remote URL that could name a command is refused, not run" {
_upd_fixture
local marker="$UPD_DIR/pwned" url
for url in "ext::sh -c \"touch $UPD_DIR/pwned\"" \
"--upload-pack=touch $UPD_DIR/pwned" \
"http://example.invalid/x.git"; do
# Written into the file directly: `git remote set-url` rejects a value
# starting with a dash itself, which would leave that case untested.
sed -i "s#\turl = .*#\turl = $url#" "$UPD_CHECKOUT/.git/config"
rm -f "$(_upd_cache)"
run _upd_check
[ "$status" -eq 0 ] || { echo "$url exited $status"; return 1; }
[ ! -e "$marker" ] || { echo "$url ran"; return 1; }
# Nothing recorded either, so a refused URL does not also poison the
# cache for the hour after someone fixes it.
[ ! -e "$(_upd_cache)" ] \
|| { echo "$url was attempted"; return 1; }
done
}
@test "a branch is compared against what it tracks, not against its own name" {
# `git worktree add -b x origin/development` leaves a branch called x
# tracking refs/heads/development. Asking the remote for refs/heads/x gets
# an empty answer, and an empty answer is indistinguishable from being up
# to date - so the whole check goes quiet on exactly the branches someone
# developing dejima is standing on.
_upd_fixture
git -C "$UPD_CHECKOUT" checkout -q -b feature --track origin/trunk
_upd_commit "$UPD_ORIGIN" second
run _upd_check
[[ "$output" == *"origin/trunk"* ]] \
|| { echo "compared against the wrong ref: $output"; return 1; }
}
# --- what a session wrote, on its way to the operator's terminal ---
#
# Reading an epitaph is the tool's stated purpose, and every byte of one came
# from a session transcript. Same class as the audit findings, which is why
# both go through the same sanitize()/sanitize_body() in dejima-color-lib.sh.
# Colour is off in these: bats captures the output, so nothing is a terminal
# and every escape byte left is one the fixture planted.
_epitaph_hostile() {
_epitaph_fixture
# \u001b through the JSON, which is how it would actually arrive: the
# extractor decodes it, so the store holds a real ESC byte.
local f="$EPI_TR/$EPI_SLUG/s1.jsonl"
{
printf '{"type":"system","subtype":"compact_boundary","compactMetadata":{"trigger":"\\u001b[31mmanual","preTokens":900,"postTokens":90}}\n'
printf '{"type":"user","isCompactSummary":true,"uuid":"aaaabbbb","sessionId":"s1","timestamp":"2026-01-02T03:04:05.000Z","message":{"content":"first line\\u001b[2Jcleared\\nsecond\\u0007 line"}}\n'
} > "$f"
"$REPO_ROOT/bin/dejima-epitaph" save "$EPI_WS" >/dev/null 2>&1
}
@test "showing an epitaph strips the escapes a session put in it" {
_epitaph_hostile
run "$REPO_ROOT/bin/dejima-epitaph" show 1 "$EPI_WS"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" != *$'\033'* ]] || { echo "an escape reached the terminal"; return 1; }
[[ "$output" != *$'\007'* ]] || { echo "a BEL reached the terminal"; return 1; }
# The text either side of the escape survives - this strips control bytes,
# it does not drop the content they were hidden in.
[[ "$output" == *"first line"* ]] && [[ "$output" == *"cleared"* ]] \
|| { echo "$output"; return 1; }
# And it is still a body: the field rule would have taken the newlines
# with the rest of C0 and folded the whole epitaph onto one line.
[ "${#lines[@]}" -gt 1 ] || { echo "the body was folded to one line"; return 1; }
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
}
@test "searching epitaphs strips the escapes out of what it prints" {
_epitaph_hostile
run "$REPO_ROOT/bin/dejima-epitaph" search 'line' "$EPI_WS"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" != *$'\033'* ]] || { echo "an escape reached the terminal"; return 1; }
[[ "$output" == *"first line"* ]] || { echo "the hit is gone entirely: $output"; return 1; }
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
}
@test "listing epitaphs strips the escapes out of the header fields" {
# trigger comes off the transcript, and the filename is built from its
# timestamp and uuid - so the row is session-authored on both sides.
_epitaph_hostile
run "$REPO_ROOT/bin/dejima-epitaph" list "$EPI_WS"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" != *$'\033'* ]] || { echo "an escape reached the terminal"; return 1; }
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
}
@test "the update notice strips the escapes out of a remote's name" {
# Both the remote name and the branch it tracks are read from the
# checkout's .git/config, which is session-writable when dejima's own
# checkout is the workspace.
_upd_fixture
_upd_commit "$UPD_ORIGIN" second
git -C "$UPD_CHECKOUT" config "branch.trunk.remote" "$(printf 'ori\033[2Jgin')"
git -C "$UPD_CHECKOUT" config "remote.$(printf 'ori\033[2Jgin').url" "$UPD_ORIGIN"
git -C "$UPD_CHECKOUT" config "remote.$(printf 'ori\033[2Jgin').fetch" '+refs/heads/*:refs/remotes/o/*'
run _upd_check
[[ "$output" == *"commits this checkout does not"* ]] \
|| { echo "no notice at all: $output"; return 1; }
[[ "$output" != *$'\033'* ]] || { echo "an escape reached the terminal: $output"; return 1; }
}
@test "a launch does not reach the network to check for updates" {
# dejima-update-check runs on every launch, and this suite drives dozens
# of them. Each gets its own HOME and so its own empty cache, so without
# the fixtures turning it off every one of them would ls-remote the real
# origin: a five-second stall apiece wherever that origin is unreachable,
# and an $output that depends on how far behind the checkout happens to
# be. CI never sees it - a pull_request checkout is a detached HEAD, and
# the tool exits on that before it looks at a remote.
local workspace git_log real_git
_rw_setup
workspace="$(mktemp -d)"; git -C "$workspace" init -q
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
git_log="$(mktemp)"
real_git="$(command -v git)"
# A wrapper rather than a replacement: bin/dejima uses git for other
# things on this path, and a stub that answered nothing would fail the
# launch for an unrelated reason. On PATH rather than in _rw_launch's own
# stub directory, which it recreates on every call.
GIT_WRAP="$(mktemp -d)"
cat > "$GIT_WRAP/git" <<EOF
#!/bin/sh
printf '%s\\n' "\$*" >> "$git_log"
exec "$real_git" "\$@"
EOF
chmod +x "$GIT_WRAP/git"
PATH="$GIT_WRAP:$PATH"
_rw_launch "$workspace"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ -s "$git_log" ] || { echo "the wrapper was never reached, so this proves nothing"; return 1; }
run grep -c 'ls-remote' "$git_log"
[ "$output" -eq 0 ] || { echo "the launch called out:"; grep ls-remote "$git_log"; return 1; }
rm -rf "$workspace" "$GIT_WRAP"
}
@test "[identity.default] is what a launch acts under when none is named" {
# The unnamed default gets a name, which is what lets it carry a git name
# and a forge credential beside the key rather than those coming from
# three different places.
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/default-key"; : > "$ID_DIR/tok"
write_config '[core]' 'forge = gitea' \
'[forge.gitea]' 'ssh_host = git.example' 'api = https://git.example/api/v1' \
'[identity.default]' "ssh_key = $ID_DIR/default-key" \
"token_file = $ID_DIR/tok" 'user_name = dejima agent'
run "$REPO_ROOT/bin/dejima" --print-config
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qx "core.ssh_key=$ID_DIR/default-key" <<< "$output" \
|| { echo "the default identity did not supply the key: $output"; return 1; }
rm -rf "$ID_DIR"
}
@test "[core] ssh_key alongside [identity.default] is refused, not ranked" {
# Two spellings of one thing. Which key a session pushed with is not
# something to work out from precedence rules.
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/old-key"; : > "$ID_DIR/new-key"
write_config '[core]' 'forge = none' "ssh_key = $ID_DIR/old-key" \
'[identity.default]' "ssh_key = $ID_DIR/new-key"
run "$REPO_ROOT/bin/dejima" --print-config
[ "$status" -ne 0 ] || { echo "both spellings were accepted: $output"; return 1; }
[[ "$output" == *"[core] ssh_key"* ]] && [[ "$output" == *"[identity.default]"* ]] \
|| { echo "the refusal does not name both: $output"; return 1; }
rm -rf "$ID_DIR"
}
@test "[core] ssh_key on its own still works, and says what replaced it" {
# Deprecated, not removed: it is how every config written before
# identities existed says this.
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/old-key"
write_config '[core]' 'forge = none' "ssh_key = $ID_DIR/old-key"
run "$REPO_ROOT/bin/dejima" --print-config
[ "$status" -eq 0 ] || { echo "a pre-identity config stopped working: $output"; return 1; }
grep -qx "core.ssh_key=$ID_DIR/old-key" <<< "$output" || { echo "$output"; return 1; }
[[ "$output" == *"[identity.default]"* ]] \
|| { echo "nothing said what replaced it: $output"; return 1; }
rm -rf "$ID_DIR"
}
@test "a named identity still beats [identity.default]" {
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/default-key"; : > "$ID_DIR/bot-key"
write_config '[core]' 'forge = none' \
'[identity.default]' "ssh_key = $ID_DIR/default-key" \
'[identity.bot]' "ssh_key = $ID_DIR/bot-key"
run "$REPO_ROOT/bin/dejima" --identity bot --print-config
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qx "core.ssh_key=$ID_DIR/bot-key" <<< "$output" || { echo "$output"; return 1; }
rm -rf "$ID_DIR"
}
@test "the [core] ssh_key deprecation is warned even when a launch does not use it" {
# A config with [core] ssh_key and a named identity is one migration away
# from the refusal - adding [identity.default] would stop it starting.
# Finding that out on the launch that breaks is worse than being told now,
# so the warning is about the config rather than about this launch.
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/old-key"; : > "$ID_DIR/bot-key"
write_config '[core]' 'forge = none' "ssh_key = $ID_DIR/old-key" \
'identity = bot' '[identity.bot]' "ssh_key = $ID_DIR/bot-key"
run "$REPO_ROOT/bin/dejima" --print-config
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"[core] ssh_key is deprecated"* ]] \
|| { echo "no migration warning: $output"; return 1; }
# And says it is not the key in play, so the reader is not left thinking
# this launch pushed with it.
[[ "$output" == *"runs as bot"* ]] \
|| { echo "the warning does not say which account is in force: $output"; return 1; }
# The path, because "move it" is not actionable without knowing what.
[[ "$output" == *"$ID_DIR/old-key"* ]] \
|| { echo "the warning does not name the key: $output"; return 1; }
rm -rf "$ID_DIR"
}
@test "a migrated config is warned about nothing" {
# The other half of a deprecation warning: it has to stop. One that fires
# after the migration is one an operator learns to ignore, which is how
# the next warning gets missed too.
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/key"
write_config '[core]' 'forge = none' \
'[identity.default]' "ssh_key = $ID_DIR/key" 'user_name = dejima agent'
run "$REPO_ROOT/bin/dejima" --print-config
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" != *"deprecated"* ]] \
|| { echo "still warning after the migration: $output"; return 1; }
grep -qx "core.ssh_key=$ID_DIR/key" <<< "$output" \
|| { echo "the migrated key is not the one in force: $output"; return 1; }
rm -rf "$ID_DIR"
}
+340
View File
@@ -0,0 +1,340 @@
#!/usr/bin/env python3
"""Drives bin/dejima-tty-suspend the way a user drives Ctrl-Z, and reports.
Ctrl-Z cannot be tested by sending a signal: the whole question is whether the
terminal turns that keystroke INTO a signal, or passes it through as a byte.
That needs a real terminal, and a real job-control shell to suspend a job and
bring it back - the kernel discards SIGTSTP sent to an orphaned process group,
which is what a launcher started any other way ends up in.
So: a pty, an interactive shell on it, and a stand-in for the container run
that does the one thing that matters here - put the terminal in raw mode and
proxy keystrokes onward. Docker is not available where these tests run, and is
not what is being tested; the terminal handling is.
Usage: tty-suspend-harness.py <helper-script> <shell>
Prints one "PASS <name>" or "FAIL <name> <detail>" per line, and exits nonzero
if any failed.
"""
import fcntl
import os
import pty
import select
import struct
import sys
import termios
import time
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
# are then fixed names in a world-writable directory that this fixture goes on
# to WRITE and EXECUTE. Every bats caller passes a directory, so nothing
# shipped ever took that branch - but this runs on CI hosts, and a predictable
# executed path is not a thing to leave lying around for the one caller that
# forgets (#249).
WORK = os.environ.get("TTY_SUSPEND_WORK")
if not WORK:
sys.exit("tty-suspend-harness: set TTY_SUSPEND_WORK to a directory of its own")
RECEIVED = os.path.join(WORK, "received")
LAUNCHER = os.path.join(WORK, "launcher.sh")
CONTAINER = os.path.join(WORK, "container.py")
# A stand-in for `docker compose run`: raw mode, then proxy stdin onward.
# "Onward" is a file, which is what lets a test see whether a keystroke reached
# the container or was turned into a signal before it could.
open(CONTAINER, "w").write('''#!/usr/bin/env python3
import fcntl, os, signal, struct, sys, termios, tty
fd = 0
saved = termios.tcgetattr(fd)
def _size():
return struct.unpack("HHHH", fcntl.ioctl(fd, termios.TIOCGWINSZ, b"\\0" * 8))[:2]
# Redraws only when the dimensions ACTUALLY moved, which is what both shipped
# harnesses do. Measured on a pty, bytes written in a fixed window after each
# kind of poke:
#
# idle same-size SIGWINCH size changed and back
# Claude Code 0 0 3337
# Cline 0 0 17810
# nvim (control) 0 3356 6724
#
# The first version of this stand-in redrew on the bare signal, like nvim. That
# is the one behaviour the programs dejima actually runs do not have, so the
# suite could not see that the resume poke was a no-op for both of them.
_last = [_size()]
def _on_winch(*a):
now = _size()
if now == _last[0]:
return
_last[0] = now
sys.stdout.write("CONTAINER-REDRAW\\r\\n")
sys.stdout.flush()
signal.signal(signal.SIGWINCH, _on_winch)
# The finding of #179, as something a test can observe: the client survives
# SIGSTOP and does not survive SIGTSTP, so the launcher has to start it with
# SIGTSTP already ignored. Read back rather than caught - installing a handler
# would REPLACE the inherited SIG_IGN and defeat the very thing being checked,
# which is what the first version of this did.
_ign = 0
for _line in open("/proc/self/status"):
if _line.startswith("SigIgn"):
_ign = int(_line.split()[1], 16)
if _ign & (1 << (signal.SIGTSTP - 1)):
sys.stdout.write("CONTAINER-TSTP-IGNORED\\r\\n")
sys.stdout.flush()
tty.setraw(fd)
# What a full-screen harness does on startup, and does not undo on a suspend.
sys.stdout.write("\\033[?25l")
sys.stdout.write("CONTAINER-ATTACHED\\r\\n"); sys.stdout.flush()
open(%r, "wb").close()
try:
while True:
try:
b = os.read(fd, 1)
except OSError:
break
if not b:
break
with open(%r, "ab") as fh:
fh.write(b)
if b == b"\\x1a":
# What a harness that implements Ctrl-Z does, and why the byte
# reaching the container is fatal: it stops itself, and there is no
# job-control shell in a container to continue it.
termios.tcsetattr(fd, termios.TCSADRAIN, saved)
sys.stdout.write("CONTAINER-SUSPENDED-ITSELF\\r\\n"); sys.stdout.flush()
os.kill(os.getpid(), signal.SIGSTOP)
tty.setraw(fd)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, saved)
''' % (RECEIVED, RECEIVED))
# dejima's shape: background children in one process group, the helper, then
# the container run in the foreground.
# TTY_SUSPEND_NO_HELPER leaves the helper out, which is dejima before this
# existed. It is the control: a harness that cannot see the bug cannot be
# trusted to see the fix either.
HELPER_EXPECTED = not os.environ.get("TTY_SUSPEND_NO_HELPER")
start_helper = '"%s" &\nhelper=$!' % HELPER if HELPER_EXPECTED else ""
# bin/dejima's shape: the client inherits SIGTSTP ignored and is started with
# the terminal, and the keystroke is converted into an explicit SIGSTOP for it
# before the launcher stops itself. Without the conversion the client is the
# one thing that must not be stopped by a catchable signal.
open(LAUNCHER, "w").write('''#!/bin/bash
sleep 300 &
sleep 300 &
%s
trap '' TSTP
python3 %s < /dev/tty &
client=$!
trap - TSTP
on_tstp() {
kill -STOP "$client" 2>/dev/null || true
printf '\\033[?25h' > /dev/tty 2>/dev/null || true
trap - TSTP
kill -TSTP $$
trap on_tstp TSTP
kill -CONT "$client" 2>/dev/null || true
}
trap on_tstp TSTP
while kill -0 "$client" 2>/dev/null; do
wait "$client" && break
case "$?" in 146|148) continue ;; esac
break
done
kill "${helper:-0}" 2>/dev/null || true
echo LAUNCHER-DONE
''' % (start_helper, CONTAINER))
# 77 means "no terminal to test with", which bats turns into a skip. A machine
# that cannot allocate a pty cannot answer the question either way, and a
# failure there would say something untrue about the code.
try:
master, slave = pty.openpty()
except OSError as exc:
sys.stderr.write("no pty available: %s\n" % exc)
sys.exit(77)
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 40, 100, 0, 0))
pid = os.fork()
if pid == 0:
os.setsid()
fcntl.ioctl(slave, termios.TIOCSCTTY, 0)
os.dup2(slave, 0); os.dup2(slave, 1); os.dup2(slave, 2)
if slave > 2:
os.close(slave)
os.close(master)
# LC_ALL=C so the shell's job-control notice is the "Stopped" this expects.
# Left to the environment it is whatever locale the runner happens to have,
# and the assertion then fails on a translated message with nothing about
# the terminal handling having changed (#249).
env = dict(os.environ, PS1="PROMPT> ", LC_ALL="C", LANG="C")
args = [SHELL, "--norc", "-i"] if "bash" in SHELL else [SHELL, "-f", "-i"]
os.execvpe(SHELL, args, env)
os.close(slave)
seen = []
failures = []
transcript_all = ""
def pump(seconds):
end = time.time() + seconds
while time.time() < end:
r, _, _ = select.select([master], [], [], 0.05)
if r:
try:
chunk = os.read(master, 4096)
except OSError:
return
if not chunk:
return
text = chunk.decode("utf-8", "replace")
seen.append(text)
global transcript_all
transcript_all += text
def expect(text, timeout=8.0):
"""Wait for text to appear in output since the last call to forget()."""
end = time.time() + timeout
while time.time() < end:
if text in "".join(seen):
return True
pump(0.1)
return text in "".join(seen)
def forget():
del seen[:]
def send(s):
os.write(master, s.encode())
pump(0.3)
def lflag():
return termios.tcgetattr(master)[3]
def reclaimed():
"""True once dejima-tty-suspend has taken ^Z back from the container's raw mode.
Read off the pty rather than inferred from output, and specific enough to
mean only the helper. Raw mode has ISIG off; a plain shell has ISIG on with
intr at ^C. Only the helper leaves ISIG on, susp at ^Z, and intr undefined
- which is the whole of what it does, so this is the feature's precondition
stated exactly.
"""
attrs = termios.tcgetattr(master)
cc = attrs[6]
return bool(attrs[3] & termios.ISIG) \
and cc[termios.VSUSP] == b"\x1a" \
and cc[termios.VINTR] == b"\x00"
def await_reclaim(timeout=10.0):
end = time.time() + timeout
while time.time() < end:
if reclaimed():
return True
pump(0.05)
return reclaimed()
def received():
try:
with open(RECEIVED, "rb") as fh:
return fh.read()
except FileNotFoundError:
return b""
def check(name, ok, detail=""):
print(("PASS " + name) if ok else ("FAIL %s %s" % (name, detail)))
if not ok:
failures.append(name)
try:
pump(1.0)
forget()
send("bash %s\n" % LAUNCHER)
check("the container run attached", expect("CONTAINER-ATTACHED"))
for cycle in (1, 2):
# Wait for the helper to hold the terminal before typing at it (#262).
#
# CONTAINER-ATTACHED is printed by the stand-in immediately after it
# sets raw mode, so it marks the START of the window this feature
# exists to close, not the end. The helper only acts once it has POLLED
# the terminal and seen -isig, so between those two moments ^Z is still
# the byte 0x1a. Sending into that window put the byte in the container
# and failed `nothing-reached-the-container` - the security assertion -
# on a machine where nothing was wrong but the scheduling.
#
# Waiting for the precondition rather than lengthening the deadline
# after it: with the terminal provably converting ^Z to a signal before
# anything is typed, that check can no longer fail for timing reasons
# at all, which is the point. A longer timeout would only have narrowed
# the window.
#
# Skipped without the helper, where the byte reaching the container is
# the expected result and this would never come true.
if HELPER_EXPECTED:
check("cycle%d-suspend-char-reclaimed" % cycle, await_reclaim(),
"dejima-tty-suspend never took ^Z back from raw mode")
before = received()
forget()
send("\x1a")
stopped = expect("Stopped", 5.0) or expect("suspended", 0.5)
check("cycle%d-suspends-the-job" % cycle, stopped)
check("cycle%d-nothing-reached-the-container" % cycle,
received() == before, repr(received()[-8:]))
forget()
send("echo SHELL-ALIVE-%d\n" % cycle)
check("cycle%d-shell-usable-while-suspended" % cycle,
expect("SHELL-ALIVE-%d" % cycle, 5.0))
forget()
send("fg\n")
expect("CONTAINER-REDRAW", 5.0)
flags = lflag()
check("cycle%d-raw-mode-restored" % cycle,
not (flags & termios.ICANON) and not (flags & termios.ECHO),
"ICANON=%s ECHO=%s" % (bool(flags & termios.ICANON), bool(flags & termios.ECHO)))
check("cycle%d-redraw-poked" % cycle, "CONTAINER-REDRAW" in "".join(seen))
check("cycle%d-session-alive" % cycle, "LAUNCHER-DONE" not in "".join(seen))
before = received()
send("typed\n")
pump(0.8)
check("input-reaches-the-resumed-session", received() != before)
before = received()
send("\x03")
pump(0.8)
check("ctrl-c-still-reaches-the-session", received().endswith(b"\x03"),
repr(received()[-4:]))
# The finding, asserted: the client is stopped by SIGSTOP, which it cannot
# observe, and never by SIGTSTP, which it cannot survive.
# A suspended session must not leave the prompt with an invisible cursor:
# the harness hid it and a suspend is not an exit, so somebody has to.
check("the-cursor-is-shown-again-when-suspended",
"\033[?25h" in transcript_all,
"the prompt inherits a hidden cursor, like a dead ssh session")
check("the-client-was-started-with-sigtstp-ignored",
"CONTAINER-TSTP-IGNORED" in transcript_all,
"it would be stopped by the one signal it cannot survive")
finally:
try:
os.killpg(os.getpgid(pid), 9)
except Exception:
pass
sys.exit(1 if failures else 0)
+286 -22
View File
@@ -93,7 +93,13 @@ launch() {
conf="$(bash -c 'source "$1"; config_path' _ "$LIB")"
mkdir -p "$(dirname "$conf")"
# No default, so a config that launches must select one.
printf '[core]\nharness = claude-code\nssh_key = %s\n' "$LAUNCH_DIR/sandbox-key" >> "$conf"
# A launch runs dejima-update-check, which would ls-remote this checkout's
# own origin. Off here rather than in each fixture's config, because the
# launch is what triggers it. Its cache needs nothing further: it lands
# under $HOME, which every launch fixture already points somewhere of its
# own.
printf '[core]\nharness = claude-code\nupdate_check = off\nssh_key = %s\n' \
"$LAUNCH_DIR/sandbox-key" >> "$conf"
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
SANDBOX_TEST_RUN_ENV="$LAUNCH_ENV" \
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
@@ -112,6 +118,35 @@ dockerfile_for() {
"$LAUNCH_DIR/dockerfile-log" 2>/dev/null
}
@test "valid_domain refuses the hostname shapes a resolver would read differently" {
# The matcher is a trust boundary: what it accepts becomes a dnsmasq
# `server=` line and an ipset entry. The published bypass in this space
# came from an egress control accepting a string its resolver then read as
# a different host, so the shapes below are the ones worth asserting on
# rather than only "does it reject evil.com".
#
# Run against this matcher during the 2026-08 prior-art sweep: every case
# here was already refused except the empty label, which was not.
local bad
for bad in 'a..b.com' 'github.com.' '.github.com' '-bad.com' 'bad-.com' \
'a.-b.com' 'evil.com%00.github.com' 'a_b.com' '' \
'evil.com
github.com'; do
run bash -c 'source "$1"; valid_domain "$2"' _ "$LIB" "$bad"
[ "$status" -ne 0 ] || { echo "accepted: $(printf %q "$bad")"; return 1; }
done
# And the shapes that must keep working, including the ones the tightening
# could plausibly have broken: an IDNA label starts with a letter and
# contains hyphens, and a single label with no dot is a real internal name.
local good
for good in 'crates.io' 'static.crates.io' 'xn--e1afmkfd.com' 'GitHub.com' \
'localhost' 'a' 'llm-backend.example.com'; do
run bash -c 'source "$1"; valid_domain "$2"' _ "$LIB" "$good"
[ "$status" -eq 0 ] || { echo "refused: $good"; return 1; }
done
}
@test "valid_domain accepts real domains and rejects shell-active input" {
run bash -c 'source "$1"; valid_domain "crates.io"' _ "$LIB"
[ "$status" -eq 0 ]
@@ -506,6 +541,85 @@ EOF
[ ! -f "$CONFIG_DIR/dependencies" ]
}
# Two running sandboxes, each with a working directory, plus a dejima-attach
# that lists them in the order its picker numbers them.
stub_two_containers() {
STUB_DIR="$(mktemp -d)"
cat > "$STUB_DIR/docker" <<'EOF'
#!/bin/bash
if [ "$1" = ps ]; then
printf 'dejima-run-aaaa\taaaaaaaaaaaa\n'
printf 'dejima-run-bbbb\tbbbbbbbbbbbb\n'
exit 0
fi
if [ "$1" = inspect ]; then
case "$*" in
*aaaaaaaaaaaa*) echo /home/someone/workspace/alpha ;;
*bbbbbbbbbbbb*) echo /home/someone/workspace/beta ;;
esac
exit 0
fi
exit 0
EOF
chmod +x "$STUB_DIR/docker"
cat > "$STUB_DIR/dejima-attach" <<'EOF'
#!/bin/bash
[ "$1" = --list ] || exit 1
printf 'run1\tlabel-b\tbeta\t3m\tattachable\tbbbbbbbbbbbb\n'
printf 'run2\tlabel-a\talpha\t9m\tattachable\taaaaaaaaaaaa\n'
EOF
chmod +x "$STUB_DIR/dejima-attach"
PATH="$STUB_DIR:$PATH"
}
@test "several running sandboxes are listed by project, not by run id alone" {
# The name is a run id, which is exactly what someone with three sessions
# open does not know. What they do know is which project each is working
# on. Taken from the container's own working directory, so it is there
# whether or not remote access is on.
stub_two_containers
run "$REPO_ROOT/bin/dejima-allow" crates.io
[ "$status" -ne 0 ]
[[ "$output" == *"Multiple running dejima containers"* ]] || { echo "$output"; return 1; }
grep -qE 'alpha +dejima-run-aaaa' <<< "$output" || { echo "$output"; return 1; }
grep -qE 'beta +dejima-run-bbbb' <<< "$output" || { echo "$output"; return 1; }
rm -rf "$STUB_DIR"
}
@test "the listing carries the number the dejima-attach picker gives each one" {
# The picker numbers its rows 1..N in the order --list prints them, so a
# position there is the number an operator would type. Deliberately not
# this tool's own ordering: two lists that number the same sessions
# differently is worse than one list without numbers.
stub_two_containers
run "$REPO_ROOT/bin/dejima-allow" crates.io
# beta is first in the attach listing, so beta is 1 - not alpha, which
# docker happens to report first.
grep -qE '^ *beta .*\(dejima-attach 1\)' <<< "$output" || { echo "$output"; return 1; }
grep -qE '^ *alpha .*\(dejima-attach 2\)' <<< "$output" || { echo "$output"; return 1; }
rm -rf "$STUB_DIR"
}
@test "the listing drops the picker number rather than failing without one" {
# dejima-attach refuses outright when remote access is off. That is a
# column missing, not an error: the project name is the part that makes
# this usable and it does not depend on the feature.
stub_two_containers
cat > "$STUB_DIR/dejima-attach" <<'EOF'
#!/bin/bash
echo "Remote access is not configured." >&2
exit 1
EOF
chmod +x "$STUB_DIR/dejima-attach"
run "$REPO_ROOT/bin/dejima-allow" crates.io
[ "$status" -ne 0 ]
grep -qE 'alpha +dejima-run-aaaa' <<< "$output" || { echo "$output"; return 1; }
[[ "$output" != *"dejima-attach 1"* ]] || { echo "invented a number: $output"; return 1; }
[[ "$output" != *"Remote access is not configured"* ]] \
|| { echo "leaked the attach refusal: $output"; return 1; }
rm -rf "$STUB_DIR"
}
@test "dejima-allow --project persists without needing a container" {
run "$REPO_ROOT/bin/dejima-allow" --project "$PROJ" crates.io
[ "$status" -eq 0 ]
@@ -952,13 +1066,13 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
launch_setup
run launch "$PROJ"
[ "$status" -eq 0 ]
[ "$(launched_env SANDBOX_IMAGE)" = "dejima:$(id -un)-harness-claude-code" ]
[ "$(launched_env SANDBOX_IMAGE)" = "localhost/dejima:$(id -un)-harness-claude-code" ]
"$REPO_ROOT/bin/dejima-dep" add --project "$PROJ" apt:libpq-dev >/dev/null
run launch "$PROJ"
[ "$status" -eq 0 ]
slug="$(bash -c 'source "$1"; project_slug "$2"' _ "$LIB" "$PROJ")"
[ "$(launched_env SANDBOX_IMAGE)" = "dejima:$(id -un)-$slug" ]
[ "$(launched_env SANDBOX_IMAGE)" = "localhost/dejima:$(id -un)-$slug" ]
}
# nvim's data/state/cache are Docker volumes, not host paths, so nothing a
@@ -1002,8 +1116,8 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
launch_setup
run launch "$PROJ"
[ "$status" -eq 0 ]
grep -q -- "-t dejima:$(id -un)-global -f" <(docker_log)
[ "$(launched_env SANDBOX_IMAGE)" = "dejima:$(id -un)-global" ]
grep -q -- "-t localhost/dejima:$(id -un)-global -f" <(docker_log)
[ "$(launched_env SANDBOX_IMAGE)" = "localhost/dejima:$(id -un)-global" ]
}
@test "the launcher builds the global layer then the project layer on top of it" {
@@ -1013,12 +1127,12 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
run launch "$PROJ"
[ "$status" -eq 0 ]
slug="$(bash -c 'source "$1"; project_slug "$2"' _ "$LIB" "$PROJ")"
global_line=$(grep -n -- "-t dejima:$(id -un)-global -f" <(docker_log) | cut -d: -f1)
project_line=$(grep -n -- "-t dejima:$(id -un)-$slug -f" <(docker_log) | cut -d: -f1)
global_line=$(grep -n -- "-t localhost/dejima:$(id -un)-global -f" <(docker_log) | cut -d: -f1)
project_line=$(grep -n -- "-t localhost/dejima:$(id -un)-$slug -f" <(docker_log) | cut -d: -f1)
[ -n "$global_line" ]
[ -n "$project_line" ]
[ "$global_line" -lt "$project_line" ]
[ "$(launched_env SANDBOX_IMAGE)" = "dejima:$(id -un)-$slug" ]
[ "$(launched_env SANDBOX_IMAGE)" = "localhost/dejima:$(id -un)-$slug" ]
}
@test "the project layer's Dockerfile is generated FROM the global layer, not the base image" {
@@ -1034,11 +1148,11 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
[ "$status" -eq 0 ]
slug="$(bash -c 'source "$1"; project_slug "$2"' _ "$LIB" "$PROJ")"
# base -> harness -> global -> project, each FROM the one before it.
dockerfile_for "dejima:$(id -un)-harness-claude-code" \
| grep -qxF "FROM dejima:$(id -un)"
dockerfile_for "dejima:$(id -un)-global" \
| grep -qxF "FROM dejima:$(id -un)-harness-claude-code"
dockerfile_for "dejima:$(id -un)-$slug" | grep -qxF "FROM dejima:$(id -un)-global"
dockerfile_for "localhost/dejima:$(id -un)-harness-claude-code" \
| grep -qxF "FROM localhost/dejima:$(id -un)"
dockerfile_for "localhost/dejima:$(id -un)-global" \
| grep -qxF "FROM localhost/dejima:$(id -un)-harness-claude-code"
dockerfile_for "localhost/dejima:$(id -un)-$slug" | grep -qxF "FROM localhost/dejima:$(id -un)-global"
}
@test "the launcher builds no dependency layer when neither global nor project deps are approved" {
@@ -1049,10 +1163,10 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
# harness in the image - so this is about the DEPENDENCY layers, which is
# what "no approved dependencies" is a statement about.
local tagged
tagged="$(grep -oE -- '-t dejima:[^ ]+' <(docker_log) | sed 's/^-t //' | sort -u)"
[ "$tagged" = "dejima:$(id -un)-harness-claude-code" ] \
tagged="$(grep -oE -- '-t localhost/dejima:[^ ]+' <(docker_log) | sed 's/^-t //' | sort -u)"
[ "$tagged" = "localhost/dejima:$(id -un)-harness-claude-code" ] \
|| { echo "expected only the harness layer to be built, got:"; echo "$tagged"; return 1; }
[ "$(launched_env SANDBOX_IMAGE)" = "dejima:$(id -un)-harness-claude-code" ]
[ "$(launched_env SANDBOX_IMAGE)" = "localhost/dejima:$(id -un)-harness-claude-code" ]
}
@test "the launcher warns when the approval store sits inside the mounted workspace" {
@@ -1677,22 +1791,67 @@ EOF
# The assertion is one-sided on purpose: the victim must never be written. The
# refusal count is only there to prove the swap window was real, so a run where
# the swapper never landed cannot pass vacuously.
#
# Each half restores state before swapping again, and that is what keeps the
# window open rather than tidiness. declare_entry calls os.makedirs the moment
# the directory is missing, so the writer puts a real directory back at the
# path while the symlink is meant to be there. A swapper that only tried
# unlink() then hit "Is a directory", left `held` stranded, and every later
# rename failed with "Directory not empty" - it went through the motions for
# the rest of the run without ever planting another symlink. The window closed
# in the first few milliseconds, the refusal count stopped rising, and a run
# where it closed before the writer's first attempt scored zero and failed a
# test whose guarantee had held perfectly. Discarding what the writer put there
# is also what a hostile session does: it owns that directory.
swap_loop() {
python3 - "$1" "$2" <<'PY' &
import os, sys, time
SWAP_HEALTH="$(mktemp)"
python3 - "$1" "$2" "$SWAP_HEALTH" <<'PY' &
import errno, os, shutil, stat, sys, time
path, target = sys.argv[1], sys.argv[2]
path, target, health_at = sys.argv[1], sys.argv[2], sys.argv[3]
held = path + ".held"
def clear(p):
# Whatever is at the path, gone - a symlink this loop planted, or the
# directory the writer recreated underneath it.
try:
if stat.S_ISDIR(os.lstat(p).st_mode):
shutil.rmtree(p)
else:
os.unlink(p)
except OSError as e:
if e.errno != errno.ENOENT:
raise
# The longest run of cycles that planted nothing, which is what the test
# asserts on. A count of successful plants does not separate the two cases:
# the wedge took about a second to happen, so a broken swapper still plants
# a hundred times and then never again. Losing a single cycle to the writer
# recreating the directory at the wrong moment is normal and self-correcting;
# never recovering is the bug.
plants = streak = worst = 0
end = time.time() + 60
while time.time() < end:
try:
os.rename(path, held)
if not os.path.lexists(held):
os.rename(path, held)
clear(path)
os.symlink(target, path)
plants += 1
streak = 0
except OSError:
pass
streak += 1
worst = max(worst, streak)
# Written as it goes: this process is killed, not allowed to finish.
# Through a rename, because the reader is a shell that may look at any
# moment - truncate-then-write hands it an empty file often enough to
# matter, which reads as "the swapper never planted anything".
with open(health_at + ".tmp", "w") as fh:
fh.write("%d %d\n" % (plants, worst))
os.replace(health_at + ".tmp", health_at)
time.sleep(0.004)
try:
os.unlink(path)
clear(path)
os.rename(held, path)
except OSError:
pass
@@ -1701,6 +1860,20 @@ PY
SWAPPER=$!
}
# Plants, and the longest run of cycles that planted nothing.
assert_swap_window_stayed_open() {
local plants=0 worst=0
# || true and the defaults: the file does not exist until the swapper's
# first cycle, and an empty read there is "nothing yet", not a value.
read -r plants worst < "$SWAP_HEALTH" || true
plants="${plants:-0}"; worst="${worst:-0}"
rm -f "$SWAP_HEALTH"
[ "$plants" -gt 20 ] \
|| { echo "the swapper planted only $plants time(s)"; return 1; }
[ "$worst" -lt 10 ] \
|| { echo "the swapper stalled for $worst consecutive cycles"; return 1; }
}
declare_repeatedly() {
bash -c '
source "$1"
@@ -1723,6 +1896,10 @@ declare_repeatedly() {
kill "$SWAPPER" 2>/dev/null || true
[ "$(cat "$victim")" = "original" ]
[ "$refused" -gt 0 ]
# The window stayed open for the whole run rather than closing after the
# first collision - the failure that made this test flaky while the guard
# it covers was working.
assert_swap_window_stayed_open
rm -rf "$(dirname "$victim")"
}
@@ -1738,6 +1915,10 @@ declare_repeatedly() {
kill "$SWAPPER" 2>/dev/null || true
[ "$(cat "$elsewhere/allowed-domains")" = "original" ]
[ "$refused" -gt 0 ]
# The window stayed open for the whole run rather than closing after the
# first collision - the failure that made this test flaky while the guard
# it covers was working.
assert_swap_window_stayed_open
rm -rf "$elsewhere"
}
@@ -1752,3 +1933,86 @@ declare_repeatedly() {
[ "$status" -eq 0 ]
[[ "$output" == *"Removed apt:ripgrep"* ]]
}
@test "with no terminal the multi-container case stays a list and an exit" {
# The picker must never be what a script meets. `run` gives no tty, which
# is the same shape as cron, a pipe, or an agent running this: a prompt
# there is a hang nobody can answer.
stub_two_containers
run "$REPO_ROOT/bin/dejima-allow" crates.io
[ "$status" -ne 0 ] || { echo "a script got no error: $output"; return 1; }
[[ "$output" == *"specify one with --container"* ]] \
|| { echo "the flag is not offered: $output"; return 1; }
[[ "$output" != *"q to quit"* ]] || { echo "prompted with no tty: $output"; return 1; }
# And the line an agent is pointed at stays, since this is the path an
# agent's operator reads.
[[ "$output" == *"don't let it guess one"* ]] \
|| { echo "the guess-one warning is gone: $output"; return 1; }
rm -rf "$STUB_DIR"
}
@test "the picker acts on the row that was picked" {
# The number typed indexes the rows as printed. Worth its own test because
# the failure is silent: the wrong session is patched, the domain is
# allowed, and everything reports success.
stub_two_containers
local out
# beta is docker's second row, so 2 is beta - and 1 must not be.
# || true: the stub's reachability probe decides the exit status, and this
# is a test about which container was acted on, not about that.
out="$(printf '2\n' | TERM=xterm-256color script -qec \
"PATH='$STUB_DIR:$PATH' '$REPO_ROOT/bin/dejima-allow' crates.io" /dev/null || true)"
[[ "$out" == *dejima-run-bbbb* ]] \
|| { echo "picking 2 did not act on the second row: $out"; return 1; }
[[ "$out" != *"inside dejima-run-aaaa"* ]] \
|| { echo "picking 2 acted on the first row: $out"; return 1; }
out="$(printf '1\n' | TERM=xterm-256color script -qec \
"PATH='$STUB_DIR:$PATH' '$REPO_ROOT/bin/dejima-allow' crates.io" /dev/null || true)"
[[ "$out" == *dejima-run-aaaa* ]] \
|| { echo "picking 1 did not act on the first row: $out"; return 1; }
rm -rf "$STUB_DIR"
}
@test "quitting the picker allows nothing" {
stub_two_containers
local out
out="$(printf 'q\n' | TERM=xterm-256color script -qec \
"PATH='$STUB_DIR:$PATH' '$REPO_ROOT/bin/dejima-allow' crates.io" /dev/null)"
[[ "$out" != *Resolving* ]] || { echo "q went ahead anyway: $out"; return 1; }
rm -rf "$STUB_DIR"
}
@test "the picker's columns are padded to what is in them" {
# "dejima-sandbox-run-" plus twelve hex digits is 31 characters, and the
# column was a fixed 28 - so every real name overran it and the project
# beside it started one space later than the row above. That is the whole
# complaint: there is no telling where the name stops.
stub_two_containers
local out proj_cols
out="$(printf 'q\n' | TERM=xterm-256color script -qec \
"PATH='$STUB_DIR:$PATH' '$REPO_ROOT/bin/dejima-allow' crates.io" /dev/null \
| sed 's/\x1b\[[0-9;]*m//g; s/\r$//')"
# The container name starts at the same column on every row, header
# included, which is what padding to the widest value buys.
proj_cols="$(grep -oE '^ +[0-9]+ +[a-z]+ +dejima-run' <<< "$out" \
| awk '{ print index($0, "dejima-run") }' | sort -u | wc -l)"
[ "$proj_cols" -eq 1 ] \
|| { echo "rows start the name at different columns:"; echo "$out"; return 1; }
rm -rf "$STUB_DIR"
}
@test "dejima-allow's picker does not wipe the terminal it was run in" {
# It shares the banner with dejima-attach and deliberately not the clear.
# dejima-attach is a front door, navigated to; this is a prompt inside a
# shell the operator is already using, and wiping their scrollback to ask
# one question is worse than the interleaving the clear exists to fix.
stub_two_containers
local out
out="$(printf 'q\n' | TERM=xterm-256color script -qec \
"PATH='$STUB_DIR:$PATH' '$REPO_ROOT/bin/dejima-allow' crates.io" /dev/null || true)"
[[ "$out" != *$'\033[2J'* ]] \
|| { echo "dejima-allow cleared the operator's screen"; return 1; }
# It still drew the picker, so this is not passing because nothing ran.
[[ "$out" == *"q to quit"* ]] || { echo "no picker at all: $out"; return 1; }
rm -rf "$STUB_DIR"
}
+357 -6
View File
@@ -5,6 +5,7 @@
setup() {
REPO_ROOT="$BATS_TEST_DIRNAME/.."
LIB="$REPO_ROOT/bin/dejima-provider-lib.sh"
LIB_PROJECT="$REPO_ROOT/bin/dejima-project-lib.sh"
CONFIG_DIR="$(mktemp -d)"
export DEJIMA_CONFIG_DIR="$CONFIG_DIR"
}
@@ -482,9 +483,14 @@ assert_remedy_forges_exist() {
}
@test "the hook substitutes only the two runtime placeholders" {
# env -u, because these run inside a dejima sandbox as often as not, and
# there the briefing-delivery variables are set - the hook then emits only
# the session id and never reads the file under test. That made this one
# fail and the next one pass for the wrong reason, its assertion being
# satisfied by the session id the stub carries anyway. See .dejima.md.
b="$(mktemp)"
printf 'session=@@SESSION_ID@@ container=@@CONTAINER_ID@@\n' > "$b"
run env DEJIMA=1 DEJIMA_BRIEFING_FILE="$b" bash -c \
run env -u DEJIMA_BRIEFING_VIA_ARGS -u DEJIMA_BRIEFING_RENDERED DEJIMA=1 DEJIMA_BRIEFING_FILE="$b" bash -c \
'printf "{\"session_id\":\"abc123\",\"cwd\":\"$PWD\"}" | bash "$1"' _ \
"$REPO_ROOT/hooks/sandbox-context.sh"
[ "$status" -eq 0 ]
@@ -507,7 +513,7 @@ assert_remedy_forges_exist() {
printf 'resume-cmd: dejima --resume @@SESSION_ID@@\ncontainer=@@CONTAINER_ID@@\n' > "$b"
# Session id crafted to test each special case in the replacement context.
# Use printf %s so the value is never interpreted by the shell.
run env DEJIMA=1 DEJIMA_BRIEFING_FILE="$b" bash -c \
run env -u DEJIMA_BRIEFING_VIA_ARGS -u DEJIMA_BRIEFING_RENDERED DEJIMA=1 DEJIMA_BRIEFING_FILE="$b" bash -c \
'printf "{\"session_id\":%s,\"cwd\":\"%s\"}" "$(printf "%s" "$1" | jq -Rs .)" "$PWD" | bash "$2"' _ \
'pr-42&rm -rf /tmp\test/path' "$REPO_ROOT/hooks/sandbox-context.sh"
[ "$status" -eq 0 ]
@@ -522,7 +528,7 @@ assert_remedy_forges_exist() {
}
@test "a missing briefing file degrades rather than failing the session" {
run env DEJIMA=1 DEJIMA_BRIEFING_FILE=/nonexistent/briefing bash -c \
run env -u DEJIMA_BRIEFING_VIA_ARGS -u DEJIMA_BRIEFING_RENDERED DEJIMA=1 DEJIMA_BRIEFING_FILE=/nonexistent/briefing bash -c \
'printf "{\"session_id\":\"x\",\"cwd\":\"$PWD\"}" | bash "$1"' _ \
"$REPO_ROOT/hooks/sandbox-context.sh"
[ "$status" -eq 0 ]
@@ -617,7 +623,7 @@ assert_remedy_forges_exist() {
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$(lib manifest_get workflow branch-push push)" = "on" ]
[ "$(lib manifest_get workflow branch-push blocked_branches)" = "main master" ]
[ "$(lib manifest_get workflow branch-push block_merge)" = "off" ]
[ "$(lib manifest_get workflow branch-push block_merge)" = "on" ]
[ "$(lib manifest_get workflow branch-push requires_worktree)" = "on" ]
[ "$(lib manifest_get workflow branch-push autonomy)" = "push-branch" ]
# Requires no capability, so it is usable against a forge that offers
@@ -765,11 +771,51 @@ assert_remedy_forges_exist() {
grep -q 'export DEJIMA_GITHUB_PUSH' "$REPO_ROOT/bin/dejima"
}
@test "no shipped workflow tells the agent not to merge without blocking it" {
# Every shipped briefing says some version of "do not merge". Two of them
# said it with block_merge off, which made the sentence an assertion
# nothing held: the forge's CLI is installed by the FORGE, so `<pr> merge`
# is on PATH whenever a forge is selected, whatever the workflow tells the
# agent to do. The manifest that was off carried a reason - "nothing here
# ever reaches a forge's PR interface" - and the reason was false.
#
# Asserted as an invariant over every workflow rather than as three pinned
# values, because a fourth workflow gets this wrong the same way. A
# workflow that genuinely means to permit merging has to say so in its
# briefing and change this test on purpose.
local wf name
for wf in "$REPO_ROOT"/workflows/*/; do
[ -d "$wf" ] || continue
name="$(basename "$wf")"
[ "$(lib manifest_get workflow "$name" block_merge)" = "on" ] \
|| { echo "$name leaves merging unguarded"; return 1; }
grep -qi 'merg' "$wf/briefing.md" \
|| { echo "$name's briefing says nothing about merging"; return 1; }
done
}
@test "a workflow blocking merges reaches the guard with the forge's command" {
# block_merge is only half of it: what actually refuses the command is
# DEJIMA_MERGE_BLOCK, resolved from the FORGE's merge_block and passed to
# docker/forge-guard.sh. A workflow with the flag on and a forge that
# names no command blocks nothing, which is correct - there is nothing to
# block - but the pairing is what has to hold.
local wf
for wf in branch-push local-only worktree-pr; do
[ "$(lib manifest_get workflow "$wf" block_merge)" = "on" ] || continue
[ -n "$(lib manifest_get forge gitea merge_block)" ] \
|| { echo "gitea names no merge command for $wf to block"; return 1; }
done
grep -q 'DEJIMA_MERGE_BLOCK="$(manifest_get forge "$DEJIMA_FORGE_NAME" merge_block)"' \
"$REPO_ROOT/bin/dejima" \
|| { echo "the launcher no longer resolves the forge's merge command"; return 1; }
}
@test "local-only pushes nothing and blocks no branches" {
run lib manifest_validate workflow local-only
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$(lib manifest_get workflow local-only push)" = "off" ]
[ "$(lib manifest_get workflow local-only block_merge)" = "off" ]
[ "$(lib manifest_get workflow local-only block_merge)" = "on" ]
[ "$(lib manifest_get workflow local-only requires_worktree)" = "off" ]
[ "$(lib manifest_get workflow local-only autonomy)" = "commit" ]
# blocked_branches is empty BECAUSE push is off - there is no push to
@@ -784,6 +830,144 @@ assert_remedy_forges_exist() {
[ -z "$output" ]
}
# --- optional tools ----------------------------------------------------------
@test "every shipped tool is pinned by digest, not fetched from a moving ref" {
# The bar docs/tools.md sets, asserted rather than described. A tool that
# installs from whatever a branch points at today is one nothing has
# reviewed, and it would build differently on different days with no
# change to this repository.
local tool spec
for tool in $(lib provider_list tool); do
run lib manifest_validate tool "$tool"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
spec="$(lib manifest_get tool "$tool" install)"
[[ "$spec" == tarball:* ]] \
|| { echo "$tool does not install from a pinned artifact: $spec"; return 1; }
# valid_tarball_spec already requires a 64-hex digest and an https
# URL; this is the part it cannot check - that the URL names a
# version rather than a branch.
[[ "$spec" != *"/refs/heads/"* && "$spec" != *"/master/"* && "$spec" != *"/main/"* ]] \
|| { echo "$tool installs from a moving ref: $spec"; return 1; }
done
}
@test "a tool cannot declare anything that changes what dejima permits" {
# The manifest has no key for a hook, a mount, a domain or a guard. A tool
# that needed one would be a change to the boundary rather than a program
# on PATH, and adding the key is the moment to argue about it - not the
# moment a manifest quietly starts carrying one.
local key
for key in guards hooks mounts api_domains fixed_hosts install_hook \
briefing no_confirm_args settings_args; do
run lib manifest_get tool rtk "$key"
[ "$status" -ne 0 ] \
|| { echo "the tool schema accepts '$key', which it must not"; return 1; }
done
}
@test "rtk declares the archive shape its release actually has" {
# Inspected: `tar tzf rtk-x86_64-unknown-linux-musl.tar.gz` lists one
# entry, `rtk`, with no enclosing directory. Declared `prefix`, the layer
# would strip that single component, extract nothing, and build cleanly -
# a tool the session is told it has and does not.
#
# Pinned here because the emitter test below exercises both shapes with an
# explicit setting and so cannot notice this manifest changing.
[ "$(lib manifest_get tool rtk install_layout)" = "binary" ] \
|| { echo "rtk's release is a bare binary and the manifest no longer says so"; return 1; }
}
@test "a tool may only set environment variables that are its own" {
# image_env becomes a Dockerfile ENV, which every process in the container
# inherits - not just the tool that declared it. rtk shipped with
# NO_COLOR=1, reasoning that its output lands in a model's context where an
# escape sequence is noise. The reasoning was fine and the scope was wrong:
# a launch with the tool enabled lost colour in the harness, in git, in ls,
# in everything.
#
# A harness may set a bare name there, because a harness IS the container's
# main process. A tool is one program among many.
local d; d="$(mktemp -d "$BATS_TEST_TMPDIR/toolenv.XXXXXX")"
mkdir -p "$d/tools/probe"
local sha; sha="$(printf 'a%.0s' {1..64})"
local bad
for bad in 'NO_COLOR=1' 'DO_NOT_TRACK=1' 'PATH=/x' 'PROBEISH_X=1'; do
printf 'description = x\ninstall = tarball:p@%s:https://e.example/p.tgz\ncommand = p\nimage_env = %s\n' \
"$sha" "$bad" > "$d/tools/probe/tool.conf"
run env DEJIMA_PROVIDER_ROOT="$d" \
bash -c 'source "$1"; tool_validate probe' _ "$LIB"
[ "$status" -ne 0 ] || { echo "accepted image_env $bad"; return 1; }
[[ "$output" == *"not its own"* ]] || { echo "$output"; return 1; }
done
# Namespaced to the tool, which is what a tool's own switch looks like.
printf 'description = x\ninstall = tarball:p@%s:https://e.example/p.tgz\ncommand = p\nimage_env = PROBE_TELEMETRY=0 PROBE_QUIET=1\n' \
"$sha" > "$d/tools/probe/tool.conf"
run env DEJIMA_PROVIDER_ROOT="$d" bash -c 'source "$1"; tool_validate probe' _ "$LIB"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
}
@test "no shipped tool takes the colour out of the rest of the container" {
# The specific regression, pinned by name as well as by the rule above:
# these are the bare names most likely to look like a tool's own business
# and reach everything.
local tool pair
for tool in $(lib provider_list tool); do
run lib tool_validate "$tool"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
for pair in $(lib manifest_get tool "$tool" image_env); do
case "${pair%%=*}" in
NO_COLOR|TERM|CLICOLOR|CLICOLOR_FORCE|FORCE_COLOR)
echo "$tool sets ${pair%%=*} for the whole container"; return 1 ;;
esac
done
done
}
@test "rtk is declared with its telemetry switched off in the image" {
# Two independent offs, because either one can be wrong: the tool's own
# switch here, and the firewall, which does not allowlist the endpoint.
[[ "$(lib manifest_get tool rtk image_env)" == *"RTK_TELEMETRY_DISABLED=1"* ]] \
|| { echo "rtk's telemetry switch is no longer set"; return 1; }
# And the endpoint stays out of what core allowlists.
run grep -rn 'telemetry.rtk-ai' "$REPO_ROOT/docker" "$REPO_ROOT/bin"
[ "$status" -ne 0 ] || { echo "the telemetry endpoint reached the allowlist:"; echo "$output"; return 1; }
}
@test "a tool's archive shape decides how it is unpacked" {
# A bare-binary archive stripped of a component extracts NOTHING, and does
# it silently: the layer builds, and the tool is not in it.
local spec
spec="tarball:x@$(printf 'a%.0s' {1..64}):https://e.example/x.tgz"
run env DEJIMA_TARBALL_STRIP=binary bash -c \
'source "$1"; generate_spec_dockerfile base "$2"' _ "$LIB_PROJECT" "$spec"
[[ "$output" == *"tar -C /opt/dejima/x/bin -xaf"* ]] \
|| { echo "binary layout still strips a component: $output"; return 1; }
run env DEJIMA_TARBALL_STRIP=prefix bash -c \
'source "$1"; generate_spec_dockerfile base "$2"' _ "$LIB_PROJECT" "$spec"
[[ "$output" == *"--strip-components=1"* ]] \
|| { echo "prefix layout stopped stripping: $output"; return 1; }
# Both verify before they unpack. A tar extracted first and checked after
# has already written whatever it contained.
[[ "$output" == *"sha256sum -c -"*"tar -C"* ]] \
|| { echo "the digest is no longer checked before unpacking"; return 1; }
}
@test "the briefing names the tools a launch enabled, and says nothing when there are none" {
run env DEJIMA_TOOLS=rtk bash -c \
'source "$1"; briefing_compose none local-only claude-code' _ "$LIB"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"Extra tools this session has"* ]] || { echo "$output"; return 1; }
[[ "$output" == *"rtk git status"* ]] \
|| { echo "the tool's own instructions did not reach the briefing"; return 1; }
run env DEJIMA_TOOLS= bash -c \
'source "$1"; briefing_compose none local-only claude-code' _ "$LIB"
[[ "$output" != *"Extra tools this session has"* ]] \
|| { echo "a heading with nothing under it"; return 1; }
}
@test "the github forge pushes with its own token, not the read-only one" {
run lib manifest_validate forge github
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
@@ -798,7 +982,115 @@ assert_remedy_forges_exist() {
# declared: the entrypoint links the merge shadow only over a command that
# is behind /usr/local/lib/dejima/forge, and refuses to start otherwise.
# This forge shipping nothing of its own is what "system:" says.
[ "$(lib manifest_get forge github install)" = "system:gh" ]
# system:gh AND the one CLI this forge ships. install-forge-bins.sh fails
# the build in both directions - a declared file that is missing, and a
# file under bin/ that no manifest declares - so this is the declaration
# an operator reads to know what lands in the image.
[ "$(lib manifest_get forge github install)" = "system:gh bin/github-pr" ]
}
@test "each forge declares which of its keys belong to a person" {
# The credential paths, which a project may not declare and an identity
# may set. One declaration drives both rules, so they cannot disagree
# about which key is which.
[ "$(lib manifest_get forge gitea identity_keys)" = "token_file" ]
[ "$(lib manifest_get forge github identity_keys)" = "push_token_file" ]
[ -z "$(lib manifest_get forge none identity_keys)" ]
# And every one of them is a key that forge actually has, or the identity
# would offer a setting nothing reads.
local forge key spec found
for forge in $(lib provider_list forge); do
for key in $(lib manifest_get forge "$forge" identity_keys); do
found=0
for spec in $(lib manifest_get forge "$forge" keys); do
[ "${spec%%:*}" = "$key" ] && found=1
done
[ "$found" -eq 1 ] \
|| { echo "$forge names identity key '$key' and has no such key"; return 1; }
done
done
}
@test "no two forges claim the same identity key name" {
# An identity's keys are unqualified, so one name resolving to two forges'
# types would make the validation depend on which forge was read first.
# Refused here rather than guessed at in _identity_key_specs.
local forge key seen=""
for forge in $(lib provider_list forge); do
for key in $(lib manifest_get forge "$forge" identity_keys); do
case " $seen " in
*" $key "*) echo "two forges declare identity key '$key'"; return 1 ;;
esac
seen="$seen $key"
done
done
}
@test "the identity key set is core's two plus whatever the forges declare" {
# ssh_key and the git name are core's, because every forge needs the same
# answer to "who is pushing". The credentials are not, and are typed by
# the forge that owns them rather than by a second copy here.
local specs
specs="$(lib _identity_key_specs)"
grep -qP '^ssh_key\t\?path$' <<< "$specs" || { echo "$specs"; return 1; }
grep -qP '^user_name\t\?text$' <<< "$specs" || { echo "$specs"; return 1; }
grep -qP '^user_email\t\?text$' <<< "$specs" || { echo "$specs"; return 1; }
grep -qP '^token_file\t\?path$' <<< "$specs" \
|| { echo "token_file did not inherit gitea's type: $specs"; return 1; }
}
@test "the github forge declares how its CLI can be aimed at another repo" {
# gh takes --repo on nearly every subcommand and reads GH_REPO, so the
# ordinary path can act on a repository the session has nothing to do
# with (#187). The guard is generic; the manifest is what makes it fire.
[ "$(lib manifest_get forge github repo_flags)" = "--repo -R" ]
[ "$(lib manifest_get forge github repo_env)" = "GH_REPO" ]
# And in a path rather than a flag: `gh api repos/<owner>/<name>/...`
# names a repository with no --repo in it at all (#243).
[ "$(lib manifest_get forge github repo_paths)" = "api:repos" ]
# The gitea CLIs resolve the repo from origin and refuse another host, so
# there is nothing to declare and nothing for the guard to compare. A
# forge is not required to be aimable.
[ -z "$(lib manifest_get forge gitea repo_flags)" ]
[ -z "$(lib manifest_get forge gitea repo_env)" ]
[ -z "$(lib manifest_get forge gitea repo_paths)" ]
}
@test "a repo path has to name a subcommand and a segment" {
# `<subcommand>:<first segment>`. The subcommand half is what keeps the
# guard from comparing every positional argument that contains two
# slashes, so a spelling that omits it is not a narrower declaration - it
# is one that would refuse an issue title.
local d bad; d="$(mktemp -d "$BATS_TEST_TMPDIR/pathtype.XXXXXX")"
mkdir -p "$d/forges/probe"
for bad in "repos" "api:" ":repos" "api:repos/more" "api:/repos" "-api:repos"; do
printf 'repo_paths = %s\n' "$bad" > "$d/forges/probe/forge.conf"
run env DEJIMA_PROVIDER_ROOT="$d" \
bash -c 'source "$1"; manifest_validate forge probe' _ "$LIB"
[ "$status" -ne 0 ] || { echo "accepted repo_paths = $bad"; return 1; }
done
printf 'repo_paths = api:repos\n' > "$d/forges/probe/forge.conf"
run env DEJIMA_PROVIDER_ROOT="$d" \
bash -c 'source "$1"; manifest_validate forge probe' _ "$LIB"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
}
@test "a repo flag has to look like a flag" {
# It is compared against argv. A bare word would be indistinguishable from
# a subcommand or a value, and would refuse commands at random.
local d bad; d="$(mktemp -d "$BATS_TEST_TMPDIR/flagtype.XXXXXX")"
mkdir -p "$d/forges/probe"
for bad in "repo" "--" "-" "--repo=x"; do
printf 'repo_flags = %s\n' "$bad" > "$d/forges/probe/forge.conf"
run env DEJIMA_PROVIDER_ROOT="$d" \
bash -c 'source "$1"; manifest_validate forge probe' _ "$LIB"
[ "$status" -ne 0 ] || { echo "accepted repo_flags = $bad"; return 1; }
done
# Space-separated, like every other manifest list.
printf 'repo_flags = --repo -R\n' > "$d/forges/probe/forge.conf"
run env DEJIMA_PROVIDER_ROOT="$d" \
bash -c 'source "$1"; manifest_validate forge probe' _ "$LIB"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
}
@test "the briefing does not call GitHub read-only when the forge can push to it" {
@@ -994,3 +1286,62 @@ assert_remedy_forges_exist() {
[ -n "$spec" ] || { echo "$name declares no install and is not in the base image"; return 1; }
done
}
@test "a workflow that omits block_merge blocks merges anyway" {
# The default decides what happens to a workflow that forgets the key -
# a third-party one, or a fourth shipped one added in a hurry. `off` means
# a missing key silently permits merging from inside a session, which is
# the direction a guard should never fail in. Every shipped workflow sets
# it explicitly, so the invariant test covering those would pass either
# way; this is about the ones nobody has written yet.
FAKE_ROOT="$(mktemp -d)"
mkdir -p "$FAKE_ROOT/workflows/forgetful"
printf 'requires =\npush = off\n' > "$FAKE_ROOT/workflows/forgetful/workflow.conf"
run env DEJIMA_PROVIDER_ROOT="$FAKE_ROOT" DEJIMA_CONFIG_DIR="$CONFIG_DIR" \
bash -c 'source "$1"; manifest_get workflow forgetful block_merge' _ "$LIB"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$output" = "on" ] \
|| { echo "a workflow that names no block_merge got '$output'"; return 1; }
rm -rf "$FAKE_ROOT"
}
@test "a forge that aims its CLI also gets that CLI shadowed" {
# entrypoint.sh installs the PATH shadow from merge_block and disclose
# only (_shadow_clis). repo_flags and repo_env are exported to the
# container regardless - so a forge declaring repo_flags without either of
# the other two hands the guard its configuration with no guard on PATH to
# read it, and the aiming check silently does not exist.
#
# Both shipped forges declare disclose, so nothing is broken today. This
# is the manifest contract stated as an invariant, for the third forge
# someone writes (#244).
local forge named shadowed cli entry
for dir in "$REPO_ROOT"/forges/*/; do
[ -d "$dir" ] || continue
forge="$(basename "$dir")"
[ -n "$(lib manifest_get forge "$forge" repo_flags)$(lib manifest_get forge "$forge" repo_env)" ] \
|| continue
# The CLI is the first word of each command the forge names.
named=""
for entry in pr_command issue_command; do
cli="$(lib manifest_get forge "$forge" "$entry")"
[ -n "$cli" ] && named="$named ${cli%% *}"
done
# What entrypoint.sh would actually shadow: merge_block's first word,
# and the CLI in each disclose record ("<kind>:<cli> <subcommand>").
shadowed=" $(lib manifest_get forge "$forge" merge_block | cut -d' ' -f1) "
while IFS= read -r entry; do
entry="${entry#"${entry%%[![:space:]]*}"}"
[ -n "$entry" ] || continue
cli="${entry#*:}"
shadowed="$shadowed ${cli%% *} "
done < <(lib manifest_get forge "$forge" disclose | tr ',' '\n')
for cli in $named; do
[[ "$shadowed" == *" $cli "* ]] \
|| { echo "forge $forge aims '$cli' but nothing shadows it: declare it in merge_block or disclose"; return 1; }
done
done
}
+157
View File
@@ -0,0 +1,157 @@
#!/bin/bash
# The pre-release gate: everything that can be checked by running it, plus the
# list of what cannot, derived from what the release actually changes.
#
# Run before opening a development -> main pull request. It is deliberately not
# a CI job: half of what it produces is a list of things a person has to go and
# do, and a green tick nobody had to earn is worse than no tick.
#
# Usage: test/release-pass.sh [base] [head] (defaults: main, HEAD)
set -uo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
base="${1:-main}"
head="${2:-HEAD}"
# Guarded, because everything below is relative to it: a cd that failed
# silently would have this reporting on some other directory's suites and
# diff, which for a release gate is the worst way to be wrong.
cd "$root" || { echo "cannot enter $root" >&2; exit 1; }
case "${1:-}" in
-h|--help)
echo "Usage: test/release-pass.sh [base] [head]"
echo
echo "Runs the gates that can be run here, then prints the manual checks"
echo "the diff between base and head calls for. See docs/release.md."
exit 0 ;;
esac
status=0
note() { printf '\n== %s\n' "$1"; }
fail() { printf ' NOT OK %s\n' "$1"; status=1; }
pass() { printf ' ok %s\n' "$1"; }
note "Confined suites"
if bash test/run-confined.sh; then
pass "every confined suite"
else
fail "confined suites"
fi
note "shellcheck"
# Captured rather than re-run, and the status kept in a variable rather than
# read off $? at a distance: 127 is "not installed", which is a different thing
# from "found faults" and must not be reported as either passing or failing the
# code.
shellcheck_out="$(bash test/shellcheck.sh 2>&1)"
shellcheck_rc=$?
if [ "$shellcheck_rc" = 0 ]; then
pass "shellcheck"
elif [ "$shellcheck_rc" = 127 ]; then
printf '%s\n' "$shellcheck_out"
fail "shellcheck is not installed here - the gate did NOT run"
else
printf '%s\n' "$shellcheck_out"
fail "shellcheck found faults"
fi
note "Privileged suite"
if docker info >/dev/null 2>&1; then
if bash -c 'bats test/runtime.bats'; then
pass "test/runtime.bats"
else
fail "test/runtime.bats"
fi
else
fail "no Docker here - test/runtime.bats did NOT run"
echo " It is the only thing that exercises mounts, the firewall and"
echo " the entrypoint end to end. Run it on the host, or check that"
echo " the privileged CI job RAN rather than queued: it is pinned to"
echo " one self-hosted runner and queues silently when that is down."
fi
# --- what a person has to do, chosen by what the release touches -------------
changed="$(git diff --name-only "$base...$head" 2>/dev/null)" || changed=""
if [ -z "$changed" ]; then
note "No diff between $base and $head - nothing to release."
exit "$status"
fi
touches() { grep -qE "$1" <<< "$changed"; }
# Not a checklist to complete: an inventory of what nothing has checked. The
# gate for a release here is the security review plus the maintainer running
# the build - see docs/release.md. Printed anyway, because a release whose
# unverified paths are written down is one somebody can debug later.
note "Nothing has verified these, and this release touches them"
printed=0
manual() { printf ' - %s\n' "$1"; printed=1; }
touches '^(bin/dejima$|docker/entrypoint|docker/Dockerfile|docker/docker-compose)' &&
manual "Launch a real session. Nothing else exercises the mounts, the
credential proxy and the exit audit together."
touches '^(forges/|bin/dejima-provider-lib|docker/forge-guard)' &&
manual "Open a pull request through EVERY configured forge, and check the
body that arrived. forge = github has never been run end to end."
touches '^(docker/git-guard|hooks/git-push-guard|hooks/merge-guard|docker/forge-guard)' &&
manual "Try the thing the guard refuses, by the route an agent would take."
touches '^(bin/dejima-tty-suspend|bin/dejima$)' &&
manual "Ctrl-Z a real session and fg it back: the terminal handling is
tested against a stand-in, not against a real container run."
touches '^web/' &&
manual "Load the page on a real phone. Nothing here can see a soft keyboard."
touches '^(harnesses/|bin/harness/|docker/harness-wrapper)' &&
manual "Launch each affected harness against a real model."
touches '^(bin/dejima-attach|bin/dejima-ttyd|systemd/)' &&
manual "Attach over the web front door and detach again."
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."
[ "$printed" = 1 ] || echo " (none - this release touches nothing with a manual check)"
note "Required before this merges"
echo " [ ] Security review of the whole $base...$head diff, against this"
echo " project's own invariants - see docs/release.md. This is the gate."
echo " [ ] Documentation review: every changed behaviour has its doc changed,"
echo " and no doc describes something that no longer exists."
echo " [ ] The maintainer runs the build: normal usage for a while, and a"
echo " launch on the other configured harness to confirm it starts."
# A config key added in a release makes the PREVIOUS main refuse to start once
# an operator has adopted it, because an unknown key fails validation. That
# 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)"
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))"
# 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
# itself the thing that makes the rollback refuse.
# The singular is spelled out rather than derived: "harnesses" does not lose
# an s to become "harness", and a manifest is named for the singular.
new_providers=""
for pair in harnesses:harness forges:forge workflows:workflow; do
axis="${pair%%:*}"
one="${pair#*:}"
[ -d "$axis" ] || 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
if [ -n "$added" ] || [ -n "$new_providers" ]; then
[ -z "$added" ] || printf ' %s\n' $added
[ -z "$new_providers" ] || printf ' [%s] as a config section\n' $new_providers
else
echo " (none - a rollback accepts any config this release accepts)"
fi
note "Then"
echo " Say in the release PR what was run and what was not. A privileged job"
echo " that queued is not a job that passed."
exit "$status"
+217 -30
View File
@@ -178,7 +178,13 @@ launch() {
conf="$(bash -c 'source "$1"; config_path' _ "$LIB")"
mkdir -p "$(dirname "$conf")"
# No default, so a config that launches must select one.
printf '[core]\nharness = claude-code\nssh_key = %s\n' "$LAUNCH_DIR/sandbox-key" >> "$conf"
# A launch runs dejima-update-check, which would ls-remote this checkout's
# own origin. Off here rather than in each fixture's config, because the
# launch is what triggers it. Its cache needs nothing further: it lands
# under $HOME, which every launch fixture already points somewhere of its
# own.
printf '[core]\nharness = claude-code\nupdate_check = off\nssh_key = %s\n' \
"$LAUNCH_DIR/sandbox-key" >> "$conf"
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
SANDBOX_TEST_SETTINGS_COPY="$LAUNCH_DIR/settings-copy.json" \
@@ -757,26 +763,28 @@ start_tmux() {
}
# Provenance, pinned offline. Upstream's color.table is the authority, but a
# test cannot go and fetch it, so the name->hex half is transcribed from it
# here. The half that catches a typo is checked rather than transcribed: every
# escape is decoded back to a hex and compared against the one written beside
# it, so a value edited in one place and not the other fails here instead of
# shipping under the name "gruvbox".
@test "the picker's palette is gruvbox, by upstream's own values" {
local -A gruvbox=(
[bright_orange]=fe8019 [light1]=ebdbb2 [gray_245]=928374
[dark4]=7c6f64 [dark2]=504945 [bright_green]=b8bb26
[bright_yellow]=fabd2f [bright_red]=fb4934 [bright_blue]=83a598
)
local seen=0 r g b name hex
while read -r r g b name hex; do
seen=$((seen + 1))
[ "${gruvbox[$name]:-unknown}" = "$hex" ]
[ "$(printf '%02x%02x%02x' "$r" "$g" "$b")" = "$hex" ]
done < <(sed -n \
"s/.*38;2;\([0-9]*\);\([0-9]*\);\([0-9]*\)m'.*# \([a-z_0-9]*\) *#\([0-9a-f]*\).*/\1 \2 \3 \4 \5/p" \
"$REPO_ROOT/bin/dejima-attach")
[ "$seen" -eq "${#gruvbox[@]}" ]
# The terminal palette carries no hexes at all any more, and that is the
# property worth pinning. It used to hardcode gruvbox in 24-bit, which is the
# brand but assumes a background dejima does not own: measured against a light
# one, light1 came out at 1.21:1 and bright yellow at 1.49:1. Indexed colours
# cannot have that problem, because the theme's author picked them against
# their own background - and on a gruvbox terminal they ARE those hexes.
@test "the terminal palette is the terminal's own, not hardcoded" {
local lib="$REPO_ROOT/bin/dejima-color-lib.sh"
run grep -n '38;2;' "$lib" "$REPO_ROOT/bin/dejima-tui-lib.sh"
[ "$status" -ne 0 ] \
|| { echo "24-bit colour is back in a terminal palette:"; echo "$output"; return 1; }
# And it is one palette: a second definition is how C_OK and C_WARN came
# to mean two different things depending on which file a caller sourced.
run grep -c '^\s*C_OK=' "$REPO_ROOT/bin/dejima-tui-lib.sh"
[ "$output" -eq 0 ] \
|| { echo "dejima-tui-lib.sh defines its own palette again"; return 1; }
# The colours it does use are the semantic slots, so a gruvbox terminal
# fills them with gruvbox.
local slot
for slot in '92m' '93m' '91m' '94m' '90m'; do
grep -q "$slot" "$lib" || { echo "the palette lost slot $slot"; return 1; }
done
}
@test "attaching creates a grouped session on the requested window and disposes of it on detach" {
@@ -1314,7 +1322,6 @@ EOF
stub_launch
write_remote_config
launch "$PROJ"
grep -q 'dejima-notify.sh waiting' "$LAUNCH_DIR/settings-copy.json"
grep -q 'dejima-notify.sh done' "$LAUNCH_DIR/settings-copy.json"
# The layer is a throwaway, so the operator's real settings.json - which
# nothing registers this hook into - is left byte-for-byte alone.
@@ -1329,8 +1336,8 @@ EOF
# Copying them would run each of them twice. Verified against Claude Code
# 2.1.220: a SessionStart hook in a --settings file and one in
# ~/.claude/settings.json both fire.
[ "$(jq '.hooks.Notification | length' "$LAUNCH_DIR/settings-copy.json")" = "1" ]
[ "$(jq '.hooks.Stop | length' "$LAUNCH_DIR/settings-copy.json")" = "1" ]
[ "$(jq '.hooks.Notification // [] | length' "$LAUNCH_DIR/settings-copy.json")" = "0" ]
run jq -r '[.hooks[][].hooks[].command] | join(" ")' "$LAUNCH_DIR/settings-copy.json"
[[ "$output" != *operator-desktop-notify.sh* ]]
[[ "$output" != *operator-tts.sh* ]]
@@ -1346,15 +1353,29 @@ EOF
# the whitelist below is what makes the log sufficient.
stub_curl() {
STUB_DIR="${STUB_DIR:-$(mktemp -d)}"
# stdin as well as argv, because the request URL now arrives that way and
# a stub that only saw argv would report the push as carrying no
# destination at all - which reads like the value being dropped.
#
# Drained only when `--config -` asked for it, exactly as the real curl
# does. A stub that reads stdin unconditionally HANGS when the caller
# sends none, so a change that stopped redirecting would fail this suite
# by timing out rather than by failing an assertion.
cat > "$STUB_DIR/curl" <<EOF
#!/bin/bash
printf '%s\n' "\$@" >> "$STUB_DIR/curl-argv"
_want_stdin=
for _a in "\$@"; do
[ "\$_a" = --config ] && _want_stdin=1
done
[ -z "\$_want_stdin" ] || cat >> "$STUB_DIR/curl-stdin"
exit 0
EOF
chmod +x "$STUB_DIR/curl"
PATH="$STUB_DIR:$PATH"
}
curl_log() { cat "$STUB_DIR/curl-argv" 2>/dev/null; }
curl_stdin() { cat "$STUB_DIR/curl-stdin" 2>/dev/null; }
# The EXACT argument vector of one push, one argument per line - a whitelist,
# not a search. Absence assertions cannot do this job: an argument that leaks
@@ -1368,7 +1389,7 @@ expect_argv() {
-H "Title: dejima: $1" \
-H "Priority: default"
[ -z "${3:-}" ] || printf '%s\n' -H "Click: $3"
printf '%s\n' -d "$2" -- https://ntfy.example.invalid/dejima
printf '%s\n' -d "$2" --config -
}
# Compared as a whole file, so a SECOND push would fail this too. The
# sentinel is what makes "whole" true: both sides are command substitutions,
@@ -1562,6 +1583,49 @@ EOF
[ -z "$(curl_log)" ]
}
@test "the ntfy URL never reaches curl's argv, because it can carry a token" {
# An ntfy server that wants credentials takes them in the URL, and
# docs/remote-access.md documents that form. Every argument of every
# process on this host is readable by any local account through
# /proc/<pid>/cmdline, so a URL in argv is a token published to the
# machine for as long as the request runs - `ps` is enough.
#
# Both halves are asserted. Absent from argv is the fix; present on stdin
# is what stops the fix from being "the push lost its destination", which
# would pass an absence check just as well.
stub_curl
conf_with_ntfy
write_entry 0123456789abcdef '%1' 'work:2'
marker 0123456789abcdef waiting
run "$REPO_ROOT/bin/dejima-notify-watch" --once
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$(curl_log)" != *ntfy.example.invalid* ]] \
|| { echo "the URL is in argv:"; curl_log; return 1; }
[[ "$(curl_stdin)" == *'url = "https://ntfy.example.invalid/dejima"'* ]] \
|| { echo "the URL did not reach curl at all:"; curl_stdin; return 1; }
# The click URL is not secret and stays where it is: it is composed from
# base_url, which is the address of the operator's own front door.
[[ "$(curl_log)" == *"Click: https://dejima.example.invalid/?arg=0123456789abcdef"* ]] \
|| { echo "the click target moved:"; curl_log; return 1; }
}
@test "a misconfigured ntfy URL is not echoed back with its credentials" {
# The refusal path prints the offending value, and the journal is where
# that lands under a systemd user unit. A token that survives a
# misconfiguration by being written to the journal is a token that
# outlives the mistake.
stub_curl
write_remote_config 'ntfy_url = https://someone:hunter2@ntfy.example.invalid/t{}'
run "$REPO_ROOT/bin/dejima-notify-watch" --once
[ "$status" -ne 0 ] || { echo "a bad URL was accepted: $output"; return 1; }
[[ "$output" != *hunter2* ]] \
|| { echo "the credential was echoed back: $output"; return 1; }
[[ "$output" == *"ntfy_url"* ]] || { echo "$output"; return 1; }
[ -z "$(curl_log)" ]
}
@test "a config value curl would read as an option is refused, not passed on" {
# The url schema type requires an http(s) scheme, which "-K/path" is not -
# verified against the real curl, which would otherwise read "-K/path" as
@@ -1694,11 +1758,16 @@ EOF
}
@test "an argument that is not --once is refused rather than watched forever" {
# Anything unrecognized - a typo, --help - used to fall into the watch
# loop, which for an interactive invocation looks like a hang.
# Anything unrecognized - a typo - used to fall into the watch loop, which
# for an interactive invocation looks like a hang.
#
# --help was on this list until #64, which asked every tool to explain
# itself. It is now a recognized argument that prints and exits 0, so it
# belongs to the --help test rather than here. The property this one is
# about is unchanged: an argument the tool does not know is refused.
conf_with_ntfy
stub_curl
for bad in --help --onces -x once; do
for bad in --onces -x once; do
run timeout 10 "$REPO_ROOT/bin/dejima-notify-watch" "$bad"
[ "$status" -eq 2 ]
[[ "$output" == *"usage:"* ]]
@@ -1938,6 +2007,13 @@ expect_ttyd_argv() {
[ -z "${2:-}" ] || printf '%s\n' -t "fontSize=$2"
[ -z "${3:-}" ] || printf '%s\n' -t "tmuxPrefix=$3"
[ -z "${4:-}" ] || printf '%s\n' -t "focusOnTap=$4"
[ -z "${5:-}" ] || printf '%s\n' -t "buttonColors=$5"
# Always sent, unlike the four above: a browser has no theme to inherit,
# so this is not an option an operator turns on. Read out of the script
# rather than transcribed - this file asserts that ttyd is given a theme
# and that its values match the page (static.bats), not what they are.
printf '%s\n' -t "$(sed -n "/^theme_opt=(-t '/,/^}')$/p" \
"$REPO_ROOT/bin/dejima-ttyd" | sed "1s/^theme_opt=(-t '//; \$s/')$//")"
printf '%s\n' "$(readlink -f "$REPO_ROOT/bin")/dejima-attach"
}
# Sentinel on both sides: both are command substitutions, which strip trailing
@@ -2163,7 +2239,10 @@ PY
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -eq 0 ]
assert_ttyd_argv 12345
run grep -cx -- '-t' "$STUB_DIR/ttyd-argv"
# No fontSize reaches ttyd, rather than no -t at all: the theme is sent on
# every launch (a browser has no palette to inherit), so counting -t stopped
# measuring what this is about, which is that an unset option sends nothing.
run grep -c 'fontSize' "$STUB_DIR/ttyd-argv"
[ "$output" = "0" ]
# Set. Checked through the exact vector, not a substring: "fontSize=20
@@ -2227,6 +2306,36 @@ PY
assert_ttyd_argv 12345 20 C-Space
}
@test "ttyd_button_colors reaches ttyd, and unset sends nothing so the page decides" {
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
# Unset is not "off": the page colours the toolbar unless told not to, and
# a host-wide "off" written here would be a different thing from silence.
write_remote_config 'ttyd_port = 12345'
stub_ttyd
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -eq 0 ]
run grep -c 'buttonColors' "$STUB_DIR/ttyd-argv"
[ "$output" = "0" ]
local value
for value in on off; do
: > "$STUB_DIR/ttyd-argv"
write_remote_config 'ttyd_port = 12345' "ttyd_button_colors = $value"
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -eq 0 ]
assert_ttyd_argv 12345 '' '' '' "$value"
done
# Alongside every other -t knob, since they share one splice.
: > "$STUB_DIR/ttyd-argv"
write_remote_config 'ttyd_port = 12345' 'ttyd_font_size = 20' \
'ttyd_tmux_prefix = C-a' 'ttyd_focus_on_tap = on' 'ttyd_button_colors = off'
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -eq 0 ]
assert_ttyd_argv 12345 20 C-a on off
}
@test "ttyd_focus_on_tap reaches ttyd, and unset sends nothing so the page decides" {
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
@@ -2662,7 +2771,7 @@ nginx_blocks() {
# bin/dejima registers $HOME/.claude/hooks/dejima-notify.sh per session,
# and ~/.claude/hooks is what gets mounted read-only into the container.
# Without this link every Notification and Stop is an exit-127.
# Without this link every notification hook is an exit-127.
[ -L "$FAKE_HOME/.claude/hooks/dejima-notify.sh" ]
[ "$(readlink -f "$FAKE_HOME/.claude/hooks/dejima-notify.sh")" \
= "$(readlink -f "$REPO_ROOT/hooks/dejima-notify.sh")" ]
@@ -2882,7 +2991,7 @@ remote_doc() {
# rather than derived so this still catches the doc losing one of them.
local keys key
keys='enabled ntfy_url base_url ttyd_port ttyd_font_size ttyd_tmux_prefix
ttyd_focus_on_tap'
ttyd_focus_on_tap ttyd_button_colors'
for key in $keys; do has -F "\`$key\`"; done
# The port default, taken from the library so the two cannot drift.
@@ -3010,3 +3119,81 @@ remote_doc() {
[ "$status" -ne 0 ]
[ -z "$output" ]
}
@test "one session-finished push per stop, not a second one a minute later" {
# Claude Code raises Notification when it needs permission and when the
# prompt has sat idle. A dejima session runs with permission prompts off,
# so only the timer is left, and it expires shortly after the Stop it
# follows - two pushes on a phone for one event, the second carrying
# nothing the first had not. Debouncing cannot merge them: the watcher
# keys on run id AND event, deliberately, so waiting and done are separate
# signals whenever a harness can actually tell them apart.
stub_launch
write_remote_config
launch "$PROJ"
run jq -r '[.hooks[][].hooks[].command] | join(" ")' "$LAUNCH_DIR/settings-copy.json"
[[ "$output" == *'dejima-notify.sh done'* ]] \
|| { echo "the stop notification is gone entirely: $output"; return 1; }
[[ "$output" != *'dejima-notify.sh waiting'* ]] \
|| { echo "the idle timer is registered again: $output"; return 1; }
rm -rf "$PROJ"
}
@test "the watcher still consumes a waiting marker no hook registers" {
# Nothing writes one by default now, and that changes nothing here: the
# marker directory is bind-mounted read-write, so a session creates
# whatever names it likes. The enum is the boundary, not the registration,
# and dropping `waiting` from it would leave a marker that accumulates
# rather than one that is refused.
conf_with_ntfy
stub_curl
write_entry 0123456789abcdef '%1' 'work:2'
marker 0123456789abcdef waiting
run "$REPO_ROOT/bin/dejima-notify-watch" --once
[ "$status" -eq 0 ]
[ ! -e "$RUNTIME_DIR/dejima/notify/0123456789abcdef/waiting" ] \
|| { echo "the marker was left to accumulate"; return 1; }
assert_argv 'myproject needs input' 'Session is waiting for you.' \
'https://dejima.example.invalid/?arg=0123456789abcdef'
}
@test "the session picker starts on a clean screen, scrollback included" {
# Reopening it from the web toolbar is a full page load, and it drew on
# top of whatever the previous session had left on screen - rows
# interleaved with the tail of the session just left, which reads as a
# rendering fault rather than a new screen (#213). Invisible over SSH,
# where a fresh connection to the forced command starts on a blank one.
write_remote_config
stub_docker_run_id 0123456789abcdef
start_tmux
write_entry 0123456789abcdef "$TARGET_PANE" 'work:1'
run script -qec "echo q | '$REPO_ROOT/bin/dejima-attach'" /dev/null
[ "$status" -eq 0 ]
[[ "$output" == *$'\033[2J'* ]] || { echo "the screen is not cleared"; return 1; }
# And the scrollback with it: without 3J a swipe up on a phone lands in
# the middle of the previous session's output.
[[ "$output" == *$'\033[3J'* ]] || { echo "the scrollback is left behind"; return 1; }
# Not on the machine interface. ttyd feeds run ids back in from --list,
# and these bytes in a log are the failure this is gated to avoid.
run script -qec "'$REPO_ROOT/bin/dejima-attach' --list" /dev/null
[ "$status" -eq 0 ]
[[ "$output" != *$'\033[2J'* ]] || { echo "--list cleared the screen"; return 1; }
}
@test "no terminal, no clear" {
# A pipe or a dumb terminal gets none of it. NO_COLOR does not suppress it
# though: that asks for no decoration, not for a front door drawn over the
# last session.
local out
out="$(bash -c 'source "$1"; tui_clear; echo AFTER' _ "$REPO_ROOT/bin/dejima-tui-lib.sh")"
[ "$out" = AFTER ] || { echo "a pipe got escape bytes: $(printf '%q' "$out")"; return 1; }
run script -qec "TERM=dumb bash -c 'source $REPO_ROOT/bin/dejima-tui-lib.sh; tui_clear; echo AFTER'" /dev/null
[[ "$output" != *$'\033['* ]] || { echo "a dumb terminal got escape bytes"; return 1; }
# NO_COLOR too, on a real terminal. It rides the same switch as the colour
# and the glyphs, which is the reading this file already committed to -
# the alternative is a picker answering to two ideas of "plain".
run script -qec "NO_COLOR=1 TERM=xterm bash -c 'source $REPO_ROOT/bin/dejima-tui-lib.sh; tui_clear; echo AFTER'" /dev/null
[[ "$output" != *$'\033['* ]] || { echo "NO_COLOR still got a screen clear"; return 1; }
}
+156 -6
View File
@@ -37,7 +37,22 @@ bats_require_minimum_version 1.5.0
# The `dejima-test-` prefix is this suite's own namespace. Nothing else may use
# it, which is what makes the reclaim in setup_file safe.
IMAGE=dejima:latest
# The cap_add list from the compose file, one per line. A minimal reader for
# one known block rather than a YAML parser: the alternative is this suite
# hardcoding a second copy of the capability floor and the two disagreeing
# silently, which is exactly the failure the floor exists to prevent.
compose_cap_add() {
awk '
/^ cap_add:/ { in_block = 1; next }
in_block && /^ - / { sub(/^ - /, ""); print; next }
in_block && !/^ / { in_block = 0 }
' "$BATS_TEST_DIRNAME/../docker/docker-compose.yml"
}
# localhost/ for the same reason bin/dejima uses it: a bare name is a Docker
# Hub name to anything that pushes, and nothing built here is meant to leave
# the host (#53).
IMAGE=localhost/dejima:latest
# What a session actually runs: the base image plus the selected harness's own
# layer, built in setup_file. Exported there because bats re-runs this file's
@@ -157,7 +172,7 @@ harness_manifest_value() {
# teardown_file's sweep finds it. Prints the new tag.
build_harness_layer() {
local base="$1" tag df ctx
tag="dejima-test-harness:$(basename "$BATS_RUN_TMPDIR")"
tag="localhost/dejima-test-harness:$(basename "$BATS_RUN_TMPDIR")"
df="$(mktemp)"
# An empty build context, for the reason bin/dejima uses one: a build
# whose context is a directory anything else can write is a way into the
@@ -309,6 +324,29 @@ setup_file() {
done < <(docker ps -a --filter name=^dejima-test- --filter status=exited \
--filter status=created --format '{{.Names}}' 2>/dev/null)
# And the images this suite builds - harness, override, derived - which
# nothing reaps across runs (#26). The per-test teardown removes only the
# CURRENT run's suffix, so an abandoned run's images have no reaper at all.
# They hold no subnet, so this is disk rather than the network's kind of
# harm; it is also the largest thing here by disk, since each is built
# FROM the session image.
#
# Same age guard as above, for the same reason: a concurrent run's images
# are minutes old. The prefix is checked again on the name rather than
# trusted to the CLI filter, so the reap can only ever reach this suite's
# own namespace.
local _img _born _suffix
_suffix="$(basename "$BATS_RUN_TMPDIR")"
while IFS= read -r _img; do
[ -n "$_img" ] || continue
case "$_img" in dejima-test-*) ;; *) continue ;; esac
case "$_img" in *":$_suffix") continue ;; esac
_born="$(docker image inspect -f '{{.Created}}' "$_img" 2>/dev/null)" || continue
_born="$(date -d "$_born" +%s 2>/dev/null)" || continue
[ "$(( $(date +%s) - _born ))" -gt "$REAP_AFTER" ] || continue
docker rmi -f "$_img" >/dev/null 2>&1 || true
done < <(docker images --format '{{.Repository}}:{{.Tag}}' 'dejima-test-*' 2>/dev/null)
docker network create "$network" >/dev/null
# No DEJIMA_FORGE_HOSTS/LOKI_URL: this suite only asserts the default
@@ -330,8 +368,18 @@ setup_file() {
# container started without these gets none of them. Read from the
# manifest rather than written here, so this fixture cannot drift from
# what a real launch supplies.
# The capability posture from docker-compose.yml rather than a copy of it.
# This container is started with `docker run`, so a hand-written --cap-add
# list here would drift from the compose file the moment either changed -
# and the whole point of the floor is that the container a session actually
# gets is the one under test.
local -a caps=()
while IFS= read -r cap; do caps+=(--cap-add="$cap"); done < <(compose_cap_add)
[ "${#caps[@]}" -gt 0 ] || { echo "no cap_add found in docker-compose.yml" >&2; return 1; }
docker run -d --name "$container" \
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--cap-drop=ALL "${caps[@]}" \
--security-opt=no-new-privileges:true \
--network "$network" \
-e SANDBOX_TEST_MODE=1 \
-e GH_TOKEN="${GH_TOKEN:-}" \
@@ -385,7 +433,7 @@ teardown() {
docker rm -f "$c" >/dev/null 2>&1 || true
done < <(docker ps -a --filter "name=^dejima-test-.*$suffix\$" \
--format '{{.Names}}' 2>/dev/null)
docker images -q "dejima-test-*:$suffix" 2>/dev/null \
docker images -q "localhost/dejima-test-*:$suffix" 2>/dev/null \
| xargs -r docker rmi -f >/dev/null 2>&1 || true
}
@@ -398,7 +446,7 @@ dexec_node() {
}
@test "the container's user actually matches overridden SANDBOX_USER/UID/GID/HOME build args, not just the defaults" {
local test_image="dejima-test-override:$(basename "$BATS_RUN_TMPDIR")"
local test_image="localhost/dejima-test-override:$(basename "$BATS_RUN_TMPDIR")"
local test_container="$(test_container_name override)"
local sandbox_dir="$BATS_TEST_DIRNAME/../docker"
local repo_root="$BATS_TEST_DIRNAME/.."
@@ -525,6 +573,25 @@ dexec_node() {
[[ "$output" == *"policy DROP"* ]]
}
@test "the IPv6 block is a filtered family, not an absent one" {
# The DROP policy above is the good path. This asserts the container is
# actually ON that path rather than on the fallback, because the two are
# indistinguishable from the outside: a container with no IPv6 at all also
# reaches nothing over IPv6.
#
# If ip6tables ever stops working as root here, the test above fails and
# this one says why - and on a host that takes the fallback branch,
# init-firewall.sh now either turns the family off or refuses to start,
# rather than logging a conclusion it had not checked.
run dexec sh -c 'ip6tables -L >/dev/null 2>&1 && echo usable'
[[ "$output" == *usable* ]] \
|| { echo "ip6tables is not usable as root; the fallback branch is live"; return 1; }
# And the fallback's own condition is readable, so a failure here can be
# diagnosed without a second run: what does the kernel say is up?
run dexec sh -c 'cat /proc/net/if_inet6 2>/dev/null || echo "(no /proc/net/if_inet6)"'
[ "$status" -eq 0 ]
}
@test "a non-allowlisted domain is refused by the DNS proxy, not just left to fail later" {
run dexec dig definitely-not-on-the-allowlist.example.org
[[ "$output" == *"status: REFUSED"* ]]
@@ -1034,6 +1101,69 @@ dexec_node() {
run ! dexec_node sudo -n true
}
@test "the container runs with no path back up to more privilege" {
# no-new-privileges in docker-compose.yml. gosu drops privilege rather
# than gaining it, so this does not interfere with the session starting -
# what it forbids is a setuid binary or a file capability raising
# privileges on exec, which is the shape any escalation from the session
# user would have to take.
#
# Asserted on PID 1 and on the session user, because the flag is a property
# of the process tree and both halves of it matter.
run dexec cat /proc/1/status
[ "$status" -eq 0 ]
[[ "$output" == *$'NoNewPrivs:\t1'* ]] \
|| { echo "PID 1 can still gain privileges on exec"; return 1; }
run dexec_node sh -c 'grep NoNewPrivs /proc/self/status'
[[ "$output" == *"1"* ]] || { echo "the session user's shell can: $output"; return 1; }
}
@test "the capability bounding set is the declared floor and nothing more" {
# cap_drop: ALL plus an explicit list, rather than Docker's default set
# plus two. The entrypoint uses six capabilities during its root phase and
# the default carries about fourteen; this asserts the difference is real
# in the running container rather than only present in the compose file.
#
# CapBnd, not CapEff: the effective set is zero for the session user
# already (the test below), and the bounding set is what says the rest was
# never available to regain.
run dexec cat /proc/1/status
[ "$status" -eq 0 ]
local bnd; bnd=$(echo "$output" | awk '/^CapBnd:/ {print $2}')
[ -n "$bnd" ] || { echo "no CapBnd in /proc/1/status"; return 1; }
# Decoded by name so a failure says which capability, not which bit.
local have; have="$(dexec capsh --decode="$bnd" 2>/dev/null)" || have=""
[ -n "$have" ] || skip "capsh not available in the image to decode $bnd"
local cap
for cap in $(compose_cap_add); do
[[ "$have" == *"cap_$(echo "$cap" | tr '[:upper:]' '[:lower:]')"* ]] \
|| { echo "declared $cap is missing from the bounding set: $have"; return 1; }
done
# Not KILL: `dejima-allow --container` signals dnsmasq, which runs as its
# own user, so root here needs it. That absence is what failed the first
# privileged run of this change.
for cap in mknod setfcap setpcap sys_chroot audit_write net_bind_service sys_admin fsetid; do
[[ "$have" != *"cap_$cap"* ]] \
|| { echo "cap_$cap is in the bounding set and nothing declares it: $have"; return 1; }
done
}
@test "root can still signal dnsmasq, which is what adding a domain mid-session needs" {
# Named for the thing rather than left to be caught by the ipset tests,
# which is how it WAS caught: the first version of the capability floor
# dropped KILL, and `dejima-allow --container` failed with "Operation not
# permitted" because dnsmasq runs as its own user (`user=dnsmasq`) and root
# cannot signal another user's process without CAP_KILL.
#
# Adding a domain to a running session is a documented feature that every
# session briefing tells the agent to ask for, so this asserts the
# capability by exercising what needs it.
run dexec sh -c 'kill -HUP "$(cat /var/run/dnsmasq-sandbox.pid)"'
[ "$status" -eq 0 ] \
|| { echo "root cannot signal dnsmasq - is KILL in the floor? $output"; return 1; }
}
@test "the real running node process has NET_ADMIN/NET_RAW stripped, not just the bounding set" {
run dexec cat /proc/1/status
[ "$status" -eq 0 ]
@@ -1086,6 +1216,26 @@ EOF'
[[ "$output" == *"PLAYWRIGHT_LAUNCH_OK"* ]]
}
@test "a global package imports as ESM too, from a directory of its own" {
# The test above proves the browser works, in the module system that
# already resolved. NODE_PATH covers require() and nothing else: ESM walks
# up from the importing file, so the modern form failed everywhere while
# this suite stayed green. What an agent got was ERR_MODULE_NOT_FOUND,
# which reads as an image without a browser.
#
# Written somewhere with no node_modules of its own, because anywhere
# inside a project with one would resolve for the wrong reason.
dexec_node bash -c 'mkdir -p /tmp/esm-probe && cat > /tmp/esm-probe/probe.mjs <<'"'"'EOF'"'"'
import { chromium } from "playwright";
const browser = await chromium.launch({});
await browser.close();
console.log("ESM_IMPORT_OK");
EOF'
run dexec_node node /tmp/esm-probe/probe.mjs
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"ESM_IMPORT_OK"* ]] || { echo "$output"; return 1; }
}
@test "the firewall applies to Playwright's browser traffic, not just curl/git" {
# The whole point of pre-baking the browser at build time is that a
# session never needs new network access for it - confirm the egress
@@ -1354,7 +1504,7 @@ EOF
}
@test "a derived project image gets the package and keeps the base privilege posture" {
local derived="dejima-test-derived:$(basename "$BATS_RUN_TMPDIR")"
local derived="localhost/dejima-test-derived:$(basename "$BATS_RUN_TMPDIR")"
local test_container="$(test_container_name derived)"
local ctx; ctx="$(mktemp -d)"
local store; store="$(mktemp -d)"
+23 -1
View File
@@ -3,7 +3,7 @@
# and by test/static.bats, so the two cannot disagree about what is covered.
#
# The list is discovered, not enumerated: a script added under bin/, hooks/,
# docker/ or a forge's bin/ is covered the day it lands, which is the only
# docker/, test/ or a forge's bin/ is covered the day it lands, which is the only
# version of this that stays true. Discovery is by shebang rather than by
# extension, because most of this repo's scripts have no extension.
#
@@ -19,6 +19,12 @@ list_scripts() {
ls bin/dejima bin/dejima-* bin/harness/*.sh 2>/dev/null || true
ls hooks/*.sh docker/*.sh install.sh 2>/dev/null || true
ls forges/*/bin/* 2>/dev/null || true
# test/, for the reason .dejima.md gives for being careful what goes
# there: CI checks the commit out on a host and runs it, so a helper
# in a test file is code executing outside the sandbox. That makes
# 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
} | sort -u | while read -r f; do
[ -f "$f" ] || continue
# Harness plugins are sourced, not executed, so they carry no shebang
@@ -40,6 +46,22 @@ mapfile -t scripts < <(list_scripts)
[ "${#scripts[@]}" -gt 0 ] || { echo "found no shell scripts to check" >&2; exit 1; }
cd "$root"
# Said plainly rather than left to `exec` to fail with "not found", which reads
# as a broken script rather than a missing tool - and, on a nonzero exit nobody
# reads closely, as a check that ran.
if ! command -v shellcheck >/dev/null 2>&1; then
echo "shellcheck is not installed, so nothing was checked." >&2
echo >&2
echo "It is declared in .dejima/dependencies. In a sandbox that predates" >&2
echo "that declaration, approve it and relaunch so the image is rebuilt:" >&2
echo >&2
echo " dejima-project approve" >&2
echo >&2
echo "Report the suite as not run rather than as passing." >&2
exit 127
fi
# -S warning: info and style are largely taste, and a check whose output gets
# skimmed is not a check. Severities and the disabled classes are in
# .shellcheckrc, beside the reasoning for each.
+1957 -32
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
`rtk` is available, and reading less of a command's output is usually free:
it runs the real command and prints a compressed form of what came back.
Prefix a command with it where the full output is not the point.
rtk git status rtk git diff rtk git log -n 20
rtk ls . rtk find "*.py" . rtk grep "pattern" .
rtk cargo test rtk pytest rtk go test
It runs the real binary through PATH, so every guard that applies to a
command applies to it wrapped in rtk too. `rtk proxy <cmd>` runs something
unfiltered when you need the whole output, and `rtk read <file>` is a
structure-first view of a file rather than the whole body.
Use judgement rather than wrapping everything: a test run you are about to
debug line by line, or a diff you are about to apply, is output you want in
full.
+43
View File
@@ -0,0 +1,43 @@
# rtk - a CLI proxy that compresses command output before an agent reads it.
# https://github.com/rtk-ai/rtk, Apache-2.0, a single static Rust binary.
#
# The first optional tool, and the shape every later one has to follow: dejima
# does not install what a project asks for, it installs what it has hardened.
# See docs/tools.md for what that means and what was checked here.
#
# What it is NOT: rtk makes no model calls of its own. It is a deterministic
# output filter - `rtk git status` runs git and prints less of the result - so
# nothing here sends code anywhere.
description = compresses command output before it reaches the model
# The published release, pinned by digest. Deliberately not the project's
# `curl | sh` installer, which fetches whatever master points at today: an
# image build that produces a different binary each time is one nothing has
# reviewed. The digest was taken from the artifact below and checked against a
# second download.
#
# x86_64 only, which is issue #97's limitation and not this tool's: the
# tarball spec names one architecture and an arm64 host cannot use this entry.
install = tarball:rtk@c4c036fbf181fc55ef329786c8c17e0d427972b053b825944d968a6aafef1ba4:https://github.com/rtk-ai/rtk/releases/download/v0.45.0/rtk-x86_64-unknown-linux-musl.tar.gz
# The archive is one bare executable with no enclosing directory, where a Go
# or nvim release has everything under one. Stripping a component would strip
# the binary itself and extract nothing.
install_layout = binary
# rtk carries telemetry the README does not mention: an https ping compiled
# into the release binary, gated on a consent flag that defaults to unset.
# Disabled here as well, and the firewall does not allowlist the endpoint, so
# it is off three ways. Verified in the shipped artifact's strings, not only
# in the source - see docs/tools.md.
#
# This carried NO_COLOR=1 as well, reasoning that rtk's output lands in a
# model's context where an escape sequence is noise. The reasoning was fine
# and the mechanism was wrong: a tool's image_env becomes a Dockerfile ENV,
# which every process in the container inherits, so it took the colour out of
# the harness, git, ls and everything else. A tool may only set variables that
# are its own, and core refuses one that is not.
image_env = RTK_TELEMETRY_DISABLED=1
command = rtk
instructions = instructions.md
+146 -14
View File
@@ -10,7 +10,7 @@
<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: 0d07a16b97dd65deac6d7985dfa4603b6f68ba0ac31dccbb77c314bc200016ac -->
<!-- Generated by web/build.mjs - do not edit. Sources: f8a5205278ef77485e319cdefff6cbeeb027f201ab27266b02703a9eb570c608 -->
<style>
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
@@ -344,14 +344,68 @@ body.touch #terminal {
-webkit-touch-callout: none;
}
/* gruvbox dark, the same values bin/dejima-attach uses for the session
picker, each tagged with its upstream color.table name so a wrong hex is
catchable by reading. The two sit either side of one attach: picker, then
terminal. They should not look like different programs.
https://github.com/gruvbox-community/gruvbox-contrib/blob/master/color.table */
:root {
--gb-dark0-hard: #1d2021;
--gb-dark0: #282828;
--gb-dark1: #3c3836;
--gb-dark2: #504945;
--gb-light1: #ebdbb2;
--gb-orange: #fe8019; /* bright_orange, the wordmark */
--gb-yellow: #fabd2f; /* bright_yellow */
--gb-green: #b8bb26; /* bright_green */
--gb-blue: #83a598; /* bright_blue */
/* What the toolbar is made of, in tiers rather than in colours, so that
turning colour off is one block of overrides at the bottom of this file
and not an edit to every rule.
The tiers are an attention hierarchy, and the order is not arbitrary: KBD
is how you type at all on a phone, so it is the one button that must be
findable without looking for it. Enter is the key a session asks for most.
The window and arrow keys are movement - reached for often, but never the
thing you are hunting for - so they are marked out as a group without
being filled. Everything else is furniture. */
--key-fg: var(--gb-light1);
--key-bg: var(--gb-dark1);
--key-border: var(--gb-dark2);
--key-active-bg: var(--gb-dark2);
--key-bar-bg: var(--gb-dark0-hard);
--key-bar-border: var(--gb-dark2);
--key-primary-fg: var(--gb-dark0-hard);
--key-primary-bg: var(--gb-orange);
--key-primary-border: var(--gb-orange);
--key-secondary-fg: var(--gb-dark0-hard);
--key-secondary-bg: var(--gb-green);
--key-secondary-border: var(--gb-green);
/* Outlined, not coloured-in: bright_blue as text on dark1 measures 4.31:1,
which is under the 4.5:1 an unenlarged label needs, and the label is the
part that has to survive a phone in sunlight. The border carries the tier
and the text stays at 8.45:1. */
--key-nav-fg: var(--gb-light1);
--key-nav-border: var(--gb-blue);
--key-armed-fg: var(--gb-dark0-hard);
--key-armed-bg: var(--gb-blue);
--key-locked-fg: var(--gb-dark0-hard);
--key-locked-bg: var(--gb-yellow);
}
#toolbar {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px 4px calc(4px + env(safe-area-inset-bottom));
background: #14161a;
border-top: 1px solid #2a2f37;
background: var(--key-bar-bg);
border-top: 1px solid var(--key-bar-border);
touch-action: manipulation;
}
@@ -377,9 +431,9 @@ body.touch #terminal {
bound is where a monospace PREFIX still fits; the upper is the size this
has always been, so a wide screen is unchanged. */
font: 600 clamp(10px, 2.8vw, 13px)/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: #d7dce3;
background: #232830;
border: 1px solid #343b45;
color: var(--key-fg);
background: var(--key-bg);
border: 1px solid var(--key-border);
border-radius: 6px;
-webkit-user-select: none;
user-select: none;
@@ -387,21 +441,79 @@ body.touch #terminal {
}
#toolbar button:active {
background: #39414d;
background: var(--key-active-bg);
}
/* The hierarchy. Filled-warm beats filled-cool beats outlined beats plain,
which is a ramp that survives being looked at quickly and in sunlight -
the two conditions a phone toolbar is actually used in.
These sit ABOVE the latch rules on purpose. A latched CTRL and a tiered
button match at the same specificity, so what decides is source order, and
the state a button is IN has to beat the job it does. */
#toolbar button[data-action="kbd"] {
color: var(--key-primary-fg);
background: var(--key-primary-bg);
border-color: var(--key-primary-border);
}
#toolbar button[data-action="enter"] {
color: var(--key-secondary-fg);
background: var(--key-secondary-bg);
border-color: var(--key-secondary-border);
}
#toolbar button[data-action="prev-window"],
#toolbar button[data-action="next-window"],
#toolbar button[data-action="left"],
#toolbar button[data-action="down"],
#toolbar button[data-action="up"],
#toolbar button[data-action="right"] {
color: var(--key-nav-fg);
border-color: var(--key-nav-border);
}
/* Armed for one key, versus latched on. Two states on one button, so the
distinction has to be visible without a legend. */
#toolbar button.is-armed {
color: #0b0d10;
background: #7fb3ff;
border-color: #7fb3ff;
color: var(--key-armed-fg);
background: var(--key-armed-bg);
border-color: var(--key-armed-bg);
}
#toolbar button.is-locked {
color: #0b0d10;
background: #ffcc66;
border-color: #ffcc66;
color: var(--key-locked-fg);
background: var(--key-locked-bg);
border-color: var(--key-locked-bg);
}
/* Every colour the toolbar had before it had a palette. One block, because
the alternative is an override per rule and a way for the two to drift -
an operator who turns colour off is asking for what was here, not for a
second scheme to maintain. */
body.mono-keys {
--key-fg: #d7dce3;
--key-bg: #232830;
--key-border: #343b45;
--key-active-bg: #39414d;
--key-bar-bg: #14161a;
--key-bar-border: #2a2f37;
--key-primary-fg: #d7dce3;
--key-primary-bg: #232830;
--key-primary-border: #343b45;
--key-secondary-fg: #d7dce3;
--key-secondary-bg: #232830;
--key-secondary-border: #343b45;
--key-nav-fg: #d7dce3;
--key-nav-border: #343b45;
--key-armed-fg: #0b0d10;
--key-armed-bg: #7fb3ff;
--key-locked-fg: #0b0d10;
--key-locked-bg: #ffcc66;
}
#toast {
@@ -744,7 +856,7 @@ body.touch #terminal {
// an xterm.js option and is handed to the terminal untouched, which is how
// the existing TTYD_FONT_SIZE knob keeps working with no code here knowing
// about it. isWindows is ttyd's own, always present, and not an xterm option.
var PAGE_KEYS = ['tmuxPrefix', 'focusOnTap'];
var PAGE_KEYS = ['tmuxPrefix', 'focusOnTap', 'buttonColors'];
var IGNORED_KEYS = ['isWindows'];
// "C-b" -> "\x02", the byte tmux is actually listening for. Deliberately
@@ -811,6 +923,19 @@ body.touch #terminal {
return !coarsePointer;
}
// Whether the toolbar is colour-coded by what each button is for.
//
// On unless the operator says otherwise: the hierarchy is the point of it,
// and a toolbar where every key looks the same is one where the key you
// need most is found by counting positions. "off" restores exactly the grey
// it had before, for a terminal colour scheme this clashes with or an
// operator who would rather it were quiet.
function buttonColorsWanted(preference) {
if (preference === 'off' || preference === false || preference === 'false' ||
preference === '0') return false;
return true;
}
// The address of the session picker: this page with the run-id dropped.
//
// ttyd passes ?arg=<run-id> to dejima-attach as argv (--url-arg), so a page
@@ -834,6 +959,7 @@ body.touch #terminal {
splitPreferences: splitPreferences,
toolbarWanted: toolbarWanted,
focusOnTapWanted: focusOnTapWanted,
buttonColorsWanted: buttonColorsWanted,
pickerSearch: pickerSearch
};
})((globalThis.DEJIMA = globalThis.DEJIMA || {}));
@@ -1720,6 +1846,12 @@ body.touch #terminal {
// able to change it.
tapFocusPolicy(split.page.focusOnTap);
// A class rather than per-button styling: what the page decides is
// whether there is a palette at all, and the stylesheet owns which button
// gets which part of it.
document.body.classList.toggle(
'mono-keys', !D.config.buttonColorsWanted(split.page.buttonColors));
// A font size arriving in the preferences changes the cell size, so the
// column count computed before it was applied is now wrong.
viewport.refresh();
+3 -3
View File
@@ -1,13 +1,13 @@
f6f6611d0b3eacc39d10add9c4bbc56d4b3a70a47f022351a1c6eab68cd66e71 package-lock.json
38b24a901a43db06854133f4de11094e30c590ff08074bb35bf0d664975a8cb2 src/config.js
613d28096b1bed66f347590663f5f0136fb7f61a4578066fa2ed9e788ff9567a src/config.js
f690fd9d35975bc977e3d323595a2a4aa1fca6ed50f213602f08adec3f5b2c92 src/gestures.js
e75dc9d5e311a3d9bf9dc373b651ad506afbd7a7cc7ad54aac6fdad94276b0a4 src/keyboard.js
2a070f63702b95b0c62963ec6b9ec4fa2b0b89b7553a8a889d68cdf3506f9ce3 src/keys.js
7e2f5a89c28b048c6cc26d0a4a2a2411501f092dab11529a32ae3856f1350d63 src/main.js
e5888ba8413fc2b442b44cade378f7393fd231dd5f79d44c58d1bc217ed7c807 src/main.js
e3a550e2249b536c9bc3d90ee2735a9a2d8e6f772a6a631f724d289b96b1f557 src/modes.js
8446be09a1eca041a5a2e2068fdace32596713aef1d1fb74b522ea4eacc50ec3 src/mouse.js
cb5e33c0fa117176d9073bd9019f78b65bfe5535833bf1765ee542c700d67f1f src/osc52.js
7af901e2df49772c42fc1e083b4db8616e7d5f04bedf21f482da766a169165b7 src/page.css
94229c981f6287162d6ef774f71ef41d941494595f76f16bb742811ca32a767f src/page.css
e02823ce9d5d57615a0a8c0c7b91611932202758048b7eadabe4f1054aaf1a9f src/protocol.js
aac82e88c6a65617818c6589091cbb8c3bab50e82e98aabf8b583cbb3dfda7e7 src/sgr.js
dab361cf748e6d530099f79ccd3f407fcc493f81dd4afeda227376a8c9e9b81f src/template.html
+15 -1
View File
@@ -12,7 +12,7 @@
// an xterm.js option and is handed to the terminal untouched, which is how
// the existing TTYD_FONT_SIZE knob keeps working with no code here knowing
// about it. isWindows is ttyd's own, always present, and not an xterm option.
var PAGE_KEYS = ['tmuxPrefix', 'focusOnTap'];
var PAGE_KEYS = ['tmuxPrefix', 'focusOnTap', 'buttonColors'];
var IGNORED_KEYS = ['isWindows'];
// "C-b" -> "\x02", the byte tmux is actually listening for. Deliberately
@@ -79,6 +79,19 @@
return !coarsePointer;
}
// Whether the toolbar is colour-coded by what each button is for.
//
// On unless the operator says otherwise: the hierarchy is the point of it,
// and a toolbar where every key looks the same is one where the key you
// need most is found by counting positions. "off" restores exactly the grey
// it had before, for a terminal colour scheme this clashes with or an
// operator who would rather it were quiet.
function buttonColorsWanted(preference) {
if (preference === 'off' || preference === false || preference === 'false' ||
preference === '0') return false;
return true;
}
// The address of the session picker: this page with the run-id dropped.
//
// ttyd passes ?arg=<run-id> to dejima-attach as argv (--url-arg), so a page
@@ -102,6 +115,7 @@
splitPreferences: splitPreferences,
toolbarWanted: toolbarWanted,
focusOnTapWanted: focusOnTapWanted,
buttonColorsWanted: buttonColorsWanted,
pickerSearch: pickerSearch
};
})((globalThis.DEJIMA = globalThis.DEJIMA || {}));
+6
View File
@@ -110,6 +110,12 @@
// able to change it.
tapFocusPolicy(split.page.focusOnTap);
// A class rather than per-button styling: what the page decides is
// whether there is a palette at all, and the stylesheet owns which button
// gets which part of it.
document.body.classList.toggle(
'mono-keys', !D.config.buttonColorsWanted(split.page.buttonColors));
// A font size arriving in the preferences changes the cell size, so the
// column count computed before it was applied is now wrong.
viewport.refresh();
+124 -12
View File
@@ -42,14 +42,68 @@ body.touch #terminal {
-webkit-touch-callout: none;
}
/* gruvbox dark, the same values bin/dejima-attach uses for the session
picker, each tagged with its upstream color.table name so a wrong hex is
catchable by reading. The two sit either side of one attach: picker, then
terminal. They should not look like different programs.
https://github.com/gruvbox-community/gruvbox-contrib/blob/master/color.table */
:root {
--gb-dark0-hard: #1d2021;
--gb-dark0: #282828;
--gb-dark1: #3c3836;
--gb-dark2: #504945;
--gb-light1: #ebdbb2;
--gb-orange: #fe8019; /* bright_orange, the wordmark */
--gb-yellow: #fabd2f; /* bright_yellow */
--gb-green: #b8bb26; /* bright_green */
--gb-blue: #83a598; /* bright_blue */
/* What the toolbar is made of, in tiers rather than in colours, so that
turning colour off is one block of overrides at the bottom of this file
and not an edit to every rule.
The tiers are an attention hierarchy, and the order is not arbitrary: KBD
is how you type at all on a phone, so it is the one button that must be
findable without looking for it. Enter is the key a session asks for most.
The window and arrow keys are movement - reached for often, but never the
thing you are hunting for - so they are marked out as a group without
being filled. Everything else is furniture. */
--key-fg: var(--gb-light1);
--key-bg: var(--gb-dark1);
--key-border: var(--gb-dark2);
--key-active-bg: var(--gb-dark2);
--key-bar-bg: var(--gb-dark0-hard);
--key-bar-border: var(--gb-dark2);
--key-primary-fg: var(--gb-dark0-hard);
--key-primary-bg: var(--gb-orange);
--key-primary-border: var(--gb-orange);
--key-secondary-fg: var(--gb-dark0-hard);
--key-secondary-bg: var(--gb-green);
--key-secondary-border: var(--gb-green);
/* Outlined, not coloured-in: bright_blue as text on dark1 measures 4.31:1,
which is under the 4.5:1 an unenlarged label needs, and the label is the
part that has to survive a phone in sunlight. The border carries the tier
and the text stays at 8.45:1. */
--key-nav-fg: var(--gb-light1);
--key-nav-border: var(--gb-blue);
--key-armed-fg: var(--gb-dark0-hard);
--key-armed-bg: var(--gb-blue);
--key-locked-fg: var(--gb-dark0-hard);
--key-locked-bg: var(--gb-yellow);
}
#toolbar {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px 4px calc(4px + env(safe-area-inset-bottom));
background: #14161a;
border-top: 1px solid #2a2f37;
background: var(--key-bar-bg);
border-top: 1px solid var(--key-bar-border);
touch-action: manipulation;
}
@@ -75,9 +129,9 @@ body.touch #terminal {
bound is where a monospace PREFIX still fits; the upper is the size this
has always been, so a wide screen is unchanged. */
font: 600 clamp(10px, 2.8vw, 13px)/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: #d7dce3;
background: #232830;
border: 1px solid #343b45;
color: var(--key-fg);
background: var(--key-bg);
border: 1px solid var(--key-border);
border-radius: 6px;
-webkit-user-select: none;
user-select: none;
@@ -85,21 +139,79 @@ body.touch #terminal {
}
#toolbar button:active {
background: #39414d;
background: var(--key-active-bg);
}
/* The hierarchy. Filled-warm beats filled-cool beats outlined beats plain,
which is a ramp that survives being looked at quickly and in sunlight -
the two conditions a phone toolbar is actually used in.
These sit ABOVE the latch rules on purpose. A latched CTRL and a tiered
button match at the same specificity, so what decides is source order, and
the state a button is IN has to beat the job it does. */
#toolbar button[data-action="kbd"] {
color: var(--key-primary-fg);
background: var(--key-primary-bg);
border-color: var(--key-primary-border);
}
#toolbar button[data-action="enter"] {
color: var(--key-secondary-fg);
background: var(--key-secondary-bg);
border-color: var(--key-secondary-border);
}
#toolbar button[data-action="prev-window"],
#toolbar button[data-action="next-window"],
#toolbar button[data-action="left"],
#toolbar button[data-action="down"],
#toolbar button[data-action="up"],
#toolbar button[data-action="right"] {
color: var(--key-nav-fg);
border-color: var(--key-nav-border);
}
/* Armed for one key, versus latched on. Two states on one button, so the
distinction has to be visible without a legend. */
#toolbar button.is-armed {
color: #0b0d10;
background: #7fb3ff;
border-color: #7fb3ff;
color: var(--key-armed-fg);
background: var(--key-armed-bg);
border-color: var(--key-armed-bg);
}
#toolbar button.is-locked {
color: #0b0d10;
background: #ffcc66;
border-color: #ffcc66;
color: var(--key-locked-fg);
background: var(--key-locked-bg);
border-color: var(--key-locked-bg);
}
/* Every colour the toolbar had before it had a palette. One block, because
the alternative is an override per rule and a way for the two to drift -
an operator who turns colour off is asking for what was here, not for a
second scheme to maintain. */
body.mono-keys {
--key-fg: #d7dce3;
--key-bg: #232830;
--key-border: #343b45;
--key-active-bg: #39414d;
--key-bar-bg: #14161a;
--key-bar-border: #2a2f37;
--key-primary-fg: #d7dce3;
--key-primary-bg: #232830;
--key-primary-border: #343b45;
--key-secondary-fg: #d7dce3;
--key-secondary-bg: #232830;
--key-secondary-border: #343b45;
--key-nav-fg: #d7dce3;
--key-nav-border: #343b45;
--key-armed-fg: #0b0d10;
--key-armed-bg: #7fb3ff;
--key-locked-fg: #0b0d10;
--key-locked-bg: #ffcc66;
}
#toast {
+30 -1
View File
@@ -4,7 +4,7 @@ const assert = require('node:assert');
require('../src/keys.js'); // config.js parses the tmux prefix through it
require('../src/config.js');
const { DEFAULT_TMUX_PREFIX, parsePrefix, splitPreferences, toolbarWanted,
focusOnTapWanted, pickerSearch } = globalThis.DEJIMA.config;
focusOnTapWanted, pickerSearch, buttonColorsWanted } = globalThis.DEJIMA.config;
test('a tmux prefix is parsed into the byte tmux would receive', () => {
assert.equal(parsePrefix('C-b'), '\x02');
@@ -116,3 +116,32 @@ test('the picker address is already the picker when there is no run-id', () => {
assert.equal(pickerSearch(''), '');
assert.equal(pickerSearch('?toolbar=1'), '?toolbar=1');
});
// The toolbar's colours are on unless the operator turns them off: the
// hierarchy is the point of them, and a toolbar where every key looks alike is
// one where the key you need most is found by counting positions.
test('the toolbar is coloured when nothing says otherwise', () => {
assert.equal(buttonColorsWanted(undefined), true);
assert.equal(buttonColorsWanted(''), true);
assert.equal(buttonColorsWanted('on'), true);
});
test('off turns the toolbar grey, in dejima\'s own bool spelling', () => {
// ttyd_button_colors is a dejima bool, so what reaches the page is "off" -
// not "false". Accepting only the latter would ship a documented key that
// does nothing, which is exactly what focusOnTap nearly did.
assert.equal(buttonColorsWanted('off'), false);
assert.equal(buttonColorsWanted(false), false);
assert.equal(buttonColorsWanted('false'), false);
assert.equal(buttonColorsWanted('0'), false);
});
test('buttonColors is a page option, not one xterm is handed', () => {
// Every page key that reached term.options would be an unknown option to
// xterm.js, which is the failure splitPreferences exists to prevent.
const split = splitPreferences({ buttonColors: 'off', fontSize: 14 });
assert.equal(split.page.buttonColors, 'off');
assert.equal(split.terminal.buttonColors, undefined);
assert.equal(split.terminal.fontSize, 14);
});
+106
View File
@@ -0,0 +1,106 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
// Every label the toolbar draws has to be readable on a phone held outdoors,
// which is the only place this page is ever used. WCAG AA asks 4.5:1 for text
// this size, and one of these pairings shipped at 4.31:1 in the first draft -
// gruvbox's bright_blue as a label on dark1 - which is a difference nobody
// catches by looking at a screenshot on a desk.
//
// Read out of the stylesheet rather than restated here, so a colour changed
// there is a colour checked here.
const CSS = fs.readFileSync(path.join(__dirname, '..', 'src', 'page.css'), 'utf8');
function declarationsIn(block) {
const out = {};
const re = /^\s*(--[a-z0-9-]+):\s*([^;]+);/gm;
let m;
while ((m = re.exec(block)) !== null) out[m[1]] = m[2].trim();
return out;
}
function blockFor(selector) {
const start = CSS.indexOf(selector + ' {');
assert.notEqual(start, -1, 'no ' + selector + ' block in page.css');
const end = CSS.indexOf('\n}', start);
return CSS.slice(start, end);
}
// var(--x) chains down to a literal hex.
function resolve(vars, value, depth) {
depth = depth || 0;
assert.ok(depth < 10, 'variable cycle resolving ' + value);
const m = /^var\((--[a-z0-9-]+)\)$/.exec(value);
if (!m) return value;
assert.ok(vars[m[1]], 'undefined variable ' + m[1]);
return resolve(vars, vars[m[1]], depth + 1);
}
function luminance(hex) {
const h = hex.replace('#', '');
const parts = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255);
const lin = parts.map((c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)));
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
}
function contrast(fg, bg) {
const a = luminance(fg);
const b = luminance(bg);
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
}
// Each tier's label against the surface it is drawn on. The nav tier is
// outlined rather than filled, so its label sits on the ordinary key
// background - which is precisely why its colour matters.
const PAIRINGS = [
['plain keys', '--key-fg', '--key-bg'],
['the nav tier', '--key-nav-fg', '--key-bg'],
['KBD', '--key-primary-fg', '--key-primary-bg'],
['Enter', '--key-secondary-fg', '--key-secondary-bg'],
['an armed latch', '--key-armed-fg', '--key-armed-bg'],
['a locked latch', '--key-locked-fg', '--key-locked-bg']
];
function checkBlock(selector) {
const vars = declarationsIn(blockFor(selector));
PAIRINGS.forEach(([what, fgVar, bgVar]) => {
assert.ok(vars[fgVar], selector + ' does not set ' + fgVar);
assert.ok(vars[bgVar], selector + ' does not set ' + bgVar);
const fg = resolve(vars, vars[fgVar]);
const bg = resolve(vars, vars[bgVar]);
const ratio = contrast(fg, bg);
assert.ok(
ratio >= 4.5,
what + ' in ' + selector + ' is ' + fg + ' on ' + bg + ' at ' +
ratio.toFixed(2) + ':1, under the 4.5:1 a label this size needs'
);
});
}
test('every toolbar label meets AA against what it is drawn on', () => {
checkBlock(':root');
});
test('turning the colours off does not turn legibility off with them', () => {
// The grey scheme is a supported mode, not a fallback nobody looks at.
checkBlock('body.mono-keys');
});
test('the tiers are actually distinct, or the hierarchy is decoration', () => {
const vars = declarationsIn(blockFor(':root'));
const bg = (v) => resolve(vars, vars[v]);
const primary = bg('--key-primary-bg');
const secondary = bg('--key-secondary-bg');
const plain = bg('--key-bg');
assert.notEqual(primary, secondary, 'KBD and Enter are filled the same colour');
assert.notEqual(primary, plain, 'KBD is filled the same as an ordinary key');
assert.notEqual(secondary, plain, 'Enter is filled the same as an ordinary key');
assert.notEqual(
resolve(vars, vars['--key-nav-border']), resolve(vars, vars['--key-border']),
'the nav tier is outlined the same as an ordinary key'
);
});
+4 -1
View File
@@ -10,7 +10,10 @@ Git workflow - do this even though nothing stops you from doing otherwise:
branch-finishing advice from a skill cannot know that, and does not
override it.
- Stop at the push. Do not open a pull request, and do not merge anything -
neither is part of this workflow. Report the branch name and stop.
neither is part of this workflow. A `merge-guard.sh` hook and a shadow over
the forge's own command both refuse a merge here, but treat that as the
workflow rather than as a filter to route around. Report the branch name
and stop.
- Never push directly to {{blocked_branches}}, on any remote. A
`git-push-guard.sh` hook enforces that as a backstop, but treat it as the
workflow to follow rather than a filter to route around.
+8 -3
View File
@@ -1,9 +1,14 @@
# block_merge is off despite this being a pushing workflow: nothing here ever
# reaches a forge's PR interface, so there is no merge command to block.
requires =
push = on
blocked_branches = main master
block_merge = off
# This said "off" and gave a reason: nothing here ever reaches a forge's PR
# interface, so there is no merge command to block. The premise is false. What
# the briefing tells the agent to do and what the image puts on its PATH are
# different things - select forge = gitea and `gitea-pr merge` is present
# whatever the workflow says, because the CLI is installed by the forge, not
# by the workflow. This workflow's own briefing says "do not merge anything",
# and until now nothing held it to that.
block_merge = on
requires_worktree = on
autonomy = push-branch
briefing = briefing.md
+1 -1
View File
@@ -3,7 +3,7 @@ Git workflow - do this even though nothing stops you from doing otherwise:
anywhere: there is no remote destination configured for it, and
`git-push-guard.sh` blocks pushes as a backstop.
- Do not open pull requests and do not merge. Neither is part of this
workflow.
workflow, and a merge is refused here in any case.
- Report what you committed and stop. If the work needs to reach a remote,
say so plainly rather than attempting it - that is the operator's call and
needs a different workflow.
+6 -1
View File
@@ -3,7 +3,12 @@
requires =
push = off
blocked_branches =
block_merge = off
# On for the same reason branch-push's is: this workflow may be selected
# alongside a forge, its briefing says "do not open pull requests and do not
# merge", and the forge's CLI is on PATH regardless of what the workflow tells
# the agent to do. A briefing that states a rule while the manifest leaves the
# guard off is asserting something nothing holds.
block_merge = on
requires_worktree = off
autonomy = commit
briefing = briefing.md