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 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
36 changed files with 4668 additions and 1574 deletions
+4
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) |
+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:
+483 -584
View File
File diff suppressed because it is too large Load Diff
+92 -20
View File
@@ -446,13 +446,9 @@ if [ "$PRINT_CONFIG" = 1 ]; then
# nothing would show this machine's own credentials as though they were
# the identity's - the exact confusion the refusal exists to prevent, in
# the one command an operator runs to check what they configured.
_identity=""
config_exists && { _identity="$(config_get core identity)" || _identity=""; }
if [ -n "$_identity" ] && ! identity_exists "$_identity"; then
echo "[core] identity is '$_identity', and no [identity.$_identity] section sets" >&2
echo "anything in $(config_path)." >&2
exit 1
fi
# The forge is read here rather than taken from DEJIMA_FORGE_NAME, which
# is resolved further down, past this early exit.
identity_validate "$(config_get core forge)" || exit 1
while IFS= read -r _key; do
_section="${_key%.*}"
_leaf="${_key##*.}"
@@ -819,12 +815,16 @@ fi
# 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 does nothing: the
# comparison would have nothing to be right about.
# 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" ]; then
if [ -n "$DEJIMA_FORGE_REPO_FLAGS" ] || [ -n "$DEJIMA_FORGE_REPO_ENV" ] ||
[ -n "$DEJIMA_FORGE_REPO_PATHS" ]; then
_origin_url="$(git -C "$WORKSPACE_DIR" remote get-url origin 2>/dev/null)" || _origin_url=""
if [ -n "$_origin_url" ]; then
_origin_url="${_origin_url%/}"
@@ -1263,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)"
@@ -1423,6 +1423,7 @@ extra_mounts+=(-e "DEJIMA_MERGE_BLOCK=$DEJIMA_MERGE_BLOCK")
extra_mounts+=(-e "DEJIMA_DISCLOSE=$DEJIMA_DISCLOSE")
extra_mounts+=(-e "DEJIMA_FORGE_REPO=$DEJIMA_FORGE_REPO")
extra_mounts+=(-e "DEJIMA_FORGE_REPO_FLAGS=$DEJIMA_FORGE_REPO_FLAGS")
extra_mounts+=(-e "DEJIMA_FORGE_REPO_PATHS=$DEJIMA_FORGE_REPO_PATHS")
extra_mounts+=(-e "DEJIMA_FORGE_REPO_ENV=$DEJIMA_FORGE_REPO_ENV")
# The harness's own configuration, mounted at the mode it declared, and -
@@ -1868,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
@@ -1892,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
@@ -1901,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
@@ -1970,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" \
@@ -1998,7 +2070,7 @@ for _tool in $DEJIMA_TOOLS; do
_tool_install="$(manifest_get tool "$_tool" install)"
_tool_env="$(manifest_get tool "$_tool" image_env)"
_tool_layout="$(manifest_get tool "$_tool" install_layout)"
tag="dejima:${SANDBOX_USER}-tool-${_tool}-$(printf '%s' "$_tool_install" | sha256sum | cut -c1-12)"
tag="${SANDBOX_IMAGE_REPO}:${SANDBOX_USER}-tool-${_tool}-$(printf '%s' "$_tool_install" | sha256sum | cut -c1-12)"
DEJIMA_TARBALL_STRIP="$_tool_layout" \
generate_spec_dockerfile "$SANDBOX_IMAGE" "$_tool_install" "" "$_tool_env" "" \
> "$SANDBOX_DERIVED_DOCKERFILE"
@@ -2016,10 +2088,10 @@ done
unset _tool _tool_install _tool_env _tool_layout
if [ -s "$(config_root)/dependencies" ]; then
build_dep_layer "dejima:${SANDBOX_USER}-global" "$(config_root)"
build_dep_layer "${SANDBOX_IMAGE_REPO}:${SANDBOX_USER}-global" "$(config_root)"
fi
if [ -n "$PROJECT_STORE" ] && [ -s "$PROJECT_STORE/dependencies" ]; then
build_dep_layer "dejima:${SANDBOX_USER}-$(project_slug "$WORKSPACE_DIR")" "$PROJECT_STORE"
build_dep_layer "${SANDBOX_IMAGE_REPO}:${SANDBOX_USER}-$(project_slug "$WORKSPACE_DIR")" "$PROJECT_STORE"
fi
export SANDBOX_IMAGE
+93 -13
View File
@@ -29,11 +29,14 @@ Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>
Scope, which decides who the grant is for and how long it lasts:
(none) the running container, right now, until it exits. Finds it
if exactly one is running; refuses if none or several.
if exactly one is running. With several, it offers them to
pick from on a terminal, and lists them and stops anywhere
else - a prompt is no use to a script.
--container <id> the same, for a named container. Use this when a session
told you its own id rather than letting anything guess.
With several running, they are listed by project and by
the number the dejima-attach picker gives each one.
the number the dejima-attach picker gives each one. On a
terminal it offers them to pick from instead.
--project [path] every future session in one project. Also writes the domain
to that project's .dejima/allowed-domains, so it is shared
with everyone working on it. Path defaults to $PWD.
@@ -63,6 +66,11 @@ source "$SCRIPT_DIR/dejima-project-lib.sh"
# reason init-firewall.sh and dns-proxy.sh share this file.
# shellcheck source=../docker/fixed-domains.sh
source "$SCRIPT_DIR/../docker/fixed-domains.sh"
# The palette, heading and prompt dejima-attach uses. Picking one session out
# of several is the same job here, and an operator holding one terminal should
# not meet two different answers to it.
# shellcheck source=dejima-tui-lib.sh
source "$SCRIPT_DIR/dejima-tui-lib.sh"
usage() {
echo "Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>" >&2
@@ -146,7 +154,15 @@ fi
#
# Reads only. Nothing here decides which container is acted on - that is the
# caller's --container, or the single running one.
describe_containers() {
# Fills CT_IDS, CT_NAMES, CT_PROJECTS and CT_ATTACH, in the order docker
# listed them, and measures the widest project and name for the renderers
# below. Padding to the widest value rather than to a guess is not cosmetic
# here: "dejima-sandbox-run-" plus twelve hex digits is 31 characters, so a
# fixed 28 ran every name straight into the project beside it and there was
# no telling where one ended.
CT_IDS=(); CT_NAMES=(); CT_PROJECTS=(); CT_ATTACH=()
CT_W_NAME=0; CT_W_PROJECT=0; CT_HAS_ATTACH=0
collect_containers() {
local listing="$1" name id project row n=0 idx
local -A attach_index=()
while IFS=$'\t' read -r _ _ _ _ _ row; do
@@ -158,12 +174,61 @@ describe_containers() {
[ -n "$name" ] || continue
project="$(docker inspect -f '{{.Config.WorkingDir}}' "$id" 2>/dev/null)" || project=""
[ -n "$project" ] && project="$(basename "$project")"
project="${project:-?}"
idx="${attach_index[${id:0:12}]:-}"
printf ' %-28s %-24s %s\n' "$name" "${project:-?}" \
"${idx:+(dejima-attach $idx)}"
CT_IDS+=("$id"); CT_NAMES+=("$name"); CT_PROJECTS+=("$project")
CT_ATTACH+=("$idx")
[ -z "$idx" ] || CT_HAS_ATTACH=1
[ "${#name}" -le "$CT_W_NAME" ] || CT_W_NAME="${#name}"
[ "${#project}" -le "$CT_W_PROJECT" ] || CT_W_PROJECT="${#project}"
done <<< "$listing"
}
# The non-interactive form: a list to read, with the flag that acts on a row.
# This is what a script gets, and what an agent is shown when it asks the
# operator to run one of these.
describe_containers() {
local i
for i in "${!CT_IDS[@]}"; do
printf ' %-*s %-*s %s\n' \
"$CT_W_PROJECT" "${CT_PROJECTS[$i]}" \
"$CT_W_NAME" "${CT_NAMES[$i]}" \
"${CT_ATTACH[$i]:+(dejima-attach ${CT_ATTACH[$i]})}"
done
}
# The interactive form. Same rows, numbered, under dejima-attach's heading -
# an operator with several sessions open is choosing between the same things
# in both tools, and picking by project is the only way they can.
#
# Headed, where dejima-attach's rows are not: there the coloured state word
# says what each column is, and vertical space on a phone is the scarce
# thing. Here the columns are two names and a number, which say nothing about
# themselves.
pick_container() {
local i
tui_banner 'running sessions'
# The attach column is absent, not blank, when no session has a number -
# remote access off is the ordinary case, and a labelled empty column
# reads as something that failed.
if [ "$CT_HAS_ATTACH" -eq 1 ]; then
printf ' %s%-*s %-*s %s%s\n' "$C_SUBTLE" \
"$CT_W_PROJECT" "project" "$CT_W_NAME" "container" "attach" "$C_OFF"
else
printf ' %s%-*s %s%s\n' "$C_SUBTLE" \
"$CT_W_PROJECT" "project" "container" "$C_OFF"
fi
for i in "${!CT_IDS[@]}"; do
printf ' %s%2d%s %s%-*s%s %s%-*s%s %s%s%s\n' \
"$C_WARN" "$((i + 1))" "$C_OFF" \
"$C_BOLD$C_TEXT" "$CT_W_PROJECT" "${CT_PROJECTS[$i]}" "$C_OFF" \
"$C_LINK" "$CT_W_NAME" "${CT_NAMES[$i]}" "$C_OFF" \
"$C_MUTED" "${CT_ATTACH[$i]}" "$C_OFF"
done
tui_pick "${#CT_IDS[@]}" "allow $domain in"
container="${CT_NAMES[$((TUI_CHOICE - 1))]}"
}
session_wanted=1
if [ -n "$project" ] || [ "$global" -eq 1 ]; then
[ -n "$container" ] || session_wanted=0
@@ -177,15 +242,30 @@ if [ "$session_wanted" -eq 1 ]; then
echo "No running dejima container found." >&2
exit 1
elif [ "$count" -gt 1 ]; then
echo "Multiple running dejima containers found; specify one with --container:" >&2
echo "" >&2
describe_containers "$containers" >&2
echo "" >&2
echo "(a sandboxed agent's own container ID is printed in its startup context," >&2
echo "or by 'cat /etc/hostname' inside that sandbox - don't let it guess one)" >&2
exit 1
collect_containers "$containers"
# A person at a terminal gets to choose; anything else gets the
# list and the flag. Both ends are checked because the prompt is
# useless without somewhere to draw it and something to read the
# answer from, and half of either is how a script ends up hung on
# a read nobody can answer.
if [ -t 0 ] && [ -t 1 ]; then
pick_container
else
echo "Multiple running dejima containers found; specify one with --container:" >&2
echo "" >&2
describe_containers >&2
echo "" >&2
echo "(a sandboxed agent's own container ID is printed in its startup context," >&2
echo "or by 'cat /etc/hostname' inside that sandbox - don't let it guess one)" >&2
exit 1
fi
fi
container="${containers%%$'\t'*}"
# Only when nothing above chose one. This line used to be reachable
# only in the single-container case, because the multi-container
# branch exited - now that the picker returns from it, an
# unconditional assignment here would silently discard the choice and
# act on whichever session docker happened to list first.
[ -n "$container" ] || container="${containers%%$'\t'*}"
fi
# The DNS proxy (dns-proxy.sh) NXDOMAINs anything not in its allowlist, so
+12 -76
View File
@@ -57,55 +57,20 @@ SESSIONS_DIR="$STATE_DIR/sessions"
# gruvbox dark, every value upstream's and tagged with its color.table row:
# https://github.com/gruvbox-community/gruvbox-contrib/blob/master/color.table
# The names are carried so a wrong hex is catchable by reading.
#
# Truecolor only: a terminal without it, or tmux without `Tc`, approximates
# 38;2 to its nearest colour rather than printing the escape, so the fallback
# already exists one layer down.
#
# Empty unless stdout is a colour-capable terminal, which keeps --list a
# machine interface (ttyd feeds run ids back in) and is also how the bats
# suite reads this script.
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-dumb}" != dumb ]; then
C_OFF=$'\033[0m'; C_BOLD=$'\033[1m'
C_BADGE=$'\033[38;2;254;128;25m' # bright_orange #fe8019, the wordmark
C_TEXT=$'\033[38;2;235;219;178m' # light1 #ebdbb2
C_MUTED=$'\033[38;2;146;131;116m' # gray_245 #928374
C_SUBTLE=$'\033[38;2;124;111;100m' # dark4 #7c6f64
C_RULE=$'\033[38;2;80;73;69m' # dark2 #504945, separator
C_OK=$'\033[38;2;184;187;38m' # bright_green #b8bb26, success
C_WARN=$'\033[38;2;250;189;47m' # bright_yellow #fabd2f, warning
C_ERR=$'\033[38;2;251;73;52m' # bright_red #fb4934, error
C_LINK=$'\033[38;2;131;165;152m' # bright_blue #83a598
GLYPH_BAR='▏'; GLYPH_DOT='●'
# A literal rather than a repeat of GLYPH_RULE: `tr` is byte-oriented and
# would shred a 3-byte box-drawing character, and every alternative costs
# a loop or a subshell to draw a fixed line. Both are the width of the
# heading they sit under ("dejima | live sessions"), and are next to it so
# they stay that way.
RULE_LINE='──────────────────────'
else
C_OFF=""; C_BOLD=""; C_BADGE=""; C_TEXT=""; C_MUTED=""; C_SUBTLE=""
C_RULE=""; C_OK=""; C_WARN=""; C_ERR=""; C_LINK=""
# Glyphs ride the same switch rather than a separate UTF-8 probe. Not
# because no-colour implies no-UTF-8, but because the two terminals this
# has to stay readable on - a $TERM the host has no entry for, and a pipe -
# are exactly the ones that mangle box drawing, and one flag is easier to
# reason about than two.
GLYPH_BAR='|'; GLYPH_DOT='*'
RULE_LINE='----------------------'
fi
# The palette, the heading and the prompt come from the shared library: this
# and dejima-allow both ask an operator to pick a session, and two copies of
# that would drift apart on the terminal an operator is actually holding.
# shellcheck source=dejima-tui-lib.sh
source "$SCRIPT_DIR/dejima-tui-lib.sh"
# Narrow deliberately. The phone is this script's main front door, and the
# rule underlines the heading instead of spanning the table because the
# table's width follows the longest project name - a rule that chased it would
# wrap on a phone exactly when the rows did.
banner() {
printf '\n %s%sdejima%s %s%s%s %slive sessions%s\n' \
"$C_BOLD" "$C_BADGE" "$C_OFF" "$C_SUBTLE" "$GLYPH_BAR" "$C_OFF" \
"$C_MUTED" "$C_OFF"
printf ' %s%s%s\n\n' "$C_RULE" "$RULE_LINE" "$C_OFF"
}
# Cleared first: this is a front door, and it is navigated to rather than
# scrolled to. dejima-allow's picker shares the banner and deliberately not
# this - see tui_clear.
banner() { tui_clear; tui_banner 'live sessions'; }
# Coloured by meaning, not by name: attachable is the one an operator wants to
# find, and stale/no-tmux are the two that will refuse if picked.
@@ -263,7 +228,7 @@ list_sessions() {
# ever race it. Removing it under a live session is not a leak
# being tidied but a session being broken - Docker recreates the
# bind source root-owned, hooks/dejima-notify.sh then fails on
# every Notification and Stop for the rest of the run, and nothing
# every notification for the rest of the run, and nothing
# rewrites the registry entry to make the session visible again.
continue
elif [ "$rc" -ne 0 ]; then
@@ -438,37 +403,8 @@ picker() {
"$sc" "$w_state" "${states[$i]}" "$C_OFF" \
"$C_SUBTLE" "${containers[$i]}" "$C_OFF"
done
# Numbered rather than arrow-driven: phone soft keyboards handle digits
# far better than arrow keys, and this is the phone's entry point.
printf '\n %sattach to%s %s[1-%d, q to quit]%s: ' \
"$C_MUTED" "$C_OFF" "$C_TEXT" "${#ids[@]}" "$C_OFF"
# `|| exit 0`, because a read that cannot happen is not a refusal: with no
# controlling terminal (`ssh -T`, or an SSH app that requests no pty) this
# returns nonzero immediately, and exiting 1 with nothing printed reads as
# a broken front door. attach() reasons about the same case explicitly.
read -r choice || exit 0
case "$choice" in
q|Q|'') exit 0 ;;
*[!0-9]*) echo "Not a number." >&2; exit 1 ;;
esac
# Normalized to base 10 once, here, because the range check and the
# subscript below do not read a number the same way: `[` is POSIX test and
# decimal-only, while $(( )) reads a leading zero as OCTAL. Without this,
# "010" - which a phone keypad makes easy to type - passes the check as 10
# and indexes as 8, and the caller is attached to a DIFFERENT session than
# the one they picked, with nothing printed to say so.
#
# It fixes the two ugly shapes as well. "09" is a value here rather than
# the "invalid octal constant" arithmetic error it is to $(( )); and a
# number past int64 wraps to one the range check can answer for, where
# `[ 99999999999999999999999 -gt 11 ]` instead exits 2 with "integer
# expression expected" - which makes the `if` false, so "Out of range."
# is never printed and the subscript below dies on an unbound element.
choice="$((10#$choice))"
if [ "$choice" -lt 1 ] || [ "$choice" -gt "${#ids[@]}" ]; then
echo "Out of range." >&2
exit 1
fi
tui_pick "${#ids[@]}" 'attach to'
choice="$TUI_CHOICE"
attach "${ids[$((choice - 1))]}"
}
+8 -12
View File
@@ -792,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
@@ -974,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"
+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() {
+16 -2
View File
@@ -45,6 +45,8 @@ 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
@@ -417,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
}
@@ -525,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
+25 -6
View File
@@ -234,6 +234,15 @@ KEY, DIM, OK, RESET = (os.environ.get(k, "") for k in ("C_KEY", "C_DIM", "C_OK",
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:
@@ -261,11 +270,11 @@ for i, path in enumerate(sys.argv[2:], 1):
else:
state = DIM + "aged out" + RESET
row = ROW % (KEY + "%4d" % i + RESET,
h.get("at", "")[:16].replace("T", " ") or "?",
h.get("trigger", "?")[:6], ctx,
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 + os.path.basename(os.path.dirname(path))[:30] + RESET
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' \
@@ -291,9 +300,15 @@ search)
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" "$(basename "$f")" "$C_RESET"
grep -inE --color=$gcolor -- "$pattern" "$f" 2>/dev/null \
"$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
@@ -329,7 +344,11 @@ show)
# editor, and neither must a test.
ed="${VISUAL:-${EDITOR:-}}"
if [ -z "$ed" ] || [ ! -t 1 ] || [ ! -t 0 ]; then
cat "$file"
# 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
+46 -2
View File
@@ -83,11 +83,36 @@ fi
# Refusing rather than sanitizing, because the config is operator-written:
# a value that can't be used as intended means they got something wrong, and
# this is the one place they say where their phone notifications go.
#
# The value is not echoed back whole. An ntfy server that wants credentials
# takes them in the URL - https://<user>:<token>@<host>/<topic>, which
# docs/remote-access.md documents - and a misconfiguration should not be the
# thing that writes that token into the journal, where it stays.
redacted_url() {
case "$1" in
*://*@*) printf '%s://<redacted>@%s' "${1%%://*}" "${1#*@}" ;;
*) printf '%s' "$1" ;;
esac
}
require_url() {
case "$2" in
http://*|https://*) ;;
*)
echo "$1 in $(config_path) must be an http:// or https:// URL: $2" >&2
echo "$1 in $(config_path) must be an http:// or https:// URL: $(redacted_url "$2")" >&2
exit 1
;;
esac
# The URL is handed to curl through a config file on stdin rather than as
# an argument (see the push below), and that format is line-oriented with
# its own quoting: a newline in the value would start a second directive,
# and a quote or a backslash would end or escape the string. The `url`
# config type's own character set already excludes all three, so this
# refuses a value that arrived some other way rather than sanitizing one
# that did - the same reading as the scheme check above.
case "$2" in
*[!A-Za-z0-9:/._~?=%@+-]*)
echo "$1 in $(config_path) has a character dejima will not send: $(redacted_url "$2")" >&2
exit 1
;;
esac
@@ -168,12 +193,31 @@ push() {
# stdout is discarded (ntfy answers with a JSON receipt); stderr is not.
# -sS exists precisely to keep transport errors on stderr, and under a
# systemd user unit that is the journal, which is where they belong.
#
# The URL goes in on stdin, not in argv. It is the one value here that can
# carry a credential, and every argument of every process on this host is
# readable by any local account through /proc/<pid>/cmdline - so a token in
# argv is a token published to the machine for as long as the request runs,
# and `ps` is enough to read it. Nothing else on this line is secret: the
# title, the body and the click URL are all composed from host data.
#
# This also retires the `-K/path` hazard the scheme check above was half
# of: a URL that is not a URL can no longer be read as an option, because
# it is no longer in the option list at all. Both checks stay anyway, and
# the second one is now what keeps this format from gaining a directive.
# Redirected from a process substitution rather than piped into. `printf |
# curl` is a pipeline under `set -o pipefail`, so a curl that exits before
# draining stdin leaves printf holding a closed pipe and the pipeline
# reports 141 - a "push failed" line for a push that succeeded. A
# redirection is not a pipeline and has no such status to inherit. It also
# keeps `--config -` in argv rather than a /dev/fd path, which is a fixed
# string a test can compare.
curl -fsS --max-time 10 --retry 2 \
-H "Title: $title" \
-H "Priority: default" \
"${click[@]}" \
-d "$body" \
-- "$NTFY_URL" >/dev/null \
--config - >/dev/null < <(printf 'url = "%s"\n' "$NTFY_URL") \
|| echo "dejima-notify-watch: push failed for $run_id/$event" >&2
}
+225 -25
View File
@@ -151,6 +151,7 @@ issue_command ?command
merge_block ?command
repo_flags ?list:flag
repo_env ?identifier
repo_paths ?list:repopath
identity_keys ?list:identifier
disclose ?disclosures
keys ?list:keyspec
@@ -183,7 +184,7 @@ EOF
requires ?list:identifier
push bool on
blocked_branches ?list:identifier
block_merge bool off
block_merge bool on
requires_worktree bool off
autonomy enum:commit|push-branch|open-pr commit
briefing ?relpath
@@ -303,6 +304,22 @@ _provider_valid_value() {
# subcommand or a value.
[[ "$value" =~ ^--?[A-Za-z0-9][A-Za-z0-9-]*$ ]]
;;
repopath)
# A repository named in a path rather than in a flag:
# `<subcommand>:<first path segment>`, as in `api:repos` for
# `gh api repos/<owner>/<name>/...`.
#
# Scoped to a subcommand because the alternative is comparing every
# positional argument that happens to contain two slashes, and a
# false refusal on an issue title is worse than the miss - this is
# a fast-fail, so a wrong yes costs an honest agent a working
# command. The guard applies it only when the subcommand is argv's
# first word.
case "$value" in *:*) ;; *) return 1 ;; esac
local sub="${value%%:*}" seg="${value#*:}"
[[ "$sub" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]] || return 1
[[ "$seg" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]]
;;
relpath)
# Joined to a provider directory, so no absolute paths and no
# traversal - a manifest may only name a file it ships.
@@ -751,23 +768,198 @@ identity_exists() {
return 1
}
# The credential keys the selected identity leaves to the section beneath it,
# as "<section> <key>" lines, empty when there are none.
#
# Only the selected forge's, because refusing over a credential belonging to a
# forge this launch does not use would be refusing over a value nothing reads.
#
# "Set" means written in the config file, not resolved from a schema default.
# A forge whose credential key carries a default still hands an identity that
# names none the machine's own path, and that is not caught here - see
# docs/configuration.md, which says so rather than leaving it to be found.
identity_borrowed_credentials() {
local name="$1" forge="$2" key
if ! config_is_set "identity.$name" ssh_key && config_is_set core ssh_key; then
printf 'core ssh_key
'
fi
for key in $(manifest_get forge "$forge" identity_keys); do
if ! config_is_set "identity.$name" "$key" \
&& config_is_set "forge.$forge" "$key"; then
printf 'forge.%s %s
' "$forge" "$key"
fi
done
}
# Whether the selected identity, if there is one, is one a session can act
# under without borrowing from anyone.
#
# Two refusals, one reason. A name with no section resolves to nothing and
# falls through to the machine's own credentials; a section that names only
# some of them falls through for the rest. Either way a session launched as
# this identity acts under another account, and nothing says so. Pushing under
# the wrong key fails loudly; pushing under the default key when you asked for
# another one does not fail at all, which is the direction worth being careful
# about.
#
# One function rather than a check at each caller: bin/dejima's --print-config
# needs the same answer, because every value it prints resolves THROUGH the
# identity and printing this machine's credentials under an identity's name is
# the confusion the refusal exists to prevent.
#
# Guarded on the config existing rather than swallowing config_get's stderr:
# with no file at all it says so, and a selection validated without one is a
# legitimate case this must stay silent for.
# The identity in force: [core] identity when it names one, and otherwise the
# reserved `default` section when the config has one.
#
# `default` is what [core] ssh_key was without a name. Giving it one is what
# lets a host state its ordinary account the same way it states any other,
# with a git name and a forge credential beside the key rather than those
# coming from three different places.
#
# Empty when neither applies, which is the pre-identity config and still a
# supported one.
# Assigns rather than prints, and every caller uses this form. identity_get
# runs on every config_get through bin/dejima's wrapper, and `$(...)` around
# an answer this cheap costs a fork per read - which is most of what a launch
# spends on config.
identity_selected_into() {
local __var="$1" __name=""
if config_exists; then
__name="$(config_get core identity)" || __name=""
if [ -z "$__name" ] && _identity_section_present default; then
__name=default
fi
fi
printf -v "$__var" '%s' "$__name"
}
identity_selected() {
local _n
identity_selected_into _n
printf '%s' "$_n"
}
# Whether a section has any key in the parsed config, read straight off the
# parse rather than through identity_exists.
#
# identity_exists walks every forge's manifest to decide which keys count, and
# bin/dejima's config_get wrapper reaches this on every single read - which
# turned a --print-config from three seconds into forty. It does not need the
# manifest: the parser refuses an unknown key outright, so anything present
# under identity.<name> in a config that validated is already one of the keys
# identity_exists would have accepted.
_identity_section_present() {
local name="$1" k
config_exists || return 1
config_validate || return 1
for k in "${!_CFG_SEEN[@]}"; do
case "$k" in "identity.$name".*) return 0 ;; esac
done
return 1
}
identity_validate() {
local forge="$1" name="" borrowed section key
name="$(identity_selected)"
# [core] ssh_key is the default identity under its old name, so a config
# holding both says the same thing twice and dejima would have to pick.
# Refused rather than ranked: which key a session pushed with is not a
# thing to work out from precedence rules.
if identity_exists default && config_is_set core ssh_key; then
echo "[core] ssh_key is set and so is [identity.default], which is the same" >&2
echo "thing under two names - the key a session gets when none is asked for." >&2
echo "Move it into [identity.default] and remove [core] ssh_key." >&2
echo "See docs/configuration.md, \"Identities\"." >&2
return 1
fi
# Deprecated, not removed: it is how every config written before
# identities existed says this, and those still work. Warned on every
# launch that has it set, not only the ones using it - a config with
# [core] ssh_key and a named identity is one migration away from the
# refusal above, and finding that out on the launch that breaks is worse
# than being told now.
#
# Every launch rather than once, because there is nowhere to record
# "once" that a session could not reach, and a deprecation nobody sees
# until removal is not a deprecation.
if config_is_set core ssh_key; then
printf ' %s[core] ssh_key is deprecated%s - it is [identity.default] ssh_key\n' \
"${C_WARN:-}" "${C_RESET:-}" >&2
printf ' under its old name.\n' >&2
if [ -n "$name" ]; then
printf ' This launch runs as %s%s%s, so it is not the key being used here.\n' \
"${C_KEY:-}" "$name" "${C_RESET:-}" >&2
else
printf ' Nothing else names an account, so it is the key this launch uses.\n' >&2
fi
printf ' Move it into an [identity.default] section, which can carry the git\n' >&2
printf ' name and the forge credential belonging to that same account:\n\n' >&2
# The value in the file, not through config_get: in this process
# config_get is bin/dejima's wrapper, so with a named identity in
# force it answers with THAT identity's key - and printing it as the
# thing to move would name a key the operator is not being asked to
# touch.
printf ' [identity.default]\n ssh_key = %s\n\n' \
"$(_config_get_from_file core ssh_key)" >&2
fi
[ -n "$name" ] || return 0
if ! identity_exists "$name"; then
echo "[core] identity is '$name', and no [identity.$name] section sets" >&2
echo "anything in $(config_path). An identity that resolves to nothing would" >&2
echo "quietly use this machine's own credentials instead - refusing rather than" >&2
echo "doing that. See docs/configuration.md, \"Identities\"." >&2
return 1
fi
borrowed="$(identity_borrowed_credentials "$name" "$forge")"
[ -n "$borrowed" ] || return 0
echo "[identity.$name] leaves a credential to the section beneath it, so a" >&2
echo "session launched as '$name' would act under whichever account owns:" >&2
while IFS=$'\t' read -r section key; do
[ -n "$key" ] || continue
echo " [$section] $key" >&2
done <<< "$borrowed"
echo "An identity is how one session's account is kept separate from another's;" >&2
echo "set each of those under [identity.$name], or unset it in the section named." >&2
echo "See docs/configuration.md, \"Identities\"." >&2
return 1
}
identity_get() {
local section="$1" key="$2" name value pname
[ "$section.$key" != "core.identity" ] || return 1
name="$(config_get core identity)" || return 1
[ -n "$name" ] || return 1
# Which keys an identity could own, before working out which identity is
# in force: this runs on every config_get, and all but a handful of keys
# are ones no identity can answer for. Resolving the identity first meant
# doing that work ~40 times a launch to discard it ~35 of those times.
case "$section" in
core) [ "$key" = ssh_key ] || return 1 ;;
forge.*) _forge_owns_identity_key "${section#forge.}" "$key" || return 1 ;;
identity.*) return 1 ;;
*) return 1 ;;
core) [ "$key" = ssh_key ] || return 1 ;;
forge.*) ;;
*) return 1 ;;
esac
identity_selected_into name
[ -n "$name" ] || return 1
# The manifest lookup last, for the same reason: it forks, and by here
# there is an identity that might actually answer.
case "$section" in
forge.*) _forge_owns_identity_key "${section#forge.}" "$key" || return 1 ;;
esac
value="$(config_get "identity.$name" "$key")" || return 1
[ -n "$value" ] || return 1
printf '%s' "$value"
}
# Whether one forge declared this key as a person's rather than a project's.
# Whether one forge declared this key as naming an account rather than the
# forge itself - a credential a session acts under, not a setting like an API
# URL that every session on this host shares.
_forge_owns_identity_key() {
local forge="$1" key="$2" k
for k in $(manifest_get forge "$forge" identity_keys); do
@@ -782,7 +974,7 @@ _forge_owns_identity_key() {
# globally would be a new way to get the same answer.
identity_git_field() {
local field="$1" name
name="$(config_get core identity)" || return 1
name="$(identity_selected)" || return 1
[ -n "$name" ] || return 1
config_get "identity.$name" "$field"
}
@@ -862,22 +1054,7 @@ provider_validate_selection() {
# quietly absent, because an operator who has them everywhere else would
# otherwise have to read a manifest to find out they are gone here.
#
# An identity that names nothing is refused rather than ignored. Falling
# through to the machine's own credentials would mean pushing under the
# wrong account with nothing saying so, which is the failure this feature
# exists to remove rather than one it may introduce.
# Guarded on the config existing rather than swallowing config_get's
# stderr: with no file at all it says so, and a selection validated
# without one is a legitimate case this must stay silent for.
local _identity=""
config_exists && { _identity="$(config_get core identity)" || _identity=""; }
if [ -n "$_identity" ] && ! identity_exists "$_identity"; then
echo "[core] identity is '$_identity', and no [identity.$_identity] section sets" >&2
echo "anything in $(config_path). An identity that resolves to nothing would" >&2
echo "quietly use this machine's own credentials instead - refusing rather than" >&2
echo "doing that. See docs/configuration.md, \"Identities\"." >&2
return 1
fi
identity_validate "$forge" || return 1
# A name outside this set is a hard error rather than an unknown guard
# treated as absent: a typo would otherwise silently turn a guard off,
@@ -1541,6 +1718,22 @@ tool_validate() {
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
@@ -1602,6 +1795,13 @@ briefing_compose() {
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
+25
View File
@@ -178,10 +178,35 @@ focus_opt=()
colors_opt=()
[ -z "$button_colors" ] || colors_opt=(-t "buttonColors=$button_colors")
# The sixteen ANSI slots xterm.js draws with, which without this are its own
# defaults - white on black, a bright red, a magenta. dejima's own output
# reaches a terminal in indexed colour precisely so the terminal's theme fills
# those slots (see dejima-color-lib.sh), and here dejima IS the terminal: the
# browser has no gruvbox to inherit, so the front door showed the stock
# palette while the same session over SSH showed the operator's.
#
# So the exact hexes belong here, by the same rule as web/src/page.css: they
# are the two surfaces where dejima owns the background. A static test keeps
# the values they share in step.
#
# Upstream gruvbox dark, by its own names. `background` and `foreground` are
# bg0/light1, which is what a gruvbox terminal is set to.
theme_opt=(-t 'theme={
"background":"#282828","foreground":"#ebdbb2",
"cursor":"#ebdbb2","cursorAccent":"#282828",
"selectionBackground":"#504945",
"black":"#282828","red":"#cc241d","green":"#98971a","yellow":"#d79921",
"blue":"#458588","magenta":"#b16286","cyan":"#689d6a","white":"#a89984",
"brightBlack":"#928374","brightRed":"#fb4934","brightGreen":"#b8bb26",
"brightYellow":"#fabd2f","brightBlue":"#83a598","brightMagenta":"#d3869b",
"brightCyan":"#8ec07c","brightWhite":"#ebdbb2"
}')
exec ttyd --interface 127.0.0.1 --port "$port" --check-origin --url-arg --writable \
--index "$index_html" \
${font_opt[@]+"${font_opt[@]}"} \
${prefix_opt[@]+"${prefix_opt[@]}"} \
${focus_opt[@]+"${focus_opt[@]}"} \
${colors_opt[@]+"${colors_opt[@]}"} \
"${theme_opt[@]}" \
"$SCRIPT_DIR/dejima-attach"
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env bash
# The small terminal interface dejima-attach and dejima-allow share.
#
# Two tools ask an operator to pick one running session out of several, and
# both had to be readable on whatever terminal the operator happened to be
# holding. This is the part that does not depend on which of them is asking:
# the palette, the heading, and the prompt.
#
# What is NOT here is the row. Each tool lists a different thing from a
# different source - the registry of remote-enabled sessions on one side,
# `docker ps` on the other - so the columns are the tool's own, measured by
# the tool against its own data.
#
# Sourced, never run.
# The palette is dejima-color-lib.sh's, and there is only one of it. This file
# used to carry a second, in hardcoded gruvbox truecolor, which collided with
# that one on C_OK and C_WARN and broke on any background dejima does not own -
# see the comment there for the contrast measurements that decided it.
#
# Sourced and initialised here rather than left to the caller, because both
# callers draw to stdout and this file's functions assume the names exist.
# shellcheck source=dejima-color-lib.sh
source "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/dejima-color-lib.sh"
color_init 1
# The rule under the heading, the width of the heading itself. Built rather
# than written out because the heading is now the caller's: two literals kept
# next to two headings stay in step only while nobody adds a third.
#
# A loop rather than `tr`, which is byte-oriented and would shred a 3-byte box
# drawing character into three copies of nothing.
tui_rule() {
local n="$1" i out=""
for ((i = 0; i < n; i++)); do out+="$GLYPH_RULE"; done
printf '%s' "$out"
}
# "dejima | <subtitle>", underlined to its own width.
# A blank screen to draw a front door on, scrollback included.
#
# The picker is reached by navigating to it - the web toolbar's SESS button is
# a full page load - and it drew on top of whatever the previous session had
# left on screen, so the rows arrived interleaved with the tail of the session
# just left. Over SSH it was invisible, because a fresh connection to the
# forced command usually starts on an empty screen (#213).
#
# 3J as well as 2J: without it a swipe up on a phone lands in the middle of
# the previous session's output, which is the same confusion one gesture away.
#
# Its own function rather than part of tui_banner, because dejima-allow's
# picker must NOT do this: that one is a prompt inside a shell the operator is
# already using, and wiping their scrollback to ask one question is worse than
# the interleaving this fixes.
#
# Rides the same switch as the colour and the glyphs, for the reason stated
# above them: one flag is easier to reason about than two, and the terminals
# that mangle box drawing are the ones these bytes should not reach either. So
# NO_COLOR turns this off as well - a reading of it this file has already
# committed to, and the alternative is a picker that answers to two different
# ideas of "plain".
tui_clear() {
[ -n "$C_OFF" ] || return 0
printf '\033[H\033[2J\033[3J'
}
tui_banner() {
local subtitle="$1"
printf '\n %s%sdejima%s %s%s%s %s%s%s\n' \
"$C_BOLD" "$C_BADGE" "$C_OFF" "$C_SUBTLE" "$GLYPH_BAR" "$C_OFF" \
"$C_MUTED" "$subtitle" "$C_OFF"
# 6 for the wordmark, 3 for the bar and the spaces either side.
printf ' %s%s%s\n\n' "$C_RULE" "$(tui_rule $((9 + ${#subtitle})))" "$C_OFF"
}
# Asks for one of 1..$1 and leaves it in TUI_CHOICE. $2 is the verb the
# prompt opens with. Exits the caller outright on anything meaning "not this
# time" - q, an empty line, or no terminal to read from.
#
# The answer comes back in a variable rather than on stdout because those
# exits have to be the caller's. In `$(tui_pick ...)` the function runs in a
# subshell, where `exit 0` ends the subshell and hands the caller an empty
# string it would then use as an index.
#
# Numbered rather than arrow-driven: phone soft keyboards handle digits far
# better than arrow keys, and dejima-attach is the phone's entry point.
TUI_CHOICE=""
tui_pick() {
local count="$1" verb="$2" choice
TUI_CHOICE=""
printf '\n %s%s%s %s[1-%d, q to quit]%s: ' \
"$C_MUTED" "$verb" "$C_OFF" "$C_TEXT" "$count" "$C_OFF"
# `|| exit 0`, because a read that cannot happen is not a refusal: with no
# controlling terminal (`ssh -T`, or an SSH app that requests no pty) this
# returns nonzero immediately, and exiting 1 with nothing printed reads as
# a broken front door.
read -r choice || exit 0
case "$choice" in
q|Q|'') exit 0 ;;
*[!0-9]*) echo "Not a number." >&2; exit 1 ;;
esac
# Normalized to base 10 once, here, because the range check and the
# subscript the caller does with this do not read a number the same way:
# `[` is POSIX test and decimal-only, while $(( )) reads a leading zero as
# OCTAL. Without this, "010" - which a phone keypad makes easy to type -
# passes the check as 10 and indexes as 8, and the caller acts on a
# DIFFERENT session than the one picked, with nothing printed to say so.
#
# It fixes the two ugly shapes as well. "09" is a value here rather than
# the "invalid octal constant" arithmetic error it is to $(( )); and a
# number past int64 wraps to one the range check can answer for, where
# `[ 99999999999999999999999 -gt 11 ]` instead exits 2 with "integer
# expression expected" - which makes the `if` false, so "Out of range."
# is never printed and the caller dies on an unbound element.
choice="$((10#$choice))"
if [ "$choice" -lt 1 ] || [ "$choice" -gt "$count" ]; then
echo "Out of range." >&2
exit 1
fi
TUI_CHOICE="$choice"
}
+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
+12 -4
View File
@@ -10,8 +10,17 @@
# reason this is code is the pairing in the second block, which resolves one
# directory tree through the filenames of another.
# Registers the waiting/done notification handlers into dejima's settings
# layer, in the shape Claude Code reads them.
# Registers the `done` notification handler into dejima's settings layer, in
# the shape Claude Code reads them.
#
# Stop only, though the hook and the host-side watcher both understand
# `waiting` as well. Claude Code raises Notification when it needs permission
# to run something and when the prompt has sat idle, and a dejima session runs
# with permission prompts off - so the first never happens, and what is left
# is a timer that expires shortly after the Stop it follows. Registering both
# put two pushes on the operator's phone for one event, a minute apart, the
# second saying nothing the first had not. `waiting` stays in the enum for a
# harness that can say it without having stopped first.
#
# Claude Code merges hook entries across settings layers rather than letting
# the higher one replace the lower, so these are added to whatever the
@@ -25,8 +34,7 @@
harness_register_notify() {
local settings="$1" tmp
tmp="$(mktemp "${TMPDIR:-/tmp}/dejima-settings-remote.XXXXXX.json")"
if jq '.hooks.Notification = ((.hooks.Notification // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/dejima-notify.sh waiting"}]}])
| .hooks.Stop = ((.hooks.Stop // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/dejima-notify.sh done"}]}])' \
if jq '.hooks.Stop = ((.hooks.Stop // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/dejima-notify.sh done"}]}])' \
"$settings" > "$tmp"; then
mv "$tmp" "$settings"
else
+7 -1
View File
@@ -34,7 +34,13 @@ services:
# $SANDBOX_IMAGE lets bin/dejima point a run at a derived,
# per-project image instead, without this build ever producing one
# under that name itself - see bin/dejima.
image: ${SANDBOX_IMAGE:-dejima:${SANDBOX_USER:-node}}
# localhost/ so this can never resolve to Docker Hub: Docker reads a first
# component containing a dot or colon, or exactly "localhost", as a
# registry host, and a bare `dejima:node` is `docker.io/library/dejima:node`
# to anything that pushes (#53). bin/dejima holds the same prefix in
# SANDBOX_IMAGE_REPO and passes it in; this default is for a compose
# invocation by hand.
image: ${SANDBOX_IMAGE:-localhost/dejima:${SANDBOX_USER:-node}}
# Run tini as PID 1 so orphaned processes get reaped. Without it PID 1 is
# the session's own command (claude, or nvim under SANDBOX_NVIM_CONFIG),
# which never reaps: every process-substitution child the test suite and
+106 -18
View File
@@ -28,8 +28,10 @@
# to a forge here.
# DEJIMA_FORGE_REPO the "<owner>/<name>" of the repository the session
# was launched against, and DEJIMA_FORGE_REPO_FLAGS /
# DEJIMA_FORGE_REPO_ENV the spellings this forge's CLI
# accepts for naming a different one.
# DEJIMA_FORGE_REPO_ENV / DEJIMA_FORGE_REPO_PATHS the
# spellings this forge's CLI accepts for naming a
# different one: a flag, an environment variable, and a
# path segment under a named subcommand.
# DEJIMA_FORGE_REAL_DIR where the real CLIs live, behind the shadow.
set -u
@@ -44,7 +46,8 @@ fi
# Outside a dejima container, or with nothing to do, this is a pass-through.
if [ -z "${DEJIMA:-}" ] ||
{ [ -z "${DEJIMA_MERGE_BLOCK:-}" ] && [ -z "${DEJIMA_DISCLOSE:-}" ] &&
[ -z "${DEJIMA_FORGE_REPO_FLAGS:-}" ] && [ -z "${DEJIMA_FORGE_REPO_ENV:-}" ]; }; then
[ -z "${DEJIMA_FORGE_REPO_FLAGS:-}" ] && [ -z "${DEJIMA_FORGE_REPO_ENV:-}" ] &&
[ -z "${DEJIMA_FORGE_REPO_PATHS:-}" ]; }; then
exec "$real" "$@"
fi
@@ -120,7 +123,8 @@ fi
# Refused rather than skipped when the launch resolved no repository at all: a
# workspace with no origin has nothing for --repo to legitimately name, and
# "cannot confirm" is not a reason to let it through.
if [ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ] || [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ]; then
if [ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ] || [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ] ||
[ -n "${DEJIMA_FORGE_REPO_PATHS:-}" ]; then
refuse_repo() {
echo "Blocked by dejima's forge guard: this session was launched against ${DEJIMA_FORGE_REPO:-no repository}, and $1 names '$2'. Act on the repository the sandbox mounts." >&2
exit 1
@@ -154,15 +158,33 @@ if [ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ] || [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ];
;;
"$flag"=*) value="${arg#*=}" ;;
*)
# A one-dash flag may carry its value with no
# separator - `-Rowner/name` - which two-dash flags do
# not do.
# One-dash flags only; a two-dash flag carries its
# value with `=` or as the next argument, both above.
case "$flag" in
--*) continue ;;
-?) ;;
*) continue ;;
esac
case "$arg" in "$flag"?*) value="${arg#"$flag"}" ;; *) continue ;; esac
# Short flags cluster, so the letter can sit anywhere
# in one: `-Rowner/name`, `-wR owner/name` and
# `-wRowner/name` all name a repository and none of
# them is `-R` as its own argument (#243). Split at the
# letter the way getopt does - what follows it in the
# same argument is the value, and nothing following it
# means the next argument is.
letter="${flag#-}"
# A cluster, not a value that happens to start with a
# dash: one dash, then the letter somewhere in a run of
# letters. Without the second check, `--search foo-R/x`
# style arguments would be mined for flag letters.
case "$arg" in -[!-]*) ;; *) continue ;; esac
rest="${arg#-}"
case "$rest" in *"$letter"*) ;; *) continue ;; esac
case "${rest%%"$letter"*}" in *[!A-Za-z]*) continue ;; esac
value="${rest#*"$letter"}"
if [ -z "$value" ]; then
[ "${#args_left[@]}" -gt 0 ] || continue
value="${args_left[0]}"
fi
;;
esac
[ -n "$value" ] || continue
@@ -170,6 +192,39 @@ if [ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ] || [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ];
done
done
fi
# A repository named in a path rather than in a flag - `gh api
# repos/<owner>/<name>/issues` reaches any repository the token does,
# without a --repo anywhere in it (#243).
#
# Only when the declared subcommand is argv's first word. The alternative
# is inspecting every positional that contains two slashes, which would
# refuse `gh issue create --title repos/a/b` - and a false refusal costs an
# honest agent a working command, which is the wrong way for a fast-fail to
# be wrong.
if [ -n "${DEJIMA_FORGE_REPO_PATHS:-}" ] && [ "$#" -gt 0 ]; then
read -ra repo_paths <<< "$DEJIMA_FORGE_REPO_PATHS"
for spec in "${repo_paths[@]}"; do
[ "$1" = "${spec%%:*}" ] || continue
segment="${spec#*:}"
for arg in "$@"; do
case "$arg" in -*) continue ;; esac
# gh substitutes {owner} and {repo} from the checkout, so a
# path carrying them names this repository by construction and
# comparing the literal braces would refuse it.
case "$arg" in *'{'*) continue ;; esac
path="${arg#/}"
case "$path" in "$segment"/*/*) ;; *) continue ;; esac
path="${path#"$segment"/}"
owner="${path%%/*}"
path="${path#*/}"
name="${path%%/*}"
[ -n "$owner" ] && [ -n "$name" ] || continue
same_repo "$owner/$name" \
|| refuse_repo "the path '$arg'" "$owner/$name"
done
done
fi
fi
# --- the authorship disclosure ---------------------------------------------
@@ -235,12 +290,49 @@ read_body_or_die() {
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
@@ -272,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
+211 -162
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
@@ -25,12 +25,14 @@ config file behind them.
| `[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`/`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] identity` | unset | which `[identity.<name>]` section supplies this project's credentials and git name; also settable per launch with `--identity`. A name with no section is refused rather than falling back |
| `[core] 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 |
@@ -48,7 +50,7 @@ config file behind them.
| `[mounts] read_write` | unset | host paths (colon-separated) mounted **read-write** at their own path: for directories meant to outlive the session; refused if they overlap anything dejima needs out of a session's reach |
| `[mounts] nvim_config` | unset | nvim config dir, used when `[core] command = nvim` |
| `[credentials] proxy` | `on` | host-side credential proxy; `off` mounts the real OAuth credential into the container directly |
| `[credentials] transport` | `tls` | set to `loopback` to fall back to a plaintext forwarder with an explicit `ANTHROPIC_BASE_URL`, keeping the credential out of the container but reinstating the base-URL fingerprint: for when certificate pinning breaks the default TLS path (see [security notes](docs/security.md)) |
| `[credentials] transport` | `tls` | set to `loopback` to fall back to a plaintext forwarder with an explicit `ANTHROPIC_BASE_URL`, keeping the credential out of the container but reinstating the base-URL fingerprint: for when certificate pinning breaks the default TLS path (see [security notes](security.md)) |
| `[logging] loki_url` | unset | ship DNS/firewall logs to a Loki endpoint; `--no-log` clears it for one launch, which silences everything downstream of it |
| `[epitaph] enabled` | `on` | keep the summary a harness writes when a session compacts; `--no-epitaph` skips it for one launch |
| `[epitaph] dir` | `~/.local/share/dejima/epitaphs` | where they are kept; never mounted into a container |
@@ -77,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 |
| --- | --- |
@@ -101,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
@@ -204,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
@@ -426,39 +410,78 @@ Dockerfile directly puts it outside that record.
## Identities
One machine, several accounts. A `[identity.<name>]` section groups the things
that belong to a **person** rather than to a project - the SSH key, the forge
token, the name on the commits - so that selecting an account is one word
rather than four edits.
**Several accounts on one machine, and none of them yours.** An
`[identity.<name>]` section groups what one session acts as - the SSH key, the
forge token, the name on the commits - so that pointing a launch at a
different account is one word rather than four edits.
The rest of these documents ask you to give a session an account of its own -
the README under [Using this responsibly](../README.md#using-this-responsibly),
and security.md's [Give the sandbox its own
identity](security.md#give-the-sandbox-its-own-identity). This is how you
have more than one of them, which you will: an account is scoped to what it
may reach, and one account scoped to everything any session might ever need
is the thing separate accounts existed to avoid.
So the sections are per **agent account**, not per person:
```ini
[identity.work]
ssh_key = ~/.ssh/id_work
user_name = A Name
user_email = name@work.example
token_file = ~/.config/gitea/work-token
# Pushes to the gitea forge, and can reach nothing on GitHub.
[identity.agent-gitea]
ssh_key = ~/.ssh/id_agent_gitea
user_name = dejima agent (gitea)
user_email = agent-gitea@example.invalid
token_file = ~/.config/gitea/agent-token
[identity.oss]
ssh_key = ~/.ssh/id_oss
user_email = name@personal.example
push_token_file = ~/.config/gh/oss-push
# A GitHub account, with push rights to the repositories this host works on.
[identity.agent-github]
ssh_key = ~/.ssh/id_agent_github
user_name = dejima agent (github)
user_email = agent-github@example.invalid
push_token_file = ~/.config/gh/agent-push
# A second GitHub account, for a repository the one above may not touch.
[identity.agent-vendor]
ssh_key = ~/.ssh/id_agent_vendor
user_name = dejima agent (vendor)
user_email = agent-vendor@example.invalid
push_token_file = ~/.config/gh/vendor-push
```
Select one with `[core] identity = work`, or per launch with
`--identity oss`.
Select one with `[core] identity = agent-gitea`, or per launch with
`--identity agent-github` - which is how one host runs a session against each
of these at the same time, each pushing as itself.
**The separation is the point.** Three accounts means three things you can
scope, revoke and read in a log independently: a session that turns out to
have been prompt-injected reached what its own account could reach, and
answering "what did it touch" does not start with "everything I can touch".
**What an identity may set** is deliberately narrow: `ssh_key`, `user_name`,
`user_email`, and whichever keys the forges declare as a person's. A forge
`user_email`, and whichever keys the forges declare as an account's. A forge
says which of its own keys those are (`identity_keys` in its manifest), so
`token_file` belongs to an identity on the gitea path and `push_token_file`
does on the GitHub one, and neither is a list core keeps its own copy of.
**A key an identity does not set falls through to the file.** The `oss`
identity above names no `token_file`, so `[forge.gitea] token_file` answers
for it. An identity is an override for what it names, not a replacement for
the section.
**`default` is the account a session gets when none is named.** With `[core]
identity` unset, an `[identity.default]` section is what a launch acts under -
the ordinary account, given a name so it can carry the git name and the forge
credential too.
**A name with no section is refused.** `[core] identity = wrok` does not
**`[core] ssh_key` is that key under its old name, and is deprecated.** A
config with no `[identity.default]` reads it as before; a config with both is
refused rather than ranked, because which key a session pushed with should not
depend on precedence rules. Every launch that sets it says so and prints the
section to move it into - including launches running as some other identity,
which are one `[identity.default]` away from that refusal.
**A credential does not fall through.** An identity that names some and leaves
the rest to the section beneath it is refused, naming each one: an identity is
how one session's account stays separate from another's. "Set" means written
in the file, so a forge key carrying a schema default - `[forge.gitea]
token_file` - still answers for an identity that names none.
**A name with no section is refused.** `[core] identity = agent-gihub` does not
quietly use this machine's own credentials - it stops the launch and says so.
Pushing under the wrong key fails loudly; pushing under your default key when
you asked for another one does not fail at all, which is the direction worth
@@ -468,10 +491,16 @@ being careful about.
the copy dejima already makes of your `~/.gitconfig`. Your real one is not
touched, which was already true and is what the copy exists for.
This is the half of per-project configuration that a project cannot carry: a
project's `.dejima/` is committed and shared with everyone working on it, and
a key path is yours and your machine's. A project can say which identity it
wants; only you can say where that identity's key lives.
**Selection is the host's, not the project's.** There is no `.dejima/` kind
for an identity and a project cannot name one: a project's `.dejima/` is
committed and shared with everyone working on it, and both the credential and
which account this host holds are that host's own. `[core] identity` and
`--identity` are the whole of it today.
A project-side layer would be a different thing than it sounds - not "this
project's credentials", which cannot be shared, but "this project wants
whichever account you have called X". That does not exist yet, and this
document will say so until it does.
## Headless git push without agent forwarding
@@ -501,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
@@ -611,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
+4 -1
View File
@@ -25,7 +25,7 @@ run them.
| `hooks/git-push-guard.sh` | `PreToolUse` hook: blocks pushes outside the allowed host(s)/branches |
| `hooks/deny-dangerous-bash.sh` | `PreToolUse` hook: blocks obviously destructive Bash commands |
| `hooks/merge-guard.sh` | `PreToolUse` hook: blocks the configured forge's merge command (`DEJIMA_MERGE_BLOCK`) in the sandbox: merging is a human decision |
| `hooks/dejima-notify.sh` | `Notification`/`Stop` hook: drops an empty `waiting`/`done` marker for a remote-enabled session - see [docs/remote-access.md](remote-access.md) |
| `hooks/dejima-notify.sh` | Drops an empty `waiting`/`done` marker for a remote-enabled session. Registered as a `Stop` hook only; `waiting` is there for a harness that can say it without having stopped - see [docs/remote-access.md](remote-access.md) |
| `bin/dejima-attach` | Lists live sessions and attaches to one; the shared core behind both remote front doors |
| `bin/dejima-audit` | Advisory exit-time diff of host-persistence surfaces (git config, `.claude/`, etc.). `dejima-audit show` / `dejima-audit ack` default to the enclosing repo |
| `bin/dejima-dep` | Review and approve project dependencies declared in `.dejima/dependencies` |
@@ -40,6 +40,9 @@ run them.
| `bin/dejima-project` | Review and approve project domains and dependencies from the host |
| `bin/dejima-project-lib.sh` | Shared library sourced by `bin/dejima-dep`, `bin/dejima-project`, `bin/dejima` and the remote-access scripts; not on PATH |
| `bin/dejima-conversation-ship` | Host-side: ships session transcripts to Loki when `[logging] conversation` is on. Reads them where they already are, so the session is not in the path of its own oversight log |
| `bin/dejima-color-lib.sh` | The one terminal palette, and the glyphs that ride the same switch. Indexed colour, not 24-bit: gruvbox is the brand and an ANSI slot **is** gruvbox on a gruvbox terminal, but hardcoding the hexes assumes a background dejima does not own. `web/src/page.css` is the exception, since the page sets its own. Not on PATH |
| `bin/dejima-tui-lib.sh` | The palette, heading and numbered prompt `dejima-attach` and `dejima-allow` share. Rows stay with each tool, which lists a different thing from a different source. Not on PATH |
| `bin/dejima-update-check` | Host-side: says at startup when this checkout is behind the branch it tracks. Reaches the remote by URL from outside the repository, so the checkout's own git config never names a command it runs |
| `bin/dejima-epitaph` | Host-side: keeps the summary a session leaves when its context is compacted away, in a store nothing mounts. `save` runs from `bin/dejima`'s exit trap; `list`/`search`/`show` are the operator's, and only ever read |
| `bin/dejima-harness-lib.sh` | Loads the selected harness's plugin and provides the `emit_*` functions it declares through; the guarded-store refusal runs in `emit_mount`. Not on PATH |
| `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` |
+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
+43 -8
View File
@@ -76,6 +76,7 @@ value never does:
| `export` | `NAME=key` or `NAME=@key` for the `env_export` forge key - see [Forge](#forge) below. |
| `template` | Prose that may contain `{{placeholder}}` markers; the placeholder names are checked against the briefing whitelist at composition time, not here. |
| `keyspec` | One element of a `keys` list - see [The `keys` field](#the-keys-field-a-providers-own-config-schema) below. |
| `repopath` | `<subcommand>:<first path segment>` for the `repo_paths` forge key - where a repository appears in a path rather than behind a flag. |
| `list:TYPE` | Space-separated (not colon-separated, unlike a config-file list) elements, each checked against `TYPE`. |
A leading `?` on any type (`?hostname`, `?list:identifier`, ...) makes the
@@ -123,9 +124,10 @@ operator can set, resolved the same way as any other config key
| `pr_command` | `?command` | (empty) | The PR-management command name the forge's briefing prose and `env_export`/`merge_block` refer to. |
| `issue_command` | `?command` | (empty) | The issue-management command name, same idea. |
| `merge_block` | `?command` | (empty) | The command prefix refused inside the sandbox - only when the workflow's `block_merge = on`. Enforced by `docker/forge-guard.sh`, which shadows the named CLI on PATH and compares argv, so it holds for a harness that cannot intercept a tool call; `hooks/merge-guard.sh` applies the same rule to a command string, for the harnesses that can. Matched as a run of consecutive non-flag arguments, so a flag before the subcommand does not slip past it. |
| `repo_flags` | `?list:flag` | (empty) | The option names this forge's CLI accepts for naming a repository other than the one the session was launched against - `--repo -R` for `gh`. `docker/forge-guard.sh` refuses a value that is not that repository, in every spelling (`--repo x`, `--repo=x`, and a one-dash flag with its value attached). Fast-fail, not a boundary, for the same reason `merge_block` is not one: the session holds the token. Leave it empty for a CLI that resolves the repo itself and refuses another host, as the gitea ones do - a forge is not required to be aimable. |
| `identity_keys` | `?list:identifier` | (empty) | Which of this forge's own `keys` belong to a **person** rather than to the project - the credential paths. One declaration, two rules: a key named here is one an `[identity.<name>]` section may set, and one a project may not declare in its own `.dejima/`. Core keeps no list of its own, so a forge whose credential is spelled differently needs no change here. The identity inherits the type from this forge's `keys` entry, not a second copy of it. |
| `repo_flags` | `?list:flag` | (empty) | The option names this forge's CLI accepts for naming a repository other than the one the session was launched against - `--repo -R` for `gh`. `docker/forge-guard.sh` refuses a value that is not that repository, in every spelling of the flag itself: `--repo x`, `--repo=x`, `-R x`, `-Rx`, and the letter inside a short-flag cluster (`-wR x`, `-wRx`). Fast-fail, not a boundary, for the same reason `merge_block` is not one: the session holds the token. Leave it empty for a CLI that resolves the repo itself and refuses another host, as the gitea ones do - a forge is not required to be aimable. |
| `identity_keys` | `?list:identifier` | (empty) | Which of this forge's own `keys` name an **account** rather than the forge itself - the credential paths, which differ per session, where an API URL does not. One declaration, two rules: a key named here is one an `[identity.<name>]` section may set, and one a project may not declare in its own `.dejima/`. Core keeps no list of its own, so a forge whose credential is spelled differently needs no change here. The identity inherits the type from this forge's `keys` entry, not a second copy of it. |
| `repo_env` | `?identifier` | (empty) | The environment variable this forge's CLI reads for the same thing - `GH_REPO`. Declared separately because it takes no argument and would otherwise be the way around every flag above. |
| `repo_paths` | `?list:repopath` | (empty) | A repository this forge's CLI names in a path rather than in a flag, as `<subcommand>:<first segment>` - `api:repos` for `gh api repos/<owner>/<name>/...`. Scoped to a subcommand on purpose: comparing every positional argument that contains two slashes would refuse an issue title that happens to look like one, and a false refusal costs an honest agent a working command. Paths carrying `gh`'s own `{owner}`/`{repo}` placeholders are left alone, since those resolve to this repository by construction. |
| `disclose` | `?disclosures` | (empty) | Which of this forge's commands send a body the session authored, as comma-separated `kind:command prefix` entries - `pr:gh pr create, issue:gh issue create, ...`. `docker/forge-guard.sh` prepends a marker to the body of a matching command, so a PR, issue or comment an agent wrote says so. The three kinds are `pr`, `issue` and `comment`; the wording of each marker is core's, not the manifest's, so that dejima says one thing about agent authorship whichever forge is selected. Unlike `merge_block` this does not depend on the workflow: what a workflow permits is a policy, who wrote the text is a fact. |
| `keys` | `?list:keyspec` | (empty) | This forge's own `[forge.<name>]` config schema - see above. |
| `requires` | `?list:identifier` | (empty) | Which of this forge's own config keys an operator is expected to set for it to actually work - checked at launch by two refusal rules (partly-configured, and none-configured-with-a-fixed-host) when the workflow pushes; see [Validation](#validation). A key with a non-empty schema default never counts as missing for either check. |
@@ -172,7 +174,7 @@ as environment variables.
| `requires` | `?list:identifier` | (empty) | Forge capabilities this workflow needs (checked against the selected forge's `capabilities` at launch). |
| `push` | `bool` | `on` | Whether this workflow pushes at all. When `off`, `DEJIMA_PUSH_HOSTS` is empty regardless of the forge, so `git-push-guard.sh` refuses every push. |
| `blocked_branches` | `?list:identifier` | (empty) | Branch names `git-push-guard.sh` refuses a direct push to, even on an allowed host. |
| `block_merge` | `bool` | `off` | When `on`, resolves the selected forge's `merge_block` command into `DEJIMA_MERGE_BLOCK` for `hooks/merge-guard.sh`. |
| `block_merge` | `bool` | `on` | When `on`, resolves the selected forge's `merge_block` command into `DEJIMA_MERGE_BLOCK` for `hooks/merge-guard.sh`. Defaults on so a workflow that omits it refuses a merge rather than permitting one; every shipped workflow sets it anyway. |
| `requires_worktree` | `bool` | `off` | When `on`, this workflow's briefing fragment must contain a `<!-- core: worktree -->` marker, which the core worktree-containment paragraph is spliced into - see below. |
| `autonomy` | `enum:commit\|push-branch\|open-pr` | `commit` | Gates `<!-- requires: autonomy=VALUE -->` spans in this workflow's own briefing fragment (see below); also read by the forge/harness resolvers where autonomy-conditioned prose applies. |
| `briefing` | `?relpath` | (empty) | This workflow's briefing fragment file, relative to its own directory. |
@@ -509,13 +511,33 @@ knowing about because they are what a second forge costs:
- **`gh` can be aimed past the sandboxed repository, and is stopped.** The
gitea CLIs refuse an origin that is not on the configured host, so they can
only act on the repo the sandbox was launched against. `gh` takes `--repo`
on nearly every subcommand and reads `GH_REPO`, so this forge declares both
(`repo_flags`, `repo_env`) and `docker/forge-guard.sh` refuses a value that
is not the launched-against repository. Fast-fail, not a boundary, exactly
as `merge_block` is: the session holds the token and could call the API
on nearly every subcommand, reads `GH_REPO`, and names repositories in `gh
api` paths, so this forge declares all three (`repo_flags`, `repo_env`,
`repo_paths`) and `docker/forge-guard.sh` refuses a value that is not the
launched-against repository. Fast-fail, not a boundary, exactly as
`merge_block` is: the session holds the token and could call the API
directly. What actually bounds it is the token's scope, so a per-repository
fine-grained PAT is the expected shape of `push_token_file`.
**What it sees, and what it does not.** Worth stating both, because a guard
described only by what it catches reads like a boundary. It compares, case
-insensitively: `--repo x` and `--repo=x`; `-R x` and `-Rx`; the same letter
inside a short-flag cluster, `-wR x` and `-wRx`; `GH_REPO`; and
`gh api repos/<owner>/<name>/...`, with or without a leading slash. A launch
that resolved no repository at all refuses every one of those rather than
waving them through.
It does not see a GraphQL `mutation` sent through `gh api graphql`, a
repository named in an `--jq` expression or a `-F`/`-f` field, an API path
under a segment other than `repos/`, or `gh` invoked through something else
that passes the repository along. Nor can it know that the workspace's
`origin` is the repository you meant: it compares against whatever origin
the checkout has, so a workspace pointing somewhere unexpected shifts what
counts as "this repository" rather than being caught by this. That list is
not exhaustive either, and it is not meant to be - widening it further would
mean parsing each subcommand's flags, which is the coupling this guard
exists to avoid. The token's scope is what bounds the ones it misses.
So: if you have a choice, `gitea` is the better-worn path. Choose `github`
because that is where your repositories are, which is a good reason - and
expect to be the person who finds the next crack.
@@ -546,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)
@@ -553,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`
+465 -207
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,182 +50,8 @@ front door opening *inward*, and that is a full sandbox escape rather than a
degradation. [Verifying it](#verifying-it) is two commands, and worth
running.
## `[remote-access]`
A section of the single config file, at `~/.config/dejima/config`
(`$DEJIMA_CONFIG_DIR` relocates the whole config tree, same as it does for
approvals):
| Key | Default | Purpose |
| --- | --- | --- |
| `enabled` | `off` | the switch - nothing below runs until this is `on` |
| `ntfy_url` | unset | ntfy topic to push notifications to. `dejima-notify-watch` refuses to start without it; nothing else reads it |
| `base_url` | unset | public base URL of the web front door, used to build the notification's deep link. Absent, pushes still go out - just with no link on them |
| `ttyd_port` | `7681` | loopback port `ttyd` listens on. The reverse proxy has to `proxy_pass` to the same one |
| `ttyd_font_size` | unset | terminal font size in px, 8-72. Applies to *every* client, phone and desktop alike - see "Reading it on a phone". Unset passes no option at all and leaves `ttyd` on its own default |
| `ttyd_button_colors` | unset | whether the toolbar is colour-coded by what each button does, in the same gruvbox palette the session picker uses. Unset means yes; `off` restores the grey every key had before. See "Reading it on a phone" |
| `ttyd_focus_on_tap` | unset | whether tapping the terminal raises the soft keyboard. Unset means no on a touch device and yes on a desktop, which is almost always what you want; `on` or `off` forces it. Overridable per client with `?focus=1` / `?focus=0` |
| `ttyd_tmux_prefix` | unset | the tmux prefix the terminal page's `PREFIX`, `◀W`, `W▶` and `ZOOM` buttons send, written as a control chord: `C-a`, `C-b`, `C-Space`, `C-@`, `C-_`, `C-?`. Unset leaves the page on stock tmux's `C-b`. See "Reading it on a phone" |
`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
@@ -308,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:
@@ -429,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
@@ -453,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
@@ -462,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
@@ -513,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
@@ -693,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
@@ -746,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:
@@ -799,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
@@ -826,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.
@@ -835,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`.
@@ -858,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.
@@ -925,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.
+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.
+233 -178
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.
@@ -44,6 +44,14 @@ than the thing inferred from silence.
session that loops until someone notices is not prevented, and this is a
deliberate omission rather than an oversight - see
[Why there are no timeouts](#why-there-are-no-timeouts).
- **A suspended job. Ctrl-Z stops the observer, not the session.** It
suspends `bin/dejima` on the host; the container keeps running, with its
mounts, its firewall grants and its forge token, and keeps making requests
while the terminal looks stopped. That is deliberate - SIGSTOP mid-tool-call
leaves a session wherever it was, and the feature exists to get the terminal
back rather than to pause the work - but a stopped job reads as a paused
agent, and the difference matters most to whoever suspends one because they
are uneasy about what it is doing.
- **The first layer's controls against an agent that has stopped
cooperating.** The briefing and `.dejima.md` are advice. They shape what a
cooperating agent does and stop nothing that has decided otherwise. Only the
@@ -250,13 +258,27 @@ checkout itself and needs no symlink to edit, so a session with write access
to this repo's own checkout can change what the next launch composes and
hands to whichever session starts against it.
The provider directories are in the exit-time audit's TCB list for that
reason, alongside `bin/`, `hooks/`, `docker/` and `test/`. They hold no code
and nothing executes them, but what they hold decides what the next launch
permits: a forge's `fixed_hosts` reaches `DEJIMA_FORGE_HOSTS`, which
`entrypoint.sh` joins into the domains `init-firewall.sh` allowlists, so a
line added to a manifest widens the egress boundary on the next run. An
ordinary edit is a diff you are about to read and is not reported; one git
**Which requires dejima's own checkout to be the workspace, and almost never
is.** Nothing mounts `forges/`, `workflows/`, `harnesses/`, `tools/` or the
rest of the checkout into a container - the resolved values go in, the files
do not - so for every launch pointed at some other project, a session cannot
read these paths, let alone write them. The rest of this section is about the
one configuration where it can: dejima sandboxing the repository it is itself
installed from, which is how dejima is developed. `bin/dejima` says so at
launch when it notices.
In that configuration the provider directories are in the exit-time audit's
TCB list, alongside `bin/`, `hooks/`, `docker/`, `test/` and `tools/`. They
hold no code and nothing executes them, but what they hold decides what the
next launch permits: a forge's `fixed_hosts` reaches `DEJIMA_FORGE_HOSTS`,
which `entrypoint.sh` joins into the domains `init-firewall.sh` allowlists, so
a line added to a manifest widens the egress boundary on the next run.
`tools/*/tool.conf` is the same class, and is read by the host rather than the
container: it names a tarball URL, the sha256 checked against it and the
layout it is unpacked with, all consumed by the image build - so a session
that edits one chooses what the next launch downloads and installs as root.
An ordinary edit is a diff you are about to read and is not reported; one git
cannot see - hidden by `.gitignore`, `.git/info/exclude`, `assume-unchanged`
or `skip-worktree` - is a CRITICAL finding. It is still a change a session
can make; it is one the audit reports. See
@@ -264,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
@@ -297,178 +319,180 @@ can make; it is one the audit reports. See
either, but only after the session ends - it doesn't stop them.
## The exit-time audit
- `bin/dejima-audit` snapshots host-persistence surfaces before each
run and diffs them after the container exits: `.git/config` (and worktree
`config.worktree`) keys that run a command on the next `git` -
`core.hooksPath`, `core.fsmonitor`, `core.sshCommand`,
`credential.helper`, `core.pager`, `pager.*`, `core.editor`,
`sequence.editor`, `gpg.program`, `diff.external`, `merge.*.driver`,
`diff.*.command`, `filter.*.smudge`/`clean`/`process`, `diff.*.textconv`,
`include.path`/`includeif.*.path` (config indirection - these can point at
a planted file that itself sets an exec key, so the exec key never
appears in `.git/config` directly), `!`-shell aliases - plus content
changes under the workspace's `.claude/`, `.mcp.json`, `.envrc`,
`.vscode/`, and `.git/hooks/` (symlinks included - a repointed symlink is
hashed by its target, not just its content). When the mounted workspace is
this repo itself, it also hashes the sandbox's own trusted computing base -
`bin/`, `hooks/`, `docker/`, `install.sh`, `test/`.
`bin/dejima-audit` snapshots host-persistence surfaces before each run and
diffs them after the container exits. It is **advisory**: it fires after the
session ended, so a planted `core.hooksPath` is something to clean up before
the next `git` or `claude` in that repo, not something that was blocked. It is
silent when nothing changed and never affects the session's exit code.
Worth knowing about that first surface, because the sandbox's own workflow
makes it easy to get wrong: **a git worktree isolates the working tree, not
`.git/config`.** Config is shared with the parent checkout, so a bare
`git config <key> <value>` inside a worktree writes the real repository's
config, including any exec key above. The briefing now says so, and gives
`git -C <path> config`. A guard against an unaware agent, not a boundary:
`.git/config` is in the read-write project mount and cannot be read-only
without breaking `git push -u`, which writes `branch.<name>.merge` on every
push. Detection covers the rest, and did - this was found by the audit
reporting a `.git/config` change.
**Its diff is time-windowed, not session-attributed:** a finding means a path
changed while the session ran, not that the session changed it. A concurrent
session, another host process, or your own editing all land in the same diff,
reported identically.
**Advisory only.** It fires after the session ended, so a planted
`core.hooksPath` is something to clean up before the next `git` or
`claude` in that repo, not something that was blocked. Silent when nothing
changed, and never affects the session's exit code. It does not cover
`[core] share_dir` (see
[Shared scratch space](configuration.md#shared-scratch-space)), and its
coverage is a fixed list rather than a general integrity check, so a config
surface it has not been taught about goes unreported.
### What it watches
**Its diff is time-windowed, not session-attributed:** a finding means a
path changed while the session ran, not that the session changed it. A
concurrent session, another host process, or your own editing all land in
the same diff, reported identically.
- **Approving and ignoring are different decisions, and the difference is
what you are promising about next time.** Approving records the exact
content: `safe <id> <sha>`, so this file with these bytes is fine and any
other content re-fires. Ignoring records the surface: `spam <id>`, so this
path or key is fine whatever it later contains. Approve a hook you have
read; ignore a key some tool rewrites on every run and which you have
decided you will never care about. The review offers both, and says which
is which rather than leaving you to infer it:
`.git/config` (and worktree `config.worktree`) keys that run a command on the
next `git` - `core.hooksPath`, `core.fsmonitor`, `core.sshCommand`,
`credential.helper`, `core.pager`, `pager.*`, `core.editor`, `sequence.editor`,
`gpg.program`, `diff.external`, `merge.*.driver`, `diff.*.command`,
`filter.*.smudge`/`clean`/`process`, `diff.*.textconv`,
`include.path`/`includeif.*.path` (config indirection - these can point at a
planted file that itself sets an exec key, so the exec key never appears in
`.git/config` directly), `!`-shell aliases - plus content changes under the
workspace's `.claude/`, `.mcp.json`, `.envrc`, `.vscode/`, and `.git/hooks/`
(symlinks included - a repointed symlink is hashed by its target, not just its
content). When the mounted workspace is this repo itself, it also hashes the
sandbox's own trusted computing base: `bin/`, `hooks/`, `docker/`,
`install.sh`, `test/`.
```
[a] approve this exact content [i] ignore this key from now on [s] skip (ask again next time) [q] quit
```
Coverage is a fixed list rather than a general integrity check, so a config
surface it has not been taught about goes unreported. It does not cover
`[core] share_dir` (see
[Shared scratch space](configuration.md#shared-scratch-space)).
A finding whose file has since been deleted is **cleared** rather than
approved: there is no content left to pin an approval to, and a surface
that does not exist does not run. Nothing is recorded, so if it comes back
it comes back as a new finding. Refusing would leave the report
unresolvable: `ack` opens CRITICAL reports first, so every other report
would hide behind one no decision could clear.
**A git worktree isolates the working tree, not `.git/config`.** Config is
shared with the parent checkout, so a bare `git config <key> <value>` inside a
worktree writes the real repository's config, including any exec key above. The
briefing says so, and gives `git -C <path> config`. That is a guard against an
unaware agent, not a boundary: `.git/config` is in the read-write project mount
and cannot be read-only without breaking `git push -u`, which writes
`branch.<name>.merge` on every push. Detection covers the rest, and did - this
was found by the audit reporting a `.git/config` change.
Skipping asks again next session, the right default when you are unsure.
It matters for keys some tools rewrite on every run: with no decision
recorded, one re-fires every session and pushes real findings out of the
capped list.
### Approving and ignoring are different decisions
Severity is not negotiable by either verb. An exec-capable key or shell
alias is CRITICAL whatever the baseline says, and an `ignore` entry is
refused against a CRITICAL surface when the baseline is read, not only when
written - so neither decision can quietly downgrade something that matters.
The difference is what you are promising about next time. Approving records the
exact content (`safe <id> <sha>`), so this file with these bytes is fine and any
other content re-fires. Ignoring records the surface (`spam <id>`), so this path
or key is fine whatever it later contains. Approve a hook you have read; ignore
a key some tool rewrites on every run and which you have decided you will never
care about. The review offers both, and says which is which:
- `dejima-audit ack` shows what it is asking you to approve. For a surface
that changed during the session it prints a unified diff of the
pre-session content against the current content; for one created during
the session it prints the whole current content. To make that possible,
`snapshot` stages a copy of each monitored surface's bytes for the
duration of the session, and `compare` turns those into **bounded
diffs** stored in a `<report>.d/` directory beside the report. What
persists after the session is only the diff of a *modified* surface -
never a whole file. A created surface stores a marker (its content is
live on disk); a removed one stores nothing at all, because a deletion
diff *is* the whole former file.
```
[a] approve this exact content [i] ignore this key from now on [s] skip (ask again next time) [q] quit
```
What it prints is chosen by what changed, which is not always what you
need in order to judge it - three changed lines in a hook script are only
judgeable by reading the script. `[o]` at the prompt opens the file in
`$VISUAL`/`$EDITOR`, read-only where the editor understands how, and
returns to the same decision. It opens the file **as it is now**, not the
text the report captured, for the same reason an approval pins the content
that is on disk at the moment it is recorded: approving something nobody
read would be the one outcome worse than not asking.
Skipping asks again next session, the right default when you are unsure. It
matters for keys some tools rewrite on every run: with no decision recorded, one
re-fires every session and pushes real findings out of the capped list.
Staging and retained diffs live under `$AUDIT_DIR`
(`~/.local/share/dejima/audit`), never `/tmp`. That is the boundary the
scheme rests on: nothing in `docker-compose.yml` covers it, so the
container cannot read earlier runs' retained content, and `static.bats`
pins that. A store configured *inside* the workspace is readable and
deletable from the sandbox, so nothing is retained in that case - only a
note saying why.
A report walked to the end stops being counted as waiting, but stays on disk as
the record of what that session did. `[audit] keep` decides how many are
retained; rotation evicts one, `ack` never does.
A `SIGKILL`ed session runs no EXIT trap and leaves its staging directory;
the next run against that workspace reaps it, identifying dead owners by
pid and process start time from an `.owner` file so a concurrent session is
never mistaken for dead. A missing or unreadable owner record falls back to
a 24-hour age check rather than guessing.
A finding whose file has since been deleted is **cleared** rather than approved:
there is no content left to pin an approval to, and a surface that does not
exist does not run. Nothing is recorded, so if it comes back it comes back as a
new finding. Refusing would leave the report unresolvable, since `ack` opens
CRITICAL reports first.
Content is withheld from the store in several further cases, and in
every one `ack` prints the reason in place of the diff - so an absent
diff is never mistaken for an absent change - and still shows the live
current content on your terminal, because withholding is about the
persistent store, not about what you are shown while deciding. Surfaces
that hold credentials by convention are never staged in the first
place, so their bytes are never even copied transiently: `.envrc`,
`.mcp.json` and `.claude/settings.local.json` by exact path, and
`.env`/`.env.*` by *basename*, at any depth - so a nested `.claude/.env`
is caught too, not just one at the workspace root. A surface that
existed before the session but was too large to have been staged (over
1 MiB) withholds with "no pre-session copy", not a false claim that it
is new. The *current* side is bounded by that same 1 MiB cap, and - like
the staged side - checked *before* any copy is taken: a file the session
grew past it withholds with "change too large to retain" rather than
being copied through `$TMPDIR` first. The ordering is the point, not the
cap. `compare` runs from the exit trap on every exit and the size is the
session's own choice, so copying first would turn "I filled my own
workspace" into that many bytes written to `$TMPDIR` - often a tmpfs, so
host RAM - and into a whole copy left there if the exit was a `SIGKILL`.
A generated diff is withheld if it is binary, over 200 lines
or 64 KiB (and is bounded to that as it is generated, for the same
reason), or matches a best-effort credential screen - vendor prefixes
including `sk-ant-`, `sk-proj-`, `ghp_`, `gho_`, `github_pat_`,
`glpat-`, Slack's `xox[baprs]-`, `AIza`, `AKIA`, `_authToken`,
`Authorization: Bearer`, and PEM private-key headers, matched
case-insensitively, since `.claude/settings.json`'s `env` block - the one
high-value surface this is the only net for - conventionally holds its keys
upper-case. The screen is best-effort with real false negatives: the
denylist above is the reliable half.
Severity is not negotiable by either verb. An exec-capable key or shell alias is
CRITICAL whatever the baseline says, and an `ignore` entry is refused against a
CRITICAL surface when the baseline is read, not only when written - so neither
decision can quietly downgrade something that matters.
A git config key with more than one value on either side is not compared.
Git resolves such a key to its last value in FILE order; the audit parses
and sorts, so lexicographically-last is not what git runs, and the
pre-session side is every value the key had. Comparing anyway produced a
diff over values git does not use - two pre-session values read as a
deletion that never happened, while the appended value git now executes
appeared nowhere. It withholds instead, and must be compared by hand.
`include.path` is legitimately multi-valued, so this is an ordinary shape.
### What `ack` shows you
A diff can also come out empty when the config file's TEXT changed but the
parsed value did not, e.g. a re-quoted value. That withholds too, saying so
rather than showing nothing - an empty diff for a reported change reads as
a bug.
For a surface that changed during the session it prints a unified diff of the
pre-session content against the current content; for one created during the
session it prints the whole current content. To make that possible, `snapshot`
stages a copy of each monitored surface's bytes for the duration of the session,
and `compare` turns those into **bounded diffs** in a `<report>.d/` directory
beside the report. What persists afterwards is only the diff of a *modified*
surface, never a whole file. A created surface stores a marker (its content is
live on disk); a removed one stores nothing, because a deletion diff *is* the
whole former file.
A per-report **retention budget** (`RETAIN_BUDGET`, 1 MiB) bounds the total
diff bytes one report keeps. Once spent, the rest withhold and both the
report and the stderr summary add an alarm line: that many changed
host-persistence surfaces in one session is a signal in itself, not a limit
worth raising. `diffs = off` in `[audit]` disables retention and staging.
What it prints is chosen by what changed, which is not always what you need in
order to judge it: three changed lines in a hook script are only judgeable by
reading the script. `[o]` at the prompt opens the file in `$VISUAL`/`$EDITOR`,
read-only where the editor understands how, and returns to the same decision. It
opens the file **as it is now**, for the same reason an approval pins the content
on disk at the moment it is recorded: approving something nobody read would be
the one outcome worse than not asking.
Staging and retained diffs live under `$AUDIT_DIR`
(`~/.local/share/dejima/audit`), never `/tmp`. That is the boundary the scheme
rests on: nothing in `docker-compose.yml` covers it, so the container cannot read
earlier runs' retained content, and `static.bats` pins that. A store configured
*inside* the workspace is readable and deletable from the sandbox, so nothing is
retained in that case, only a note saying why.
A `SIGKILL`ed session runs no EXIT trap and leaves its staging directory; the
next run against that workspace reaps it, identifying dead owners by pid and
process start time from an `.owner` file so a concurrent session is never
mistaken for dead. A missing or unreadable owner record falls back to a 24-hour
age check rather than guessing.
### When content is withheld from the store
In every case below `ack` prints the reason in place of the diff - so an absent
diff is never mistaken for an absent change - and still shows the live current
content on your terminal, because withholding is about the persistent store, not
about what you are shown while deciding.
Surfaces that hold credentials by convention are never staged in the first
place, so their bytes are never even copied transiently: `.envrc`, `.mcp.json`
and `.claude/settings.local.json` by exact path, and `.env`/`.env.*` by
*basename*, at any depth - so a nested `.claude/.env` is caught too.
A surface that existed before the session but was too large to have been staged
(over 1 MiB) withholds with "no pre-session copy", not a false claim that it is
new. The *current* side is bounded by that same cap, and checked *before* any
copy is taken: a file the session grew past it withholds with "change too large
to retain" rather than being copied through `$TMPDIR` first. The ordering is the
point, not the cap. `compare` runs from the exit trap on every exit and the size
is the session's own choice, so copying first would turn "I filled my own
workspace" into that many bytes written to `$TMPDIR` - often a tmpfs, so host
RAM - and into a whole copy left there if the exit was a `SIGKILL`.
A generated diff is withheld if it is binary, over 200 lines or 64 KiB (bounded
as it is generated, for the same reason), or matches a best-effort credential
screen: vendor prefixes including `sk-ant-`, `sk-proj-`, `ghp_`, `gho_`,
`github_pat_`, `glpat-`, Slack's `xox[baprs]-`, `AIza`, `AKIA`, `_authToken`,
`Authorization: Bearer`, and PEM private-key headers, matched case-insensitively
since `.claude/settings.json`'s `env` block - the one high-value surface this is
the only net for - conventionally holds its keys upper-case. The screen has real
false negatives; the denylist is the reliable half.
A git config key with more than one value on either side is not compared. Git
resolves such a key to its last value in FILE order; the audit parses and sorts,
so lexicographically-last is not what git runs, and the pre-session side is every
value the key had. Comparing anyway produced a diff over values git does not use:
two pre-session values read as a deletion that never happened, while the appended
value git now executes appeared nowhere. It withholds instead, and must be
compared by hand. `include.path` is legitimately multi-valued, so this is an
ordinary shape.
A diff can also come out empty when the config file's TEXT changed but the parsed
value did not, e.g. a re-quoted value. That withholds too, saying so rather than
showing nothing, since an empty diff for a reported change reads as a bug.
A per-report **retention budget** (`RETAIN_BUDGET`, 1 MiB) bounds the total diff
bytes one report keeps. Once spent, the rest withhold and both the report and the
stderr summary add an alarm line: that many changed host-persistence surfaces in
one session is a signal in itself, not a limit worth raising. `diffs = off` in
`[audit]` disables retention and staging.
Two residual limits on top of the screen's false negatives. The prompt truncates
at 40 lines, naming the file so you can read the rest, so a large planted change
is cut exactly where you would want it whole. And the `SIGKILL` case is an
exposure window, not just a cleanup mechanic: until the next run reaps it, the
staging directory holds whole pre-session file copies of every monitored surface,
for as long as the workspace goes unused.
### Findings reach your terminal as untrusted text
They name a path or config key the session may have chosen. One containing an
ASCII or C1 control or a Unicode bidi override - which can scroll, blank or
reorder what you read, and split a name across the audit's own records - is
written hex-encoded behind a `{hex}` marker and decodes with
`printf '%b' "$(sed 's/../\\x&/g' <<< HEX)"`. Ordinary names print as-is, so
the marker appearing is itself worth a look. The encoded form is what `ack`
records, so what you acknowledge is the file on disk rather than a readable
approximation of its name.
Two residual limits on top of the screen's false negatives. The prompt
truncates at 40 lines, naming the file so you can read the rest - a large
planted change is cut exactly where you would want it whole. And the
`SIGKILL` case is an exposure window, not just a cleanup mechanic: until
the next run reaps it, the staging directory holds whole pre-session file
copies of every monitored surface, for as long as the workspace goes
unused.
- Findings name a path or config key the session may have chosen, so they
reach your terminal as untrusted text. One containing an ASCII or C1
control or a Unicode bidi override - which can scroll, blank or reorder
what you read, and split a name across the audit's own records - is written
hex-encoded behind a `{hex}` marker and decodes with
`printf '%b' "$(sed 's/../\\x&/g' <<< HEX)"`. Ordinary names print as-is,
so the marker appearing is itself worth a look. The encoded form is what
`ack` records, so what you acknowledge is the file on disk rather than a
readable approximation of its name.
## One project's session cannot reach another's
- `~/.claude/projects` (session transcripts and per-project memory) is
@@ -760,7 +784,7 @@ 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
@@ -773,10 +797,11 @@ session, where the credential deliberately never goes.
reach the daemon - that is daemon access rather than any process at all,
and it is not something the launcher can change.
- The container runs with `no-new-privileges` and an explicit capability
floor - `cap_drop: ALL` plus the seven it actually uses. Six are the
entrypoint's own; the seventh is `KILL`, which nothing in the entrypoint
needs and `dejima-allow --container` does, because adding a domain to a
running session signals dnsmasq and dnsmasq runs as its own user. The first means a setuid binary or a file capability cannot raise
floor - `cap_drop: ALL` plus the ones it actually uses: what the
entrypoint's root phase needs, and `KILL`. `KILL` is there for
`dejima-allow --container`, not for the entrypoint - adding a domain to a
running session signals dnsmasq, and dnsmasq runs as its own user. The list
itself is in `docker/docker-compose.yml`, which is where it stays correct. The first means a setuid binary or a file capability cannot raise
privileges on exec, which is the shape any escalation from the session user
would have to take; the second means the capabilities nothing here uses were
never in the bounding set to be regained. Both are asserted against a running
@@ -981,12 +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
@@ -1067,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
+29 -4
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,
@@ -82,6 +82,27 @@ 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
@@ -212,6 +233,10 @@ this section. Nothing in dejima can bound its own host.
- **That a finding from the exit-time audit was caused by the session.** It
reports that a surface changed while the session ran. A concurrent session
or the operator's own editing lands in the same diff.
- **That what a session leaves in a handoff directory is safe for another
machine to open.** `share_dir` and `[mounts] read_write` carry
session-authored bytes and session-chosen filenames out of the container on
purpose. Sanitising happens where dejima does the printing, and stops there.
- **That the allowlist bounds exfiltration once a session has push access.**
A commit is an outbound channel to a host that is on the list by definition.
See [ci.md](ci.md).
+5
View File
@@ -38,6 +38,11 @@ merge_block = gh pr merge
# repository-scoped fine-grained PAT is the expected shape.
repo_flags = --repo -R
repo_env = GH_REPO
# `gh api repos/<owner>/<name>/...` names a repository with no --repo anywhere
# in it, and is the ordinary way to reach an endpoint the subcommands do not
# cover. Scoped to `api` so the guard is not comparing every positional
# argument that contains two slashes (#243).
repo_paths = api:repos
disclose = pr:gh pr create, pr:gh pr edit, issue:gh issue create, issue:gh issue edit, comment:gh issue comment
# See the gitea manifest's note: this is the key an identity supplies and a
# project may not. The read-only [github] token_file is core's rather than
+4 -4
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env bash
# Notification/Stop hook. bin/dejima registers it, per session, into the
# throwaway settings.json copy it generates for a session that has remote
# access enabled, and nowhere else. Every sandbox gets such a copy, derived
# from the operator's real settings.json; registering it in that real file
# Notification hook. The selected harness's plugin registers it, per session,
# into the throwaway settings.json copy bin/dejima generates for a session
# that has remote access enabled, and nowhere else. Every sandbox gets such a
# copy, derived from the operator's real settings.json; registering it there
# instead would put this hook into all of them - and into the host's own
# Claude Code, which reads that file directly - where there is no marker
# directory to write into. The copy is mounted read-only (see
+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
+457 -9
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" "$@"; }
@@ -502,7 +502,8 @@ _identity_fixture() {
"token_file = $ID_DIR/default-token" \
'[identity.work]' "ssh_key = $ID_DIR/work-key" 'user_name = A Name' \
'user_email = name@work.example' "token_file = $ID_DIR/work-token" \
'[identity.oss]' "ssh_key = $ID_DIR/oss-key"
'[identity.oss]' "ssh_key = $ID_DIR/oss-key" \
'[identity.keyless]' "token_file = $ID_DIR/work-token"
}
@test "an identity supplies the ssh key and the forge credential" {
@@ -515,15 +516,59 @@ _identity_fixture() {
rm -rf "$ID_DIR"
}
@test "a key the identity does not name falls through to the file" {
# An identity overrides what it names. Treating it as a replacement for
# the whole section would make a half-filled one silently unset the rest.
@test "an identity that leaves a credential to the section beneath it is refused" {
# This used to fall through: oss names no token_file, so [forge.gitea]
# answered for it, and a session launched as oss talked to the forge as
# whichever account owns that token. An identity is how one session's
# account is kept separate from another's, so a credential crossing that
# line is the feature not working rather than a convenience it offers.
_identity_fixture
run "$REPO_ROOT/bin/dejima" --identity oss --print-config
[ "$status" -ne 0 ] || { echo "the borrowed token was accepted: $output"; return 1; }
# Which credential, by name - "something is wrong with your identity" is
# not something an operator can act on.
[[ "$output" == *"[forge.gitea] token_file"* ]] \
|| { echo "the refusal does not name the credential: $output"; return 1; }
# And nothing resolved is printed, for the same reason the missing-section
# refusal prints nothing: it would show this machine's token under oss.
[[ "$output" != *"forge.gitea.token_file=$ID_DIR/default-token"* ]] \
|| { echo "printed the borrowed credential anyway: $output"; return 1; }
rm -rf "$ID_DIR"
}
@test "an identity that leaves the ssh key to [core] is refused" {
# The other half, and the one the ambiguity was about: [core] ssh_key is
# the key a session gets when no identity is named, so an identity that
# names none is asking to push as itself with the default account's key.
_identity_fixture
run "$REPO_ROOT/bin/dejima" --identity keyless --print-config
[ "$status" -ne 0 ] || { echo "the borrowed key was accepted: $output"; return 1; }
[[ "$output" == *"[core] ssh_key"* ]] \
|| { echo "the refusal does not name the key: $output"; return 1; }
rm -rf "$ID_DIR"
}
@test "a credential belonging to a forge this launch does not use is not borrowed" {
# Only the selected forge's keys. Refusing over a value nothing on this
# launch reads would make an unrelated section able to block a launch.
_identity_fixture
printf '[forge.github]\npush_token_file = %s\n' "$ID_DIR/default-token" \
>> "$CONFIG_DIR/config"
run "$REPO_ROOT/bin/dejima" --identity work --print-config
[ "$status" -eq 0 ] \
|| { echo "an unused forge's credential blocked the launch: $output"; return 1; }
rm -rf "$ID_DIR"
}
@test "with no identity named, the config's own credentials are the ones used" {
# The unnamed default, which is what [core] ssh_key means. Nothing about
# the refusals above touches a host that never names an identity.
_identity_fixture
run "$REPO_ROOT/bin/dejima" --print-config
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qx "core.ssh_key=$ID_DIR/oss-key" <<< "$output" || { echo "$output"; return 1; }
grep -qx "core.ssh_key=$ID_DIR/default-key" <<< "$output" || { echo "$output"; return 1; }
grep -qx "forge.gitea.token_file=$ID_DIR/default-token" <<< "$output" \
|| { echo "oss has no token_file and did not fall through: $output"; return 1; }
|| { echo "$output"; return 1; }
rm -rf "$ID_DIR"
}
@@ -1375,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"
@@ -1495,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"
@@ -2465,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"
}
@@ -2584,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"
}
+62 -4
View File
@@ -27,7 +27,15 @@ import time
HELPER = os.path.abspath(sys.argv[1])
SHELL = sys.argv[2] if len(sys.argv) > 2 else "bash"
WORK = os.environ.get("TTY_SUSPEND_WORK") or "/tmp"
# Required, not defaulted. The fallback was "/tmp", and the three paths below
# are then fixed names in a world-writable directory that this fixture goes on
# to WRITE and EXECUTE. Every bats caller passes a directory, so nothing
# shipped ever took that branch - but this runs on CI hosts, and a predictable
# executed path is not a thing to leave lying around for the one caller that
# forgets (#249).
WORK = os.environ.get("TTY_SUSPEND_WORK")
if not WORK:
sys.exit("tty-suspend-harness: set TTY_SUSPEND_WORK to a directory of its own")
RECEIVED = os.path.join(WORK, "received")
LAUNCHER = os.path.join(WORK, "launcher.sh")
CONTAINER = os.path.join(WORK, "container.py")
@@ -110,8 +118,8 @@ finally:
# TTY_SUSPEND_NO_HELPER leaves the helper out, which is dejima before this
# existed. It is the control: a harness that cannot see the bug cannot be
# trusted to see the fix either.
start_helper = "" if os.environ.get("TTY_SUSPEND_NO_HELPER") else \
'"%s" &\nhelper=$!' % HELPER
HELPER_EXPECTED = not os.environ.get("TTY_SUSPEND_NO_HELPER")
start_helper = '"%s" &\nhelper=$!' % HELPER if HELPER_EXPECTED else ""
# bin/dejima's shape: the client inherits SIGTSTP ignored and is started with
# the terminal, and the keystroke is converted into an explicit SIGSTOP for it
# before the launcher stops itself. Without the conversion the client is the
@@ -159,7 +167,11 @@ if pid == 0:
if slave > 2:
os.close(slave)
os.close(master)
env = dict(os.environ, PS1="PROMPT> ")
# LC_ALL=C so the shell's job-control notice is the "Stopped" this expects.
# Left to the environment it is whatever locale the runner happens to have,
# and the assertion then fails on a translated message with nothing about
# the terminal handling having changed (#249).
env = dict(os.environ, PS1="PROMPT> ", LC_ALL="C", LANG="C")
args = [SHELL, "--norc", "-i"] if "bash" in SHELL else [SHELL, "-f", "-i"]
os.execvpe(SHELL, args, env)
os.close(slave)
@@ -209,6 +221,31 @@ 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:
@@ -230,6 +267,27 @@ try:
check("the container run attached", expect("CONTAINER-ATTACHED"))
for cycle in (1, 2):
# Wait for the helper to hold the terminal before typing at it (#262).
#
# CONTAINER-ATTACHED is printed by the stand-in immediately after it
# sets raw mode, so it marks the START of the window this feature
# exists to close, not the end. The helper only acts once it has POLLED
# the terminal and seen -isig, so between those two moments ^Z is still
# the byte 0x1a. Sending into that window put the byte in the container
# and failed `nothing-reached-the-container` - the security assertion -
# on a machine where nothing was wrong but the scheduling.
#
# Waiting for the precondition rather than lengthening the deadline
# after it: with the terminal provably converting ^Z to a signal before
# anything is typed, that check can no longer fail for timing reasons
# at all, which is the point. A longer timeout would only have narrowed
# the window.
#
# Skipped without the helper, where the byte reaching the container is
# the expected result and this would never come true.
if HELPER_EXPECTED:
check("cycle%d-suspend-char-reclaimed" % cycle, await_reclaim(),
"dejima-tty-suspend never took ^Z back from raw mode")
before = received()
forget()
send("\x1a")
+183 -27
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" \
@@ -575,8 +581,8 @@ EOF
run "$REPO_ROOT/bin/dejima-allow" crates.io
[ "$status" -ne 0 ]
[[ "$output" == *"Multiple running dejima containers"* ]] || { echo "$output"; return 1; }
grep -qE 'dejima-run-aaaa +alpha' <<< "$output" || { echo "$output"; return 1; }
grep -qE 'dejima-run-bbbb +beta' <<< "$output" || { echo "$output"; return 1; }
grep -qE 'alpha +dejima-run-aaaa' <<< "$output" || { echo "$output"; return 1; }
grep -qE 'beta +dejima-run-bbbb' <<< "$output" || { echo "$output"; return 1; }
rm -rf "$STUB_DIR"
}
@@ -589,8 +595,8 @@ EOF
run "$REPO_ROOT/bin/dejima-allow" crates.io
# beta is first in the attach listing, so beta is 1 - not alpha, which
# docker happens to report first.
grep -qE 'beta .*\(dejima-attach 1\)' <<< "$output" || { echo "$output"; return 1; }
grep -qE 'alpha .*\(dejima-attach 2\)' <<< "$output" || { echo "$output"; return 1; }
grep -qE '^ *beta .*\(dejima-attach 1\)' <<< "$output" || { echo "$output"; return 1; }
grep -qE '^ *alpha .*\(dejima-attach 2\)' <<< "$output" || { echo "$output"; return 1; }
rm -rf "$STUB_DIR"
}
@@ -607,7 +613,7 @@ EOF
chmod +x "$STUB_DIR/dejima-attach"
run "$REPO_ROOT/bin/dejima-allow" crates.io
[ "$status" -ne 0 ]
grep -qE 'dejima-run-aaaa +alpha' <<< "$output" || { echo "$output"; return 1; }
grep -qE 'alpha +dejima-run-aaaa' <<< "$output" || { echo "$output"; return 1; }
[[ "$output" != *"dejima-attach 1"* ]] || { echo "invented a number: $output"; return 1; }
[[ "$output" != *"Remote access is not configured"* ]] \
|| { echo "leaked the attach refusal: $output"; return 1; }
@@ -1060,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
@@ -1110,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" {
@@ -1121,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" {
@@ -1142,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" {
@@ -1157,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" {
@@ -1785,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
@@ -1809,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"
@@ -1831,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")"
}
@@ -1846,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"
}
@@ -1860,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"
}
+83 -1
View File
@@ -925,7 +925,7 @@ assert_remedy_forges_exist() {
done
}
@test "rtk is declared with its telemetry switched off in the image" {@test "rtk is declared with its telemetry switched off in the image" {
@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"* ]] \
@@ -1045,11 +1045,34 @@ assert_remedy_forges_exist() {
# with (#187). The guard is generic; the manifest is what makes it fire.
[ "$(lib manifest_get forge github repo_flags)" = "--repo -R" ]
[ "$(lib manifest_get forge github repo_env)" = "GH_REPO" ]
# And in a path rather than a flag: `gh api repos/<owner>/<name>/...`
# names a repository with no --repo in it at all (#243).
[ "$(lib manifest_get forge github repo_paths)" = "api:repos" ]
# The gitea CLIs resolve the repo from origin and refuse another host, so
# there is nothing to declare and nothing for the guard to compare. A
# forge is not required to be aimable.
[ -z "$(lib manifest_get forge gitea repo_flags)" ]
[ -z "$(lib manifest_get forge gitea repo_env)" ]
[ -z "$(lib manifest_get forge gitea repo_paths)" ]
}
@test "a repo path has to name a subcommand and a segment" {
# `<subcommand>:<first segment>`. The subcommand half is what keeps the
# guard from comparing every positional argument that contains two
# slashes, so a spelling that omits it is not a narrower declaration - it
# is one that would refuse an issue title.
local d bad; d="$(mktemp -d "$BATS_TEST_TMPDIR/pathtype.XXXXXX")"
mkdir -p "$d/forges/probe"
for bad in "repos" "api:" ":repos" "api:repos/more" "api:/repos" "-api:repos"; do
printf 'repo_paths = %s\n' "$bad" > "$d/forges/probe/forge.conf"
run env DEJIMA_PROVIDER_ROOT="$d" \
bash -c 'source "$1"; manifest_validate forge probe' _ "$LIB"
[ "$status" -ne 0 ] || { echo "accepted repo_paths = $bad"; return 1; }
done
printf 'repo_paths = api:repos\n' > "$d/forges/probe/forge.conf"
run env DEJIMA_PROVIDER_ROOT="$d" \
bash -c 'source "$1"; manifest_validate forge probe' _ "$LIB"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
}
@test "a repo flag has to look like a flag" {
@@ -1263,3 +1286,62 @@ assert_remedy_forges_exist() {
[ -n "$spec" ] || { echo "$name declares no install and is not in the base image"; return 1; }
done
}
@test "a workflow that omits block_merge blocks merges anyway" {
# The default decides what happens to a workflow that forgets the key -
# a third-party one, or a fourth shipped one added in a hurry. `off` means
# a missing key silently permits merging from inside a session, which is
# the direction a guard should never fail in. Every shipped workflow sets
# it explicitly, so the invariant test covering those would pass either
# way; this is about the ones nobody has written yet.
FAKE_ROOT="$(mktemp -d)"
mkdir -p "$FAKE_ROOT/workflows/forgetful"
printf 'requires =\npush = off\n' > "$FAKE_ROOT/workflows/forgetful/workflow.conf"
run env DEJIMA_PROVIDER_ROOT="$FAKE_ROOT" DEJIMA_CONFIG_DIR="$CONFIG_DIR" \
bash -c 'source "$1"; manifest_get workflow forgetful block_merge' _ "$LIB"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$output" = "on" ] \
|| { echo "a workflow that names no block_merge got '$output'"; return 1; }
rm -rf "$FAKE_ROOT"
}
@test "a forge that aims its CLI also gets that CLI shadowed" {
# entrypoint.sh installs the PATH shadow from merge_block and disclose
# only (_shadow_clis). repo_flags and repo_env are exported to the
# container regardless - so a forge declaring repo_flags without either of
# the other two hands the guard its configuration with no guard on PATH to
# read it, and the aiming check silently does not exist.
#
# Both shipped forges declare disclose, so nothing is broken today. This
# is the manifest contract stated as an invariant, for the third forge
# someone writes (#244).
local forge named shadowed cli entry
for dir in "$REPO_ROOT"/forges/*/; do
[ -d "$dir" ] || continue
forge="$(basename "$dir")"
[ -n "$(lib manifest_get forge "$forge" repo_flags)$(lib manifest_get forge "$forge" repo_env)" ] \
|| continue
# The CLI is the first word of each command the forge names.
named=""
for entry in pr_command issue_command; do
cli="$(lib manifest_get forge "$forge" "$entry")"
[ -n "$cli" ] && named="$named ${cli%% *}"
done
# What entrypoint.sh would actually shadow: merge_block's first word,
# and the CLI in each disclose record ("<kind>:<cli> <subcommand>").
shadowed=" $(lib manifest_get forge "$forge" merge_block | cut -d' ' -f1) "
while IFS= read -r entry; do
entry="${entry#"${entry%%[![:space:]]*}"}"
[ -n "$entry" ] || continue
cli="${entry#*:}"
shadowed="$shadowed ${cli%% *} "
done < <(lib manifest_get forge "$forge" disclose | tr ',' '\n')
for cli in $named; do
[[ "$shadowed" == *" $cli "* ]] \
|| { echo "forge $forge aims '$cli' but nothing shadows it: declare it in merge_block or disclose"; return 1; }
done
done
}
+177 -26
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
@@ -1944,6 +2008,12 @@ expect_ttyd_argv() {
[ -z "${3:-}" ] || printf '%s\n' -t "tmuxPrefix=$3"
[ -z "${4:-}" ] || printf '%s\n' -t "focusOnTap=$4"
[ -z "${5:-}" ] || printf '%s\n' -t "buttonColors=$5"
# Always sent, unlike the four above: a browser has no theme to inherit,
# so this is not an option an operator turns on. Read out of the script
# rather than transcribed - this file asserts that ttyd is given a theme
# and that its values match the page (static.bats), not what they are.
printf '%s\n' -t "$(sed -n "/^theme_opt=(-t '/,/^}')$/p" \
"$REPO_ROOT/bin/dejima-ttyd" | sed "1s/^theme_opt=(-t '//; \$s/')$//")"
printf '%s\n' "$(readlink -f "$REPO_ROOT/bin")/dejima-attach"
}
# Sentinel on both sides: both are command substitutions, which strip trailing
@@ -2169,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
@@ -2698,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")" ]
@@ -3046,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; }
}
+31 -5
View File
@@ -49,7 +49,10 @@ compose_cap_add() {
' "$BATS_TEST_DIRNAME/../docker/docker-compose.yml"
}
IMAGE=dejima:latest
# localhost/ for the same reason bin/dejima uses it: a bare name is a Docker
# Hub name to anything that pushes, and nothing built here is meant to leave
# the host (#53).
IMAGE=localhost/dejima:latest
# What a session actually runs: the base image plus the selected harness's own
# layer, built in setup_file. Exported there because bats re-runs this file's
@@ -169,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
@@ -321,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
@@ -407,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
}
@@ -420,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/.."
@@ -1478,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)"
+1025 -45
View File
File diff suppressed because it is too large Load Diff