Compare commits

...
Author SHA1 Message Date
branderson a918047f93 Review: drop the claim we have not earned, and stop writing in blocks
tests / confined (proxy) (pull_request_target) Successful in 1m14s
tests / confined (conversation) (pull_request_target) Successful in 1m27s
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / confined (shellcheck) (pull_request_target) Successful in 58s
tests / confined (providers) (pull_request_target) Successful in 2m48s
tests / privileged (pull_request_target) Successful in 3m53s
tests / confined (remote) (pull_request_target) Successful in 4m6s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
tests / confined (project) (pull_request_target) Successful in 5m50s
tests / confined (audit) (pull_request_target) Successful in 9m52s
tests / confined (static) (pull_request_target) Successful in 5m26s
tests / confined (config) (pull_request_target) Successful in 9m18s
Two things.

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

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

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

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

Cut to the claim:

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

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

The test now holds a word budget on the opening rather than a phrase
from the version being replaced. It was asserting "field's default
position" appeared, which pinned the history lesson in place: the
sentence I was asked to remove was the one the test required. A budget
expresses the constraint that actually applies, and it catches the
failure mode that reads as thorough rather than the one that reads as
sloppy.
2026-08-25 23:38:00 -07:00
branderson e8de5edbf0 Lead with what is different, not with the premise everyone shares
tests / confined (proxy) (pull_request_target) Successful in 1m10s
tests / runner-env (confined) (pull_request_target) Successful in 39s
tests / confined (conversation) (pull_request_target) Successful in 1m23s
tests / confined (shellcheck) (pull_request_target) Successful in 59s
tests / confined (providers) (pull_request_target) Successful in 2m39s
tests / runner-env (privileged) (pull_request_target) Successful in 40s
tests / privileged (pull_request_target) Successful in 3m46s
tests / confined (remote) (pull_request_target) Successful in 3m57s
tests / confined (static) (pull_request_target) Successful in 5m24s
tests / confined (project) (pull_request_target) Successful in 5m42s
tests / confined (config) (pull_request_target) Successful in 9m20s
tests / confined (audit) (pull_request_target) Successful in 9m55s
The README opened on "assume they are hostile", which is the field's
default position as of 2026 - a survey of comparable projects found the
premise everywhere and nobody claiming it as a distinction. Opening on it
claims novelty dejima does not have, and buries the thing it does have.

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

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

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

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

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

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

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

Measured on a pty, bytes written after the poke:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Three things found while doing it and fixed here:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

On resume the terminal has to be put back by hand. Both bash and zsh
restore their own modes when they take the terminal at the prompt and
put those same modes back on fg, not the ones the job was using, so
without this the session returns line-buffered and echoing. The SIGCONT
trap re-applies the session's modes and pokes the process group with
SIGWINCH, which is what makes a full-screen harness paint over whatever
the shell wrote while it was away.
2026-08-23 01:32:56 -07:00
branderson 9354287b1c Merge pull request 'Give every CLI a --help that explains its arguments' (#178) from cli-help into development
Reviewed-on: #178
Reviewed-by: branderson <brad@branderson.io>
2026-08-23 00:53:48 -07:00
branderson 3e5fc982bb Merge pull request 'Say which forge is the worn path, and which is not' (#192) from forge-maturity-doc into development
Reviewed-on: #192
2026-08-23 00:51:24 -07:00
branderson 106eec8b8b Merge pull request 'Disclose agent authorship for every forge, not just the ones dejima wrote a CLI for' (#191) from forge-disclosure into development
Reviewed-on: #191
Reviewed-by: branderson <brad@branderson.io>
2026-08-23 00:48:56 -07:00
branderson 88a485a51d Merge pull request 'Let the github forge start, and stop a flag walking past the merge block' (#190) from forge-github-shadow into development
Reviewed-on: #190
Reviewed-by: branderson <brad@branderson.io>
2026-08-23 00:48:06 -07:00
branderson 6f9485733a Merge branch 'forge-disclosure' into forge-maturity-doc
tests / confined (proxy) (pull_request_target) Successful in 1m13s
tests / confined (conversation) (pull_request_target) Successful in 1m25s
tests / privileged (pull_request_target) Successful in 3m36s
tests / confined (remote) (pull_request_target) Successful in 3m33s
tests / confined (shellcheck) (pull_request_target) Successful in 50s
tests / confined (providers) (pull_request_target) Successful in 2m14s
tests / runner-env (confined) (pull_request_target) Successful in 35s
tests / runner-env (privileged) (pull_request_target) Successful in 36s
tests / confined (project) (pull_request_target) Successful in 5m2s
tests / confined (static) (pull_request_target) Successful in 4m20s
tests / confined (config) (pull_request_target) Successful in 6m48s
tests / confined (audit) (pull_request_target) Successful in 8m57s
2026-08-23 00:35:07 -07:00
branderson 8eda94f997 Merge branch 'forge-github-shadow' into forge-disclosure
tests / confined (proxy) (pull_request_target) Successful in 1m12s
tests / confined (conversation) (pull_request_target) Successful in 1m24s
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / confined (shellcheck) (pull_request_target) Successful in 55s
tests / confined (providers) (pull_request_target) Successful in 2m17s
tests / privileged (pull_request_target) Successful in 3m43s
tests / confined (remote) (pull_request_target) Successful in 3m44s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
tests / confined (project) (pull_request_target) Successful in 5m18s
tests / confined (static) (pull_request_target) Successful in 4m35s
tests / confined (config) (pull_request_target) Successful in 7m2s
tests / confined (audit) (pull_request_target) Successful in 8m52s
2026-08-23 00:28:02 -07:00
branderson b31c6aa540 Stub a forge's system commands in the tests, so CI is not the machine
tests / confined (proxy) (pull_request_target) Successful in 1m15s
tests / confined (conversation) (pull_request_target) Successful in 1m22s
tests / runner-env (confined) (pull_request_target) Successful in 35s
tests / confined (shellcheck) (pull_request_target) Successful in 50s
tests / confined (providers) (pull_request_target) Successful in 2m19s
tests / confined (remote) (pull_request_target) Successful in 3m44s
tests / privileged (pull_request_target) Successful in 3m44s
tests / confined (static) (pull_request_target) Successful in 4m18s
tests / runner-env (privileged) (pull_request_target) Successful in 35s
tests / confined (project) (pull_request_target) Successful in 5m11s
tests / confined (config) (pull_request_target) Successful in 6m49s
tests / confined (audit) (pull_request_target) Successful in 8m45s
The tests that run install-forge-bins.sh took the machine's word for
whether a declared system: command exists. Inside a dejima container gh
does, on the CI runner it does not, so every one of these passed here
and failed there.

Two of the five expected a nonzero exit, which is worse than the one
that failed: they would have passed on the runner for entirely the
wrong reason. Both now check what the failure actually says.

The stub names come from the manifests rather than a list here, so a
forge declaring another one later needs no change to this.
2026-08-23 00:27:53 -07:00
branderson c941abb1a4 Say which forge is the worn path, and which is not
tests / confined (audit) (pull_request_target) Successful in 9m7s
tests / confined (config) (pull_request_target) Successful in 7m5s
tests / confined (conversation) (pull_request_target) Successful in 1m25s
tests / confined (project) (pull_request_target) Successful in 5m21s
tests / confined (providers) (pull_request_target) Successful in 2m20s
tests / confined (proxy) (pull_request_target) Successful in 1m14s
tests / confined (remote) (pull_request_target) Successful in 3m48s
tests / confined (shellcheck) (pull_request_target) Successful in 51s
tests / privileged (pull_request_target) Successful in 3m43s
tests / runner-env (confined) (pull_request_target) Successful in 37s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (static) (pull_request_target) Failing after 4m26s
Two forges in one table reads as two forges that got the same
consideration. The harness section already makes this point for the
harnesses; the forges need it more, because the untested one is the
public one an operator is more likely to already be on.
2026-08-22 23:35:03 -07:00
branderson d9a99a0285 Move the authorship marker out of the CLIs dejima happens to have written
tests / confined (proxy) (pull_request_target) Successful in 1m9s
tests / confined (conversation) (pull_request_target) Successful in 1m20s
tests / runner-env (confined) (pull_request_target) Successful in 32s
tests / confined (shellcheck) (pull_request_target) Successful in 46s
tests / confined (providers) (pull_request_target) Successful in 2m1s
tests / confined (remote) (pull_request_target) Successful in 3m24s
tests / privileged (pull_request_target) Successful in 3m45s
tests / confined (static) (pull_request_target) Failing after 4m5s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (project) (pull_request_target) Successful in 4m55s
tests / confined (config) (pull_request_target) Successful in 6m40s
tests / confined (audit) (pull_request_target) Successful in 8m40s
The disclosure existed because this repo wrote two of the three forge
CLIs, not because anything had decided which artifacts get marked. So a
session under one forge opened PRs, filed issues and left comments that
said an agent wrote them, and under another it opened, filed and left
exactly the same things saying nothing - with the public forge being the
one that said nothing.

The wording is unchanged, and so is what gets marked. This only changes
where it happens, which is now somewhere both forges pass through.

Two behaviours follow from that and are intended. A command carrying no
body has none invented for it. And running a forge CLI on the host marks
nothing, because there the person running it wrote the body.
2026-08-22 23:26:29 -07:00
branderson 60e95a4659 Say which commands write an authored body, and pass it in
Not conditional on the workflow, unlike the merge block: a block is a
policy about what a workflow permits, this is a statement about who
wrote the text, which holds for every session whatever it may do.

The entrypoint now shadows every CLI named rather than the single one
whose merge is blocked - a forge can have a CLI per artifact, and one
with nothing blocked can still have something to disclose.
2026-08-22 22:40:39 -07:00
branderson 6dbcf4a6a7 Mark an agent-authored body in the shadow, for every forge
dejima wrote two of the three forge CLIs, so a disclosure that lives in
the tools is one that depends on which forge an operator picked. The
shadow is the one place they all pass through, and it already compares
argv, which is what finding a body argument needs.

Handles the four spellings both CLIs accept, including --body-file -,
where reading stdin here is the only chance to prepend to it at all.
Rewritten into a temp file rather than turned into --body, which would
put a whole body in argv against a 128KB per-argument limit. A command
with no body argument is passed through: there is nothing to mark.
2026-08-22 22:38:13 -07:00
branderson 7a57989a69 Let a forge declare which of its commands write an authored body
The marker itself stays core's: a forge says which commands carry a body
a person did not write, and what kind of artifact each one is. What
dejima then says about agent authorship is one wording in one place,
rather than one per forge that happens to have a CLI of its own.
2026-08-22 22:36:08 -07:00
branderson 4b73b370a8 Stop a flag before the subcommand walking past the merge block
tests / confined (audit) (pull_request_target) Successful in 8m58s
tests / confined (config) (pull_request_target) Successful in 7m0s
tests / confined (conversation) (pull_request_target) Successful in 1m25s
tests / confined (project) (pull_request_target) Successful in 5m15s
tests / confined (providers) (pull_request_target) Successful in 2m19s
tests / confined (proxy) (pull_request_target) Successful in 1m14s
tests / confined (remote) (pull_request_target) Successful in 3m48s
tests / confined (shellcheck) (pull_request_target) Successful in 52s
tests / privileged (pull_request_target) Successful in 3m56s
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (static) (pull_request_target) Failing after 4m18s
gh --repo owner/name pr merge N reached the real gh. The match was
anchored at the first non-flag argument, which for that command is
owner/name, so the guard gave up there and handed the merge over. That
is the ordinary way to name a PR from outside its checkout, and it
mattered the moment gh got behind the shadow at all.

The sequence is now looked for as a run of consecutive non-flag
arguments, anywhere in them. Knowing which flags take a separate value
would mean knowing each CLI, which is the coupling this guard exists to
avoid. The cost is that a command passing the blocked words as
consecutive values is refused; that is loud, and the other way round is
silent.

hooks/merge-guard.sh has the same hole and is not fixed here: it is
handed a command string rather than argv. It is also redundant now -
the shadow is linked whenever anything is blocked, so every session has
it, and only a harness with hooks has both. See #189.
2026-08-22 22:33:36 -07:00
branderson fec440f326 Hold every forge to naming a command that exists
The invariant #184 broke: a forge naming a command nothing installs is
not a weaker guard, it is a container that does not start. Checked
against install-forge-bins.sh's real output rather than against the
manifest, because what matters is where a command ends up.

The two tests that changed had encoded the old fact - that github ships
nothing - and are updated to the new one rather than relaxed. gh is
stubbed in both, so neither says anything about the machine it runs on.
2026-08-22 22:22:34 -07:00
branderson 8cbdb79f86 Declare gh, so the github forge can start
forge = github with workflow = worktree-pr never got past the
entrypoint: worktree-pr is the only workflow that blocks a merge, the
block names gh, and the entrypoint refuses to start when the command it
is meant to shadow is not behind the shadow. That refusal is right - a
merge block nobody can enforce must not look enforced - but gh had no
way to get there, so github failed in the one mode its capabilities
advertise and worked only in the modes where it never opens a PR.
2026-08-22 22:05:17 -07:00
branderson 2a1ab6e530 Let a forge declare a CLI the image already provides
A forge CLI has to sit behind the shadow whether or not this repo wrote
it: that is the fixed path forge-guard.sh execs the real command
through, and the entrypoint refuses to start when the command a workflow
blocks is not there. A forge whose CLI comes from a package had no way
to say so.

Declared in the manifest rather than special-cased in the image build,
so it stays true that reading a forge's manifest tells you everything
that forge puts on PATH.
2026-08-22 22:03:42 -07:00
branderson cedf3614d3 Merge pull request 'Actually keep the keyboard down when a tap is not meant to raise it' (#182) from webui-toolbar into development
Reviewed-on: #182
Reviewed-by: branderson <brad@branderson.io>
2026-08-22 21:28:19 -07:00
branderson 2de990e2a6 Merge pull request 'Say which config line is broken instead of blaming the harness' (#183) from install-config-diagnosis into development
Reviewed-on: #183
Reviewed-by: branderson <brad@branderson.io>
2026-08-22 21:25:28 -07:00
branderson d33be93a27 Merge remote-tracking branch 'origin/development' into webui-toolbar
tests / confined (proxy) (pull_request_target) Successful in 1m5s
tests / confined (conversation) (pull_request_target) Successful in 1m20s
tests / runner-env (confined) (pull_request_target) Successful in 33s
tests / confined (shellcheck) (pull_request_target) Successful in 50s
tests / confined (providers) (pull_request_target) Successful in 1m56s
tests / confined (remote) (pull_request_target) Successful in 3m16s
tests / privileged (pull_request_target) Successful in 3m32s
tests / confined (static) (pull_request_target) Successful in 3m44s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (project) (pull_request_target) Successful in 4m43s
tests / confined (config) (pull_request_target) Successful in 6m27s
tests / confined (audit) (pull_request_target) Successful in 8m31s
2026-08-22 20:38:43 -07:00
branderson b9e65838c5 Say which config line is broken instead of blaming the harness
tests / confined (proxy) (pull_request_target) Successful in 1m12s
tests / confined (conversation) (pull_request_target) Successful in 1m19s
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / confined (shellcheck) (pull_request_target) Successful in 51s
tests / confined (providers) (pull_request_target) Successful in 2m12s
tests / confined (remote) (pull_request_target) Successful in 3m34s
tests / confined (static) (pull_request_target) Successful in 3m50s
tests / privileged (pull_request_target) Successful in 4m6s
tests / runner-env (privileged) (pull_request_target) Successful in 36s
tests / confined (project) (pull_request_target) Successful in 5m6s
tests / confined (config) (pull_request_target) Successful in 6m47s
tests / confined (audit) (pull_request_target) Successful in 8m54s
A config that does not parse leaves config_get empty for every key, so
the harness read came back empty and install.sh reported that no harness
was set - to an operator looking straight at the harness line they had
just written, which was correct. The reason was on stderr the whole time
and thrown away.

The most likely way to arrive here is a config written for a newer dejima
than the checkout, since an unrecognised key is exactly what that looks
like from the older side.
2026-08-22 20:37:51 -07:00
branderson 3707cc2f52 Actually keep the keyboard down when a tap is not meant to raise it
The policy was hung on the textarea's focus event, which misses the case
it most needed to catch: focusing an element that is already focused
fires no focus event, while a tap on it still raises the keyboard. And
the terminal is focused as a matter of course, because the page focuses
it on every connect - before the preferences that decide the policy have
even arrived. So on a phone the keyboard came up on load and every tap
brought it back, with the handler never once running.

Three things now hold it down. The tap itself is watched, not only the
focus, with the blur deferred so xterm's own handler runs first. Focusing
on connect is skipped when the policy says the keyboard stays down.
Applying the policy drops a focus that is already standing.

A keyboard dismissed by a system gesture leaves its element focused and
fires no blur, which would have let one KBD unlock taps for the rest of
the session, so the visual viewport - the only thing that sees a keyboard
at all - re-locks when the keyboard goes.

Moved out of main.js on the way, because main.js is wiring and has no
tests, and this is logic that was wrong in a way tests would have caught.
The listeners now attach once and read a variable, rather than being
re-added on each policy change: preferences arrive again on every
reconnect.
2026-08-22 20:24:13 -07:00
branderson 2d615481d6 Merge pull request 'Add an Enter key and a way back to the picker, and stop taps raising the keyboard' (#181) from webui-toolbar into development
Reviewed-on: #181
Reviewed-by: branderson <brad@branderson.io>
2026-08-22 20:02:18 -07:00
branderson 678c8104ac Stop a tap on the terminal from raising the keyboard
tests / confined (proxy) (pull_request_target) Successful in 1m12s
tests / confined (conversation) (pull_request_target) Successful in 1m19s
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / confined (shellcheck) (pull_request_target) Successful in 48s
tests / confined (providers) (pull_request_target) Successful in 2m13s
tests / privileged (pull_request_target) Successful in 3m32s
tests / confined (remote) (pull_request_target) Successful in 3m34s
tests / confined (static) (pull_request_target) Successful in 3m42s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (project) (pull_request_target) Successful in 5m0s
tests / confined (config) (pull_request_target) Successful in 6m36s
tests / confined (audit) (pull_request_target) Successful in 8m33s
xterm.js focuses its textarea on every click, which is right on a desktop
and wrong on a phone: tapping is also how you scroll and select, so each
of those taps summoned the soft keyboard and cost a second tap to put it
away. KBD is now the only thing that asks for it.

Done by letting the focus land and immediately blurring, rather than by
intercepting taps. Focus arrives from several paths inside xterm.js and
catching each would mean knowing about each; this needs to know about
none, and leaves selection and scrolling alone. The toolbar never goes
through the textarea, so its keys work with the keyboard down.

A desktop is unaffected, since the default follows the pointer type. Both
overrides exist for the cases where the pointer is not the question:
?focus=1 / ?focus=0 for one client, and remote-access.ttyd_focus_on_tap
for every one of them. Unset stays unset all the way through rather than
becoming a host-wide default, because the pointer type is a property of
the client that the host cannot see.
2026-08-22 18:57:40 -07:00
branderson 6d2bd73efc Give the toolbar a way back to the session picker
Switching sandboxes from a phone meant editing the URL or closing the
tab, because the run-id reaches dejima-attach through ttyd's --url-arg on
the WebSocket URL and so can only change on a new connection. SESS
reloads the page without it, which is the picker, and keeps every other
parameter, since those configure the page rather than the command.

It sits between W-right and ZOOM, and leaving a session this way does not
disturb it: each client already gets its own tmux session grouped onto the
real one, with destroy-unattached set on the view rather than the sandbox.
2026-08-22 18:49:34 -07:00
branderson 3652af7c6a Give the toolbar an Enter key
Enter is the key a harness session asks for most, and every soft keyboard
puts it in the corner furthest from a thumb. It goes at the right of the
top row, where the arrow keys already are.

The row was a grid with `repeat(7, 1fr)` in it, 7 being how many buttons
there were, so an eighth wrapped the row. It is now a flex row of
equal-basis children, which divides itself however many buttons there
are, and the label size is clamped so eight of them still read on a
narrow phone.
2026-08-22 18:45:34 -07:00
branderson 1d60e9222f Merge pull request 'Fix the shipper's argv overflow, let cline be told its context window, and say what 'supported' does not mean' (#180) from ship-argv into development
Reviewed-on: #180
Reviewed-by: branderson <brad@branderson.io>
2026-08-22 17:18:33 -07:00
branderson db99d0c986 Say plainly that the two harnesses are not equally proven
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 36s
tests / confined (shellcheck) (pull_request_target) Successful in 52s
tests / confined (providers) (pull_request_target) Successful in 2m21s
tests / confined (remote) (pull_request_target) Successful in 3m45s
tests / privileged (pull_request_target) Successful in 3m48s
tests / confined (static) (pull_request_target) Successful in 4m20s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (project) (pull_request_target) Successful in 5m21s
tests / confined (config) (pull_request_target) Successful in 7m6s
tests / confined (audit) (pull_request_target) Successful in 9m12s
"Supported" means the same thing for both - a manifest, a plugin, tests, and
the capability bar met or declared unmet. It does not mean they have had the
same amount of use, and a reader who sees them listed side by side could
reasonably conclude otherwise.

Claude Code is what dejima was built around; nearly every mechanism here was
designed against it and generalised afterwards. Cline was built deliberately
and is tested, but a test only covers what someone thought to check: the first
run against a live model turned up a discarded provider entry, and the first
real session turned up an argv overflow in a component Cline does not own.
Neither is a sign of a bad integration - it is the normal rate of discovery for
something this new, which is exactly why the section says to expect more.

No counts in it. "Sessions run under it: thousands" is wrong within a month and
nothing checks it, which .dejima.md already rules out; what is stated instead
are properties that stay true - which harness the mechanisms were designed
against, which guards each can host, and what the first real use found.

The cline config keys join the table in configuration.md at the same time. They
were documented in prose but missing from the list of keys, which is the place
someone looks first.
2026-08-22 16:09:30 -07:00
branderson 2152bf7a11 Let cline be told the context window of a model it cannot recognise
Cline budgets against its own model catalogue. For a model that is not in it -
every model an operator serves themselves - it has no budget at all, so it
grows the conversation until the SERVER rejects the request rather than
compacting:

  rpc error: request (16465 tokens) exceeds the available context size (16384)

[harness.cline] context_window sets it. With a value, Cline compacts before it
reaches the limit; without one it behaves as before, which is right for a model
its catalogue does know.

Deliberately no default. The number belongs to the model being served: too high
changes nothing, too low compacts constantly. The docs say to set it a little
under the server's limit, because Cline's accounting is approximate and a
compaction does not always land under target when a single tool result is
large.

Where the value goes took three attempts to establish and is worth writing
down: contextWindow belongs directly in the provider's settings. A modelInfo
object beside or inside settings is accepted and silently ignored.
2026-08-22 16:09:30 -07:00
branderson dde5d78ec6 Stop passing the Loki payload as a command-line argument
"jq: argument list too long", and the batch dropped. A single argument is
capped at 128 KiB (MAX_ARG_STRLEN), and both the intermediate --argjson value
and the finished body were passed that way.

Not a remote possibility. The transform clips a message at sixteen kilobytes
and a batch holds twenty, so those numbers put the ceiling at well over the
cap by design - any harness whose tool results are large produces this as a
matter of course, and Cline's are. It went unnoticed because the harness the
shipper grew up with produces smaller ones.

The payload is now built in one jq pass and handed to curl on stdin with
-d @-. A shell variable is bounded by memory rather than by MAX_ARG_STRLEN, so
holding it is fine; passing it is not. sandbox-context.sh already documents
this exact trap for its own jq call, which is where the fix comes from.

The regression test ships five sixty-kilobyte lines and looks for a marker at
the start of the last one, so finding it means the whole oversized batch was
accepted rather than one small line arriving beside a dropped one.
2026-08-22 16:09:30 -07:00
branderson 0f38edccb1 Assert -h prints the same page as --help, not merely that it exits 0
tests / confined (proxy) (pull_request_target) Successful in 1m13s
tests / confined (conversation) (pull_request_target) Successful in 1m19s
tests / confined (shellcheck) (pull_request_target) Successful in 51s
tests / confined (providers) (pull_request_target) Successful in 2m14s
tests / runner-env (confined) (pull_request_target) Successful in 37s
tests / confined (remote) (pull_request_target) Successful in 3m36s
tests / privileged (pull_request_target) Successful in 3m38s
tests / runner-env (privileged) (pull_request_target) Successful in 33s
tests / confined (project) (pull_request_target) Successful in 5m1s
tests / confined (static) (pull_request_target) Successful in 3m51s
tests / confined (config) (pull_request_target) Successful in 6m39s
tests / confined (audit) (pull_request_target) Successful in 8m54s
The check was status-only, which a tool that ignored -h and happened to
succeed would pass while printing nothing like help. It now compares the
output of both, so the short form has to reach the same page rather than
merely reach an exit code.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Refs #64
2026-08-22 15:04:54 -07:00
73 changed files with 7745 additions and 296 deletions
+14 -7
View File
@@ -214,15 +214,22 @@ Branches here are opened against `development`, which merges to `main`
periodically. `main` stays releasable while work lands quickly; nothing else
about the workflow changes.
`gitea-pr` defaults its base to the repository's default branch, so say so:
Moving `development` to `main` is not an ordinary merge, and is not something
to do as a follow-on to finishing a branch. It has its own pass - suites,
security review, documentation review, and the manual checks nothing here can
run - in [docs/release.md](docs/release.md), and `test/release-pass.sh` runs
what can be run and prints the rest. If you are asked to prepare a release,
start there rather than opening the pull request first.
```sh
gitea-pr create --base development --title ... --body-file -
```
`gitea-pr` prints the base it is opening against, every time, and reads
`git config gitea.prBase` before falling back to the repository's default
branch. On a checkout with that set, `gitea-pr create` targets the right
branch with no flag; on one without it, the line it prints is what tells you
otherwise. Pass `--base development` when you want to be sure.
Forgetting is the likely mistake, and it is recoverable - a pull request's
base can be changed after the fact, in the web UI or through the API. Check
what you opened rather than assuming.
Report the base it printed, not the one you meant. A pull request URL looks
identical whichever branch it is against, so nothing else will contradict a
wrong claim.
Rebase onto `origin/development` too, for the same reason: rebasing onto
`main` puts anything already merged to `development` back in your diff.
+5
View File
@@ -29,3 +29,8 @@ npm:cline
apt:tmux
apt:bats
# The other half of what CI runs. The CI job installs it itself, so without
# this line a sandbox is the one place the gate cannot be run at all - and a
# base image that happens to carry it is not a guarantee, as one that stopped
# carrying it demonstrated.
apt:shellcheck
+42 -16
View File
@@ -27,23 +27,23 @@
**Agents work best without you babysitting them. Bound the blast radius, let them
work, and assume they are hostile.**
An agent with permission prompts turned off gets a lot done. It can also be
turned against you mid-session, by a prompt injection or a poisoned
dependency, and it will not announce that it has been. dejima is built for
that case: the agent works without asking, and what it can reach, take and
break was decided before it started.
Most sandboxes stop at the container. The blast radius does not. A contained
session still opens pull requests, and your CI runs that commit before anyone
reads it; an escape from the container lands on a machine with its own reach
across your network. **dejima puts a control on every layer it passes
through.**
dejima is a **sandboxed development environment for unsupervised agentic
work.** It runs a coding agent (Claude Code, or whatever command you point it
at) inside a firewalled Docker container with `--dangerously-skip-permissions`
(no permission prompts, all tool use auto-accepted), against a project
bind-mounted from the host. The agent gets to work without stopping to ask;
the container is what makes that trade a reasonable one.
work**: a coding agent (Claude Code, or whatever command you point it at)
running with `--dangerously-skip-permissions` inside a firewalled Docker
container, against a project bind-mounted from your host. A prompt injection or
a poisoned dependency can turn it mid-session and it will not announce that, so
what it can reach, take and break is decided before it starts.
A container on its own is only confinement. What makes this a development
environment is the rest: a forge workflow the agent drives itself, project
context written for the agent rather than for you, and enough of a trail left
behind that you can walk away from a session and still pick up what it did.
A container alone is only confinement. The rest is what makes it somewhere to
work: a forge workflow the agent drives itself, project context written for the
agent rather than for you, and enough of a trail that you can walk away from a
session and still pick up what it did.
**What bounds it:**
@@ -82,7 +82,9 @@ behind that you can walk away from a session and still pick up what it did.
- **A PR and issue workflow the agent drives itself.** `gitea-pr` and
`gitea-issue` let a session open pull requests and issues against Gitea or
Forgejo and pull review feedback back in, on a token scoped for exactly
that. Merging stays a human decision, made outside the sandbox.
that; on GitHub the same work goes through `gh`, plus a `github-pr` for the
one thing `gh` cannot do. Merging stays a human decision, made outside the
sandbox.
- **Project context written for the agent.** A project ships a `.dejima.md`
saying how work is done in it - `CLAUDE.md`'s job under a harness-agnostic
name - and dejima loads it alongside a briefing on where the session is,
@@ -422,6 +424,26 @@ dejima --ro ~/other-project ~/myproject
**→ [docs/configuration.md](docs/configuration.md#one-off-mounts-for-a-single-launch)** -
both forms, and which host paths a mount is refused over and why.
### Ctrl-Z backgrounds the whole thing
Suspending a session 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 the same as 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`](docs/remote-access.md).
## Sign in on the host first
**Install your harness on the host and authenticate it there before running
@@ -659,7 +681,10 @@ finding is something to read and clean up before your next `git` or `claude`
in that repo. Its diff is also time-windowed: it reports that a path changed
while the session was running, not that the session changed it.
`dejima-audit show` indexes what is waiting; `dejima-audit ack` walks it.
`dejima-audit show` indexes what is waiting; `dejima-audit ack` walks it,
one finding at a time, with `[o]` to open the file before deciding. A report
walked to the end stops being counted as waiting; it stays on disk as the
record of what that session did, and rotation is what eventually removes it.
Approving records the exact content, so any later change re-fires; ignoring
records the surface, so that path is fine whatever it later contains; and
skipping decides nothing, which means you are asked again next session.
@@ -699,6 +724,7 @@ covers, and how to run them.
| [responsible-use.md](docs/responsible-use.md) | How to use this without making things worse |
| [security.md](docs/security.md) | Provider trust, the egress firewall, identity, and logging |
| [threat-model.md](docs/threat-model.md) | What is assumed hostile, and where each layer's compromise stops |
| [research-review.md](docs/research-review.md) | Checking the threat model against the field's, and the standing questions that are the agenda |
| [configuration.md](docs/configuration.md) | Per-project declaration, dependencies, config parity |
| [providers.md](docs/providers.md) | Forge, workflow and harness axes, and writing your own |
| [ci.md](docs/ci.md) | What push access grants on your CI host, and how to bound it |
+447 -9
View File
@@ -9,6 +9,59 @@
# dejima ~/repos/myproject
set -euo pipefail
# --help, before anything else runs.
#
# Deliberately ahead of the library sourcing and the config parse below: the
# moment someone reaches for --help is often the moment the config is missing
# or wrong, and a help page that needs a working config to print is no help.
# Nothing here but `cat`.
#
# stdout and exit 0, unlike usage(), which stays on stderr with a nonzero exit
# so a mistyped invocation still fails a script. That split is the whole of
# what #64 asked for: bare invocation keeps its terse line, --help explains.
case "${1:-}" in
-h|--help)
cat <<'DEJIMA_HELP'
dejima - run an agent harness in a container that cannot reach your machine
Usage: dejima [options] [workspace] [-- args...]
workspace project directory to mount read-write (default: current directory)
-- args... everything after is passed to the harness command untouched
Options (each overrides the config file for one launch only):
--harness <name> which harness to run
--forge <name> which forge integration to use
--workflow <name> what the agent is told to do with its work
--cmd <command> run this instead of the harness's own command
--identity <name> which [identity.<name>] supplies the credentials
-i, --include <tool> add an optional tool to this launch (repeatable);
see docs/tools.md for what dejima ships
--model <id> the selected harness's model, where it has one;
--base-url <url> its endpoint, and how much context it may use.
--context-window <n> Refused for a harness with no such setting
--ro <path> mount read-only; --rw read-write. Repeatable. Long
forms --extra-readonly and --extra-read-write. Both
refuse a path overlapping dejima's own stores
--allow-github-push open GitHub's push path (port 22)
--strict-audit refuse to start over unacknowledged CRITICAL findings
--no-log ship nothing to the log endpoint this launch
--no-conversation keep this session's transcript out of the log
--no-epitaph keep nothing if this session compacts
--no-firewall disable the egress firewall entirely (debugging only)
--print-config print the resolved configuration and exit
--print-briefing print the briefing the agent will get, and exit
-h, --help this message
Every --no-* flag only ever narrows. Nothing here turns a setting back on: a
config that says off is a decision, not a default to override in passing.
Config lives in ~/.config/dejima/config and [core] harness must be set before
a launch starts. --print-config prints every key as it resolves.
DEJIMA_HELP
exit 0 ;;
esac
# readlink -f resolves through the ~/.local/bin/dejima symlink
# install.sh sets up - without it, BASH_SOURCE[0] is the symlink's own path
# when invoked by name via PATH, so dirname finds ~/.local/bin instead of
@@ -66,10 +119,14 @@ fi
FLAG_OVERRIDES=()
_set_override() {
local key="$1" value="$2" type entry
# $3 is the flag to blame, for the flags whose spelling is not their key's:
# --context-window sets context_window, and reporting the key back would
# name an option the operator cannot type.
local flag="${3:---${1##*.}}"
local -a kept=()
type="$(config_type "$key")" || { echo "unknown config key: $key" >&2; exit 1; }
if ! _config_valid_value "$type" "$value"; then
echo "invalid value for --${key##*.}: $value" >&2
echo "invalid value for $flag: $value" >&2
exit 1
fi
# A repeated flag takes the last value, same as a repeated key in the
@@ -145,9 +202,26 @@ _mount_label() {
esac
}
# A flag naming one of the SELECTED harness's keys, deferred because which
# harness is selected is not known until every flag has been read - --harness
# may come after --model. Recorded here, resolved below the loop.
#
# Deferred rather than resolved eagerly so the error can name the harness:
# "claude-code has no model setting" is actionable where "unknown config key"
# is not.
HARNESS_KEY_FLAGS=()
_defer_harness_key() {
HARNESS_KEY_FLAGS+=("$1=$2=$3")
}
PRINT_CONFIG=0
PRINT_BRIEFING=0
ALLOW_GITHUB_PUSH_FLAG=0
# Additive, like --ro and --rw and unlike every other flag here: an optional
# tool asked for on the command line is one MORE than the config and the
# project already agreed on, not a replacement for them. Repeatable.
TOOL_FLAGS=()
NO_LOG=0
while [ $# -gt 0 ]; do
case "$1" in
--) shift; break ;;
@@ -155,8 +229,27 @@ while [ $# -gt 0 ]; do
--print-briefing) PRINT_BRIEFING=1; shift ;;
--no-firewall) _set_override core.firewall off; shift ;;
--allow-github-push) ALLOW_GITHUB_PUSH_FLAG=1; shift ;;
# Narrowing flags. Each has no counterpart that turns the setting back
# on: a config saying off is a decision, and a flag that could undo it
# in passing would make the file's answer the weaker one.
--strict-audit) _set_override audit.strict on; shift ;;
--no-epitaph) _set_override epitaph.enabled off; shift ;;
--no-conversation) _set_override logging.conversation off; shift ;;
--no-log) _set_override logging.loki_url ""
_set_override logging.conversation off
NO_LOG=1; shift ;;
--model) _need_value --model "${2:-}"
_defer_harness_key --model model "$2"; shift 2 ;;
--base-url) _need_value --base-url "${2:-}"
_defer_harness_key --base-url base_url "$2"; shift 2 ;;
--context-window) _need_value --context-window "${2:-}"
_defer_harness_key --context-window context_window "$2"; shift 2 ;;
--cmd) _need_value --cmd "${2:-}"; _set_override core.command "$2"; shift 2 ;;
--harness) _need_value --harness "${2:-}"; _set_override core.harness "$2"; shift 2 ;;
--identity) _need_value --identity "${2:-}"; _set_override core.identity "$2"; shift 2 ;;
--include|-i)
_need_value --include "${2:-}"
TOOL_FLAGS+=("$2"); shift 2 ;;
--forge) _need_value --forge "${2:-}"; _set_override core.forge "$2"; shift 2 ;;
--workflow) _need_value --workflow "${2:-}"; _set_override core.workflow "$2"; shift 2 ;;
--extra-readonly|--ro)
@@ -174,15 +267,52 @@ done
# Defined here rather than in the library because an override is a property
# of one invocation of this launcher, not of the config file.
config_get() {
local key="$1.$2" entry
local key="$1.$2" entry value
for entry in ${FLAG_OVERRIDES[@]+"${FLAG_OVERRIDES[@]}"}; do
[ "${entry%%=*}" = "$key" ] || continue
printf '%s' "${entry#*=}"
return 0
done
# The selected identity, for the handful of keys an identity owns: the
# credentials and the ssh key a PERSON brings, which is the half a shared
# project file cannot carry. Below a flag, because a flag is this launch
# and an identity is this project; above the file, because the file is the
# machine's default and naming an identity is how you say "not that one".
#
# Every consumer goes through config_get, including forge_env_exports in
# the library, so this one wrapper is the whole insertion point.
if value="$(identity_get "$1" "$2")"; then
printf '%s' "$value"
return 0
fi
_config_get_from_file "$1" "$2"
}
# The deferred harness-key flags, now that --harness has been read and
# config_get honours it. A key the selected harness does not declare is
# refused by name rather than ignored: a --model that silently did nothing
# because the harness has no such setting is the failure worth avoiding, and
# it is the whole reason these are not resolved inside the loop above.
if [ "${#HARNESS_KEY_FLAGS[@]}" -gt 0 ]; then
_flag_harness="$(config_get core harness)"
for _entry in "${HARNESS_KEY_FLAGS[@]}"; do
_flag="${_entry%%=*}"
_rest="${_entry#*=}"
_key="${_rest%%=*}"
_value="${_rest#*=}"
if [ -z "$_flag_harness" ]; then
echo "$_flag needs a harness: set [core] harness or pass --harness." >&2
exit 1
fi
if ! config_type "harness.$_flag_harness.$_key" >/dev/null 2>&1; then
echo "the $_flag_harness harness has no '$_key' setting, so $_flag does not apply to it." >&2
exit 1
fi
_set_override "harness.$_flag_harness.$_key" "$_value" "$_flag"
done
unset _flag_harness _entry _flag _rest _key _value
fi
# Retired environment variables. Named, never honoured: silently ignoring an
# exported LOKI_URL means logs stop shipping with nothing saying why, and
# silently ignoring SANDBOX_ANTHROPIC_TRANSPORT means a session quietly gets
@@ -311,6 +441,18 @@ fi
# testable without booting Docker.
if [ "$PRINT_CONFIG" = 1 ]; then
config_validate || exit 1
# Before printing, not only before launching. Every value below resolves
# THROUGH the identity, so printing them under a name that resolves to
# nothing would show this machine's own credentials as though they were
# the identity's - the exact confusion the refusal exists to prevent, in
# the one command an operator runs to check what they configured.
_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
while IFS= read -r _key; do
_section="${_key%.*}"
_leaf="${_key##*.}"
@@ -412,6 +554,44 @@ case " $_corpus_approved " in
esac
unset _corpus_approved
# The optional tools this launch has: [core] tools, plus whatever the project
# declared AND the operator approved, plus --include. A union rather than a
# precedence chain, because each of the three answers a different question -
# what this machine always wants, what this project needs, what this run is
# for - and none of them is a correction of another.
#
# The project's half comes from the APPROVAL STORE and never from the
# declaration in the workspace, for the reason the corpus block above gives:
# that file is session-writable, and a session that could add a tool would be
# choosing what the next session's image contains. This file may not name that
# directory at all, and a static test holds it to that - which is why the path
# is not spelled out here.
#
# Every name is checked against a manifest that exists before it reaches the
# image build. approved_entries already refuses an entry its validator does
# not accept, so the only unchecked source is the flag - which is the
# operator's own typing, and gets the same answer.
DEJIMA_TOOLS=""
_tools_wanted="$(config_get core tools | tr ':' ' ')"
_tools_wanted="$_tools_wanted $(approved_entries "$(config_root)" tools | tr '\n' ' ')"
if [ -n "$PROJECT_STORE" ]; then
_tools_wanted="$_tools_wanted $(approved_entries "$PROJECT_STORE" tools | tr '\n' ' ')"
fi
_tools_wanted="$_tools_wanted ${TOOL_FLAGS[*]-}"
for _tool in $_tools_wanted; do
[ -n "$_tool" ] || continue
case " $DEJIMA_TOOLS " in *" $_tool "*) continue ;; esac
if ! valid_tool_name "$_tool"; then
echo "no optional tool named '$_tool'." >&2
echo "Available: $(provider_list tool | tr '\n' ' ')" >&2
echo "See docs/tools.md." >&2
exit 1
fi
tool_validate "$_tool" || exit 1
DEJIMA_TOOLS="${DEJIMA_TOOLS:+$DEJIMA_TOOLS }$_tool"
done
unset _tools_wanted _tool
AUDIT_DIR="$(config_get audit dir)"
BASELINE_DIR="$(config_get audit baselines_dir)"
@@ -604,6 +784,61 @@ else
DEJIMA_MERGE_BLOCK=""
fi
# Which of this forge's commands write a body the session authored, as one
# record per line for the shadow to read: "<kind> <command prefix>".
#
# Not conditional on the workflow, unlike the merge block above. A merge block
# is a policy about what a workflow permits; this is a statement about who
# wrote the text, which is true of every session whatever it is allowed to do.
# The comma-separated manifest form is turned into lines here rather than in
# the shadow, so the parsing sits next to the schema that validates it.
DEJIMA_DISCLOSE=""
_disclose_spec="$(manifest_get forge "$DEJIMA_FORGE_NAME" disclose)"
if [ -n "$_disclose_spec" ]; then
_old_ifs="$IFS"
IFS=,
for _entry in $_disclose_spec; do
_entry="${_entry#"${_entry%%[![:space:]]*}"}"
_entry="${_entry%"${_entry##*[![:space:]]}"}"
[ -n "$_entry" ] || continue
DEJIMA_DISCLOSE="${DEJIMA_DISCLOSE}${DEJIMA_DISCLOSE:+$'\n'}${_entry%%:*} ${_entry#*:}"
done
IFS="$_old_ifs"
fi
# How this forge's CLI can be aimed somewhere other than the repository the
# session was launched against, and what that repository is (#187).
#
# A CLI that resolves the repo from origin and refuses another host cannot be
# aimed anywhere and declares nothing here. One that takes the repository as
# an option makes acting on an unrelated repository the same length of command
# as acting on this one. Which is which is the forge's to declare; core only
# resolves the repository the declaration is compared against, and
# docker/forge-guard.sh does the comparing.
#
# The last two path components of origin's URL, which is the same
# "<owner>/<name>" both forges use and covers ssh, scp-style and https
# remotes without parsing any of them as a URL. No origin, or a workspace
# that is not a checkout, leaves this empty and the guard does nothing: the
# comparison would have nothing to be right about.
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=""
if [ -n "$DEJIMA_FORGE_REPO_FLAGS" ] || [ -n "$DEJIMA_FORGE_REPO_ENV" ]; then
_origin_url="$(git -C "$WORKSPACE_DIR" remote get-url origin 2>/dev/null)" || _origin_url=""
if [ -n "$_origin_url" ]; then
_origin_url="${_origin_url%/}"
_origin_url="${_origin_url%.git}"
_origin_name="${_origin_url##*/}"
_origin_rest="${_origin_url%/*}"
_origin_owner="${_origin_rest##*[:/]}"
if [ -n "$_origin_owner" ] && [ -n "$_origin_name" ] &&
[ "$_origin_owner" != "$_origin_url" ]; then
DEJIMA_FORGE_REPO="$_origin_owner/$_origin_name"
fi
fi
fi
# WORKSPACE_DIR is resolved above, ahead of --print-briefing, since
# briefing_compose needs DEJIMA_RW_MOUNTS (which needs WORKSPACE_DIR for its
# own overlap guard) before that point.
@@ -809,6 +1044,9 @@ SANDBOX_NVIM_LOCKFILE=""
SANDBOX_DERIVED_DOCKERFILE=""
SANDBOX_DERIVED_CONTEXT=""
SANDBOX_CONVERSATION_SHIP_PID=""
SANDBOX_TTY_SUSPEND_PID=""
SANDBOX_TTY_MODES=""
SANDBOX_CLIENT_PID=""
# Names each host-side task in cleanup() as it starts. Between the container
# exiting and the operator's shell prompt returning, dejima audits every
@@ -855,6 +1093,20 @@ cleanup() {
# rather than dying with the batch. Reaped for the same reason the proxy
# is: an unreaped child reparents to PID 1 and stays a zombie wherever
# PID 1 does not reap.
# The terminal first, and its settings before their watcher: the
# container run restores what it saved when it attached, but a run that
# was killed rather than exited restores nothing, and what that leaves
# behind is a terminal with no interrupt character.
if [ -n "${SANDBOX_TTY_MODES:-}" ]; then
stty "$SANDBOX_TTY_MODES" < /dev/tty 2>/dev/null || true
# And the cursor, for the same reason the modes are restored here: a
# run that was killed rather than exited put neither back.
printf '\033[?25h' > /dev/tty 2>/dev/null || true
fi
if [ -n "${SANDBOX_TTY_SUSPEND_PID:-}" ]; then
kill "$SANDBOX_TTY_SUSPEND_PID" 2>/dev/null || true
wait "$SANDBOX_TTY_SUSPEND_PID" 2>/dev/null || true
fi
if [ -n "${SANDBOX_CONVERSATION_SHIP_PID:-}" ]; then
kill "$SANDBOX_CONVERSATION_SHIP_PID" 2>/dev/null || true
wait "$SANDBOX_CONVERSATION_SHIP_PID" 2>/dev/null || true
@@ -903,7 +1155,20 @@ cleanup() {
# Ahead of the audit rather than after it, so the audit keeps the last
# word (see below): this prints a line of its own whenever it keeps
# something, and that line was landing under the verdict.
"$SCRIPT_DIR/dejima-epitaph" save "$WORKSPACE_DIR" || true
# Gated here as well as inside the tool, because a flag override lives in
# this process and the tool re-reads the config for itself - it is also
# run by hand, where an environment this launcher happened to have would
# be the wrong authority. Same reason the shipper is gated at its start.
#
# DEJIMA_EPITAPH_NO_SHIP carries the one part of --no-log that this
# process cannot decide for the tool: whether to push what it kept. It
# only ever suppresses, so a stale export makes epitaph quieter and never
# louder, which is what makes it safe to honour from the environment at
# all.
if [ "$(config_get_bool epitaph enabled)" = "1" ]; then
DEJIMA_EPITAPH_NO_SHIP="$NO_LOG" \
"$SCRIPT_DIR/dejima-epitaph" save "$WORKSPACE_DIR" || true
fi
# Last writer, structurally: the operator reads bottom-up from their
# shell prompt, so anything printed after the verdict pushes it out of
# view. The marker-directory warning above is the one line a session can
@@ -938,6 +1203,23 @@ if [ -f "$HOME/.gitconfig" ]; then
else
touch "$SANDBOX_GITCONFIG_DIR/.gitconfig"
fi
# The selected identity's git name, appended to the COPY. Appended rather than
# rewritten because git takes the last value for a key, so this needs no
# knowledge of what the operator's own file already says - and because the
# copy is the only thing being edited, the real ~/.gitconfig is untouched
# whatever happens here.
#
# `git config -f` rather than a printf of "[user]\n\tname = ...": the value is
# the operator's own text, and git quotes and escapes its own file format
# correctly where a printf would produce something git reads differently the
# moment a name contains a quote or a backslash.
for _git_field in name email; do
_git_value="$(identity_git_field "user_$_git_field")" || _git_value=""
[ -n "$_git_value" ] || continue
git config -f "$SANDBOX_GITCONFIG_DIR/.gitconfig" "user.$_git_field" "$_git_value" \
|| { echo "could not set user.$_git_field from [identity] - is git installed?" >&2; exit 1; }
done
unset _git_field _git_value
export WORKSPACE_DIR
# The image's user is built to match this uid/gid/username exactly (see
@@ -1088,8 +1370,26 @@ LOKI_URL="$(config_get logging loki_url)"; export LOKI_URL
extra_mounts=()
# The forge's own env vars, generated from its manifest's env_export list
# rather than hardcoded - see forge_env_exports. -e here: passed straight
# through to `docker compose run`, which takes -e alongside -v.
# rather than hardcoded - see forge_env_exports.
#
# `-e NAME`, never `-e NAME=value`, for the ones that carry a secret - which
# is every `@` reference, since those resolve to a credential FILE'S CONTENTS
# rather than its path. A value in the argument list lands in
# /proc/<pid>/cmdline, which is world-readable: `ps` prints the token to any
# process on the host, and from there into whatever captures ps output. That
# is not a hypothetical - it is how #226 was found, in a paste an operator
# made while debugging something unrelated.
#
# The bare form takes the value from THIS process's environment instead,
# where it sits in /proc/<pid>/environ - mode 0400, owner only.
#
# This repository already made the argument one layer down: a forge's own CLI
# feeds curl its auth header on stdin rather than as an argument, and says why
# in almost these words. The reasoning was right and it had not reached here.
#
# Docker still shows the resolved value to anyone who can `docker inspect`
# the container, which the bare form does not change and cannot. What it
# changes is the audience: daemon access rather than any process at all.
_forge_env_lines=""
if ! _forge_env_lines="$(forge_env_exports "$DEJIMA_FORGE_NAME")"; then
# forge_env_exports has already explained why on stderr. It can fail
@@ -1099,8 +1399,15 @@ if ! _forge_env_lines="$(forge_env_exports "$DEJIMA_FORGE_NAME")"; then
fi
while IFS= read -r _forge_env; do
[ -n "$_forge_env" ] || continue
extra_mounts+=(-e "$_forge_env")
# forge_env_exports emits nothing at all for a value that resolved empty,
# deliberately - see its own comment on why an empty -e for a variable
# compose already declares is not harmless. So every name reaching here
# has a value, and exporting it is what makes the bare -e below carry one.
_forge_env_name="${_forge_env%%=*}"
export "${_forge_env_name}=${_forge_env#*=}"
extra_mounts+=(-e "$_forge_env_name")
done <<< "$_forge_env_lines"
unset _forge_env_name
# The resolved combination, for the briefing and the hooks that read it
# (git-push-guard.sh's host allowlist and no-direct-push branches,
@@ -1113,6 +1420,10 @@ extra_mounts+=(-e "DEJIMA_FORGE_HOSTS=$DEJIMA_FORGE_HOSTS")
extra_mounts+=(-e "DEJIMA_PUSH_HOSTS=$DEJIMA_PUSH_HOSTS")
extra_mounts+=(-e "DEJIMA_BLOCKED_BRANCHES=$DEJIMA_BLOCKED_BRANCHES")
extra_mounts+=(-e "DEJIMA_MERGE_BLOCK=$DEJIMA_MERGE_BLOCK")
extra_mounts+=(-e "DEJIMA_DISCLOSE=$DEJIMA_DISCLOSE")
extra_mounts+=(-e "DEJIMA_FORGE_REPO=$DEJIMA_FORGE_REPO")
extra_mounts+=(-e "DEJIMA_FORGE_REPO_FLAGS=$DEJIMA_FORGE_REPO_FLAGS")
extra_mounts+=(-e "DEJIMA_FORGE_REPO_ENV=$DEJIMA_FORGE_REPO_ENV")
# The harness's own configuration, mounted at the mode it declared, and -
# for the directories it marked scannable - the symlink targets that have to
@@ -1647,6 +1958,7 @@ SANDBOX_HARNESS_IMAGE_FILES="$(manifest_get harness "$DEJIMA_HARNESS_NAME" image
if [ -n "$SANDBOX_HARNESS_INSTALL" ] || [ -n "$SANDBOX_HARNESS_IMAGE_DIRS" ] \
|| [ -n "$SANDBOX_HARNESS_IMAGE_ENV" ] || [ -n "$SANDBOX_HARNESS_IMAGE_FILES" ] \
|| [ -n "$DEJIMA_TOOLS" ] \
|| [ -s "$(config_root)/dependencies" ] \
|| { [ -n "$PROJECT_STORE" ] && [ -s "$PROJECT_STORE/dependencies" ]; }; then
SANDBOX_DERIVED_DOCKERFILE="$(mktemp "${TMPDIR:-/tmp}/dejima-derived.XXXXXX")"
@@ -1675,6 +1987,34 @@ if [ -n "$SANDBOX_HARNESS_INSTALL" ] || [ -n "$SANDBOX_HARNESS_IMAGE_DIRS" ] \
SANDBOX_IMAGE="$tag"
fi
# One layer per tool, above the harness and below the dependencies. Above the
# harness because a tool is meant to be on PATH for it; below the operator's
# own dependencies because those are the last word on what the image holds.
#
# Tagged by name and version-of-spec so two projects wanting the same tool
# share a layer, and a manifest that changes what it installs gets a new one
# rather than a stale cached hit.
for _tool in $DEJIMA_TOOLS; do
_tool_install="$(manifest_get tool "$_tool" install)"
_tool_env="$(manifest_get tool "$_tool" image_env)"
_tool_layout="$(manifest_get tool "$_tool" install_layout)"
tag="dejima:${SANDBOX_USER}-tool-${_tool}-$(printf '%s' "$_tool_install" | sha256sum | cut -c1-12)"
DEJIMA_TARBALL_STRIP="$_tool_layout" \
generate_spec_dockerfile "$SANDBOX_IMAGE" "$_tool_install" "" "$_tool_env" "" \
> "$SANDBOX_DERIVED_DOCKERFILE"
echo "Building $tag ($_tool: $(manifest_get tool "$_tool" description))..."
if ! docker build -t "$tag" -f "$SANDBOX_DERIVED_DOCKERFILE" "$SANDBOX_DERIVED_CONTEXT"; then
# No fallback, for the reason the harness layer gives: a session told
# it has a tool and given an image without one fails inside the
# container, where it reads as dejima being broken.
echo "Failed to build $tag." >&2
echo "$_tool installs from: $_tool_install" >&2
exit 1
fi
SANDBOX_IMAGE="$tag"
done
unset _tool _tool_install _tool_env _tool_layout
if [ -s "$(config_root)/dependencies" ]; then
build_dep_layer "dejima:${SANDBOX_USER}-global" "$(config_root)"
fi
@@ -1772,10 +2112,108 @@ compose_status=0
#
# `|| true` and a backgrounded start: shipping is a bonus on top of the
# session, so a shipper that cannot start must not stop the session running.
"$SCRIPT_DIR/dejima-conversation-ship" "$WORKSPACE_DIR" &
SANDBOX_CONVERSATION_SHIP_PID=$!
#
# Gated here as well as inside the shipper, which checks the same two keys for
# itself: --no-conversation and --no-log are overrides held in this process,
# and the shipper re-reads the config rather than inheriting them.
if [ "$(config_get_bool logging conversation)" = "1" ] &&
[ -n "$(config_get logging loki_url)" ]; then
"$SCRIPT_DIR/dejima-conversation-ship" "$WORKSPACE_DIR" &
SANDBOX_CONVERSATION_SHIP_PID=$!
fi
docker compose -f "$COMPOSE_FILE" run --rm ${extra_mounts[@]+"${extra_mounts[@]}"} sandbox "$@" || compose_status=$?
# Ctrl-Z (#179). Two things have to be true at once, and the second is the
# one that took four rounds of measurement to find.
#
# The keystroke has to become a signal at all. Under a container run the
# terminal is raw, ISIG is off, and Ctrl-Z is the byte 0x1a delivered into the
# container - where a harness that implements suspend stops itself, with no
# job-control shell in there to continue it. dejima-tty-suspend takes that one
# character back for the host.
#
# And the container client must never receive SIGTSTP. Measured, holding
# everything else equal: the client survives SIGSTOP and does not survive
# SIGTSTP. A catchable stop signal interrupts the read it is blocked in, its
# input forwarding never recovers, and the session comes back alive but deaf -
# resumed, with nothing typed reaching the harness. That is what shipped once
# and had to be taken out again.
#
# So the client is started with SIGTSTP ignored, which it inherits and keeps
# across exec, and the launcher converts the keystroke into an explicit
# SIGSTOP for it before stopping itself. The job still stops completely, so
# the prompt comes back; the client is stopped by a signal that interrupts
# nothing.
#
# < /dev/tty because a background command is given /dev/null for stdin, and a
# container client with no terminal reads EOF and exits at once.
SANDBOX_TTY_MODES="$(stty -g < /dev/tty 2>/dev/null)" || SANDBOX_TTY_MODES=""
if [ -n "$SANDBOX_TTY_MODES" ]; then
"$SCRIPT_DIR/dejima-tty-suspend" &
SANDBOX_TTY_SUSPEND_PID=$!
trap '' TSTP
docker compose -f "$COMPOSE_FILE" run --rm ${extra_mounts[@]+"${extra_mounts[@]}"} sandbox "$@" < /dev/tty &
SANDBOX_CLIENT_PID=$!
trap - TSTP
# The pid $! names is the docker CLI; `docker compose run` then runs the
# compose plugin as a child of it, and the child is what holds the
# terminal. Stopping only the pid leaves that child in raw mode with the
# shell also on the terminal - a cursorless prompt that resumes to
# nothing, which is what the first version of this fix produced.
#
# Deepest first, so nothing is left running long enough to fork again;
# shallowest first on the way back, so a parent is ready before its child
# resumes.
_stop_tree() {
local p="$1" kid
for kid in $(pgrep -P "$p" 2>/dev/null); do _stop_tree "$kid"; done
kill -STOP "$p" 2>/dev/null || true
}
_cont_tree() {
local p="$1" kid
kill -CONT "$p" 2>/dev/null || true
for kid in $(pgrep -P "$p" 2>/dev/null); do _cont_tree "$kid"; done
}
_on_tstp() {
_stop_tree "$SANDBOX_CLIENT_PID"
# Show the cursor again. A full-screen harness hides it (DECTCEM,
# \033[?25l) and puts it back when it exits - but a suspend is not an
# exit, so the shell prompt inherits an invisible cursor and the
# terminal looks like a connection that died. The harness hides it
# again on resume, when the SIGWINCH below makes it repaint.
printf '\033[?25h' > /dev/tty 2>/dev/null || true
# Default disposition to actually stop, then put the handler back for
# the next time. Everything after the re-raise runs on resume.
trap - TSTP
kill -TSTP $$
trap _on_tstp TSTP
_cont_tree "$SANDBOX_CLIENT_PID"
}
trap _on_tstp TSTP
# Looped because `wait` returns early when a trap runs, which is exactly
# what happens on every suspend.
while kill -0 "$SANDBOX_CLIENT_PID" 2>/dev/null; do
if wait "$SANDBOX_CLIENT_PID"; then
compose_status=0
else
compose_status=$?
fi
# 128+n means a signal interrupted the wait rather than the client
# exiting; anything else is the client's own status.
case "$compose_status" in
146|148) continue ;;
esac
break
done
trap - TSTP
else
# No terminal - a CI job, or output redirected. Nothing to suspend, and
# /dev/tty would not open.
docker compose -f "$COMPOSE_FILE" run --rm ${extra_mounts[@]+"${extra_mounts[@]}"} sandbox "$@" || compose_status=$?
fi
if [ -n "${SANDBOX_ANTHROPIC_PROXY_PID:-}" ] && ! kill -0 "$SANDBOX_ANTHROPIC_PROXY_PID" 2>/dev/null; then
echo "Warning: the credential proxy exited before the session ended - API calls in" >&2
echo "the session may have been failing. Check for proxy errors in the output above." >&2
+76 -3
View File
@@ -17,6 +17,43 @@
# the thing running right now". Pass --container as well to do both.
set -euo pipefail
# --help before anything else runs: no libraries sourced, no config read. The
# moment someone reaches for it is often the moment the config is wrong.
# stdout and exit 0, where usage() stays on stderr and exits nonzero.
case "${1:-}" in
-h|--help)
cat <<'DEJIMA_ALLOW_HELP'
dejima-allow - let a sandbox reach a domain the egress firewall blocks
Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>
Scope, which decides who the grant is for and how long it lasts:
(none) the running container, right now, until it exits. Finds it
if exactly one is running; refuses if none or several.
--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.
--project [path] every future session in one project. Also writes the domain
to that project's .dejima/allowed-domains, so it is shared
with everyone working on it. Path defaults to $PWD.
--global every future session in every project on this host.
They combine. --project and --global deliberately do NOT touch a session that
is already running: they are approvals for future ones. Pass --container as
well to do both at once.
Prefer --project to --global. A domain one project needs is that project's, and
a global grant widens every other project's firewall too.
With --container, the domain is verified reachable afterwards and a warning is
printed if it is not, so a typo or a blocked port does not look like success.
See docs/configuration.md; docs/security.md explains what the allowlist bounds.
DEJIMA_ALLOW_HELP
exit 0 ;;
esac
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=dejima-project-lib.sh
source "$SCRIPT_DIR/dejima-project-lib.sh"
@@ -94,25 +131,61 @@ fi
# This is the only thing that decides. Deliberately not an environment
# variable as well: a firewall CLI with an environment-triggered no-op path
# is one an env var can silently disarm.
# A container list an operator can act on without asking the agent anything.
#
# The name alone is a run id, which is exactly what someone with three
# sessions open does not know. What they do know is which project each one is
# working on, and - where remote access is on - the number the dejima-attach
# picker gives it, because that is the list they are already reading.
#
# The project comes from the container's own working directory rather than
# from the session registry, so it is there for every session and not only a
# remote-enabled one. The picker number comes from `dejima-attach --list`,
# whose rows the picker numbers 1..N in the order they are printed; absent
# where remote access is off, which is a column missing rather than a failure.
#
# Reads only. Nothing here decides which container is acted on - that is the
# caller's --container, or the single running one.
describe_containers() {
local listing="$1" name id project row n=0 idx
local -A attach_index=()
while IFS=$'\t' read -r _ _ _ _ _ row; do
[ -n "$row" ] || continue
n=$((n + 1))
attach_index["$row"]="$n"
done < <(dejima-attach --list 2>/dev/null || true)
while IFS=$'\t' read -r name id; do
[ -n "$name" ] || continue
project="$(docker inspect -f '{{.Config.WorkingDir}}' "$id" 2>/dev/null)" || project=""
[ -n "$project" ] && project="$(basename "$project")"
idx="${attach_index[${id:0:12}]:-}"
printf ' %-28s %-24s %s\n' "$name" "${project:-?}" \
"${idx:+(dejima-attach $idx)}"
done <<< "$listing"
}
session_wanted=1
if [ -n "$project" ] || [ "$global" -eq 1 ]; then
[ -n "$container" ] || session_wanted=0
fi
if [ "$session_wanted" -eq 1 ]; then
if [ -z "$container" ]; then
containers=$(docker ps --filter "label=com.docker.compose.project=dejima" --filter "status=running" --format '{{.Names}}')
containers=$(docker ps --filter "label=com.docker.compose.project=dejima" \
--filter "status=running" --format '{{.Names}} {{.ID}}')
count=$(echo "$containers" | grep -c . || true)
if [ "$count" -eq 0 ]; then
echo "No running dejima container found." >&2
exit 1
elif [ "$count" -gt 1 ]; then
echo "Multiple running dejima containers found; specify one with --container:" >&2
echo "$containers" >&2
echo "" >&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
fi
container="$containers"
container="${containers%%$'\t'*}"
fi
# The DNS proxy (dns-proxy.sh) NXDOMAINs anything not in its allowlist, so
+31
View File
@@ -11,6 +11,37 @@
# --list one tab-separated row per live session, for scripting
set -euo pipefail
# --help before anything else runs: no libraries sourced, no config read. The
# moment someone reaches for it is often the moment the config is wrong.
# stdout and exit 0, where usage() stays on stderr and exits nonzero.
case "${1:-}" in
-h|--help)
cat <<'DEJIMA_ATTACH_HELP'
dejima-attach - list live dejima sessions and attach to one
Usage: dejima-attach [run-id | --list]
(no argument) numbered picker of live sessions; attaches to the choice
run-id attach to one directly, which is what a notification's deep
link and ttyd's --url-arg both produce
--list one tab-separated row per live session, for scripting
This is the shared core behind both remote front doors: it runs as a forced SSH
command and as ttyd's command, so what an SSH key can reach and what the web
terminal can reach are the same thing described once.
Attaching gives a tmux session on the HOST, not a shell inside the container.
That is deliberate - it is how an operator watches or steers a session - but it
means the tmux prefix key opens a window outside the sandbox, so exposing it is
exposing host access.
A session appears here only while its container is running.
See docs/remote-access.md.
DEJIMA_ATTACH_HELP
exit 0 ;;
esac
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=dejima-project-lib.sh
source "$SCRIPT_DIR/dejima-project-lib.sh"
+259 -24
View File
@@ -8,6 +8,45 @@
# `claude` in that repo again. See docs/security.md.
set -uo pipefail
# --help before anything else runs: no libraries sourced, no config read. The
# moment someone reaches for it is often the moment the config is wrong.
# stdout and exit 0, where usage() stays on stderr and exits nonzero.
case "${1:-}" in
-h|--help)
cat <<'DEJIMA_AUDIT_HELP'
dejima-audit - report what a session changed outside its workspace
Usage: dejima-audit <snapshot|compare|show|ack> [workspace] [state-file]
snapshot record the current state of everything watched
compare compare against the last snapshot and file a report
show [--full] index of reports waiting to be read; --full prints one
ack walk a report finding by finding and record a decision
([o] at the prompt opens the file in $VISUAL/$EDITOR)
--print-paths the resolved store paths, one per line, for scripts
workspace defaults to the enclosing git repository, else $PWD.
snapshot and compare are run by bin/dejima around a session, not usually by
hand. show and ack are for reading what they found.
What is watched is the part a container boundary cannot protect: dejima's own
trusted code, the harness configuration a later host session would execute,
and anything else on the host a session could have reached. A finding is
CRITICAL when it names something that runs unsandboxed on the host.
ack records a decision per finding, so an approved change stops being reported
until the thing it approved changes again. Acknowledging is per value, not per
path: the same file with different content is a new finding.
Reports and baselines live under ~/.local/share/dejima, outside every mount, so
a session cannot read what was concluded about it or edit what is pending.
See docs/security.md.
DEJIMA_AUDIT_HELP
exit 0 ;;
esac
# shellcheck source=dejima-provider-lib.sh
source "$(dirname "$(readlink -f "$0")")/dejima-provider-lib.sh"
# shellcheck source=dejima-color-lib.sh
@@ -105,17 +144,78 @@ report_key() {
printf '%s' "${b%-CRITICAL.txt}.txt" | sed 's/\.txt\.txt$/.txt/'
}
# How many reports are on disk regardless of whether anything is outstanding.
# The two numbers answer different questions and both get asked: an operator
# told "no reports" while looking at a directory of them is being told
# something false, even when nothing there wants a decision.
count_reports_total() {
find "$1" -maxdepth 1 -name '*.txt' 2>/dev/null | wc -l
}
# A report walked to its end is done with, and stays on disk as the record of
# what that session did - rotation evicts it, not `ack`. Everything below
# therefore has to distinguish "on disk" from "still wants a decision", or the
# operator is told work is waiting that no keystroke can clear. That was #195:
# five reports waiting, "nothing new - 32 already dealt with", forever, with
# the other four unreachable because selection kept returning the same one.
report_dealt_with() {
baseline_reviewed "$(report_key "$1")"
}
# Reports that still want a decision, oldest first. The caller gets a list
# rather than one path so counting and choosing cannot disagree about what is
# outstanding.
outstanding_reports() {
local dir="$1" r
while IFS= read -r r; do
[ -n "$r" ] || continue
report_dealt_with "$r" || printf '%s\n' "$r"
done < <(find "$dir" -maxdepth 1 -name '*.txt' 2>/dev/null | sort)
}
count_reports() {
local dir="$1"
printf '%s %s\n' \
"$(find "$dir" -maxdepth 1 -name '*.txt' 2>/dev/null | wc -l)" \
"$(find "$dir" -maxdepth 1 -name '*-CRITICAL.txt' 2>/dev/null | wc -l)"
local dir="$1" all crit r
all=0; crit=0
while IFS= read -r r; do
[ -n "$r" ] || continue
all=$((all + 1))
case "$r" in *-CRITICAL.txt) crit=$((crit + 1)) ;; esac
done < <(outstanding_reports "$dir")
printf '%s %s\n' "$all" "$crit"
}
# Like select_report, but skipping whatever this run has already been
# through. A report can be left outstanding on purpose - the operator skipped
# a finding in it, and skipping means ask again next time - and without this
# the run would be handed that same report back, decide it is not a new one,
# and stop, leaving every report behind it unread. That is the shape of #195
# again, one step along: not "cannot retire" but "cannot move on".
#
# Reads the walked_this_run array the ack loop maintains; harmless where that
# array does not exist, which is every other caller.
select_next_report() {
local dir="$1" r out unwalked=""
out="$(outstanding_reports "$dir")"
[ -n "$out" ] || return 1
while IFS= read -r r; do
[ -n "$r" ] || continue
[ -n "${walked_this_run[$r]:-}" ] && continue
unwalked="${unwalked}${unwalked:+$'\n'}$r"
done <<< "$out"
[ -n "$unwalked" ] || return 1
r="$(printf '%s\n' "$unwalked" | grep -- '-CRITICAL\.txt$' | head -1)"
[ -n "$r" ] || r="$(printf '%s\n' "$unwalked" | tail -1)"
printf '%s\n' "$r"
}
select_report() {
local dir="$1" r
r="$(find "$dir" -maxdepth 1 -name '*-CRITICAL.txt' 2>/dev/null | sort | head -1)"
[ -n "$r" ] || r="$(find "$dir" -maxdepth 1 -name '*.txt' 2>/dev/null | sort | tail -1)"
local dir="$1" r out
out="$(outstanding_reports "$dir")"
[ -n "$out" ] || return 1
# An unacknowledged CRITICAL first, whatever its age, then the newest of
# the rest - the same order as before, over the outstanding ones only.
r="$(printf '%s\n' "$out" | grep -- '-CRITICAL\.txt$' | head -1)"
[ -n "$r" ] || r="$(printf '%s\n' "$out" | tail -1)"
[ -n "$r" ] || return 1
printf '%s\n' "$r"
}
@@ -1044,6 +1144,46 @@ show_capped() {
fi
}
# The file a finding is about, as it is on disk now, or empty when the finding
# is not about a file at all - a git config key has no path, and a surface that
# has since been removed has nothing to open.
#
# Deliberately the current file rather than the text the report captured: the
# question `ack` asks is about what is on the host now, and an approval pins
# the content that is there at the moment it is recorded. Reading a stale copy
# and approving would be approving something nobody read.
path_for_id() {
local id="$1" f _hp
if _hp="$(host_exec_path_for_id "$id")"; then
f="$_hp"
else
case "$id" in
gitconfig:*) return 1 ;;
tcb:*) f="$ws/${id#tcb:}" ;;
*) f="$ws/$id" ;;
esac
fi
[ -f "$f" ] || return 1
printf '%s\n' "$f"
}
# The operator's own editor, read-only where the editor understands how.
#
# Read-only is the default because the file being opened is the thing under
# audit: editing it mid-decision changes what an approval would then pin, and
# an approval recorded against content the session did not write is a worse
# record than no approval. -R is only added for the vi family, whose spelling
# of it is known; anything else is run as the operator configured it.
open_in_editor() {
local file="$1" ed base
ed="${VISUAL:-${EDITOR:-vi}}"
base="$(basename "${ed%% *}")"
case "$base" in
vi|vim|nvim|view) "$ed" -R "$file" <&3 || true ;;
*) "$ed" "$file" <&3 || true ;;
esac
}
# Where the operator can read the whole thing, when the display was capped.
# Never sanitized through a path that would make it unfindable: it is the
# audit's OWN text about a location, not workspace-controlled content.
@@ -1498,8 +1638,17 @@ case "$cmd" in
show)
color_init 1
report_dir="$AUDIT_DIR/$(ws_slug "$ws")"
report="$(select_report "$report_dir")" \
|| { echo "dejima audit: no reports for $ws" >&2; exit 0; }
report="$(select_report "$report_dir")" || {
_total="$(count_reports_total "$report_dir")"
if [ "$_total" -gt 0 ]; then
printf 'dejima audit: nothing waiting for %s.\n' "$ws" >&2
printf ' %s report(s) kept in %s as the record of what those sessions did.\n' \
"$_total" "$report_dir" >&2
else
echo "dejima audit: no reports for $ws" >&2
fi
exit 0
}
# Default is an index, not a dump. `cat` of one report was a wall of
# text that answered neither question an operator actually has - how
# much is waiting, and which of it still needs a decision - and it
@@ -1537,8 +1686,16 @@ case "$cmd" in
color_init 1
report_dir="$AUDIT_DIR/$(ws_slug "$ws")"
bf="$BASELINE_DIR/$(ws_slug "$ws")"
report="$(select_report "$report_dir")" \
|| { echo "dejima audit: no reports for $ws" >&2; exit 0; }
report="$(select_report "$report_dir")" || {
_total="$(count_reports_total "$report_dir")"
if [ "$_total" -gt 0 ]; then
printf 'dejima audit: nothing waiting - every finding in %s report(s) has been dealt with.\n' \
"$_total" >&2
else
echo "dejima audit: no reports for $ws" >&2
fi
exit 0
}
read -r n_all n_crit <<< "$(count_reports "$report_dir")"
if [ "$n_all" -gt 1 ]; then
printf '%s reports waiting' "$n_all"
@@ -1546,10 +1703,25 @@ case "$cmd" in
"$C_CRIT" "$n_crit" "$C_RESET"
printf '.\n\n'
fi
# What has already been put in front of the operator during THIS
# invocation, keyed by surface and the content that is on disk now.
#
# Reports overlap: the same file can appear in several, and `ack`
# walks them in one run. Once its current content has been shown, an
# older report asking about the same file is asking about a version
# that no longer exists - the operator has seen what is there and
# decided, or declined to. Approving covers this through the baseline
# already; skipping does not, and that is the case this handles.
declare -A shown_this_run=()
# Reports this invocation has already walked, so it moves on rather
# than being handed one of them again.
declare -A walked_this_run=()
# One report per pass, every report in one invocation. Walking one and
# exiting meant clearing a backlog took as many runs as there were
# reports, with nothing on screen to say another was behind it.
while :; do
walked_this_run["$report"]=1
sc="$(sidecar_dir "$report")"
# Removed only at the end, and only for findings actually
# acknowledged - an abort must leave the operator exactly where they
@@ -1572,6 +1744,11 @@ case "$cmd" in
# Like unackable_shown, it means the report has been dealt with as far
# as it can be, so it earns a `reviewed` marker.
removed_cleared=0
# Findings the operator chose to be asked about again, and un-ackable
# ones that were shown but could not be recorded either way. The first
# keeps the report outstanding; the second must not, or it re-prompts
# forever - which is the distinction the reviewed marker turns on.
left_undecided=0
# Findings passed over because the baseline already covers them, or
# because this report has been walked before - counted so a run that
# displays nothing can say why instead of looking broken.
@@ -1712,6 +1889,23 @@ case "$cmd" in
if [ "$tier" = CRITICAL ]; then crit_acked=$((crit_acked + 1)); fi
continue
fi
# Already shown this run, for the content that is on disk now.
# Counted as dealt with rather than silently dropped, so a report
# that displays nothing still says why.
#
# crit_acked is deliberately NOT advanced: nothing was approved,
# so a report whose CRITICAL was only superseded must keep its
# -CRITICAL.txt. And left_undecided is set, so this report is not
# retired on the strength of a question that was never answered -
# it comes back on the next run, where the operator meets it once.
if [ "$ackable" -eq 1 ]; then
_seen_key="$rid"$'\t'"$(current_sha_for_id "$rid")"
if [ -n "${shown_this_run[$_seen_key]:-}" ]; then
already=$((already + 1))
left_undecided=1
continue
fi
fi
# A finding cleared on an earlier pass because its surface was
# gone. Nothing was recorded for it - there is no content to pin -
# so baseline_suppresses cannot cover it, and it is ackable, so
@@ -1744,7 +1938,10 @@ case "$cmd" in
else
printf ' %s[%s]%s %s\n' "$(tier_color "$tier")" "$tier" "$C_RESET" "$body"
fi
[ "$ackable" -eq 1 ] && render_finding "$rid" "$sc"
if [ "$ackable" -eq 1 ]; then
render_finding "$rid" "$sc"
shown_this_run["$rid"$'\t'"$(current_sha_for_id "$rid")"]=1
fi
# Each option says what it does to FUTURE findings, because that
# is the whole difference between the two that record something
# and it was not visible: approve pins this exact content and
@@ -1761,16 +1958,46 @@ case "$cmd" in
esac
k_a="${C_KEY}[a]$C_RESET"; k_i="${C_KEY}[i]$C_RESET"
k_s="${C_KEY}[s]$C_RESET"; k_q="${C_KEY}[q]$C_RESET"
k_o="${C_KEY}[o]$C_RESET"
# Offered only where there is a file to open. The report shows the
# part that changed, which is chosen by what changed and not by
# what a person needs in order to judge it - three changed lines in
# a hook script are only judgeable by reading the script.
open_path="$(path_for_id "$rid")" || open_path=""
[ "$ackable" -eq 1 ] || open_path=""
open_hint=""
[ -n "$open_path" ] && open_hint=" $k_o open the file"
# Looped, so opening the file returns to the same decision rather
# than counting as one.
key=""
while :; do
if [ "$ackable" -ne 1 ]; then
printf ' %s skip %s quit (this finding cannot be acknowledged): ' "$k_s" "$k_q"
elif [ "$tier" = CRITICAL ]; then
printf ' %s approve this exact content %s skip (ask again next time) %s quit: ' \
"$k_a" "$k_s" "$k_q"
printf ' %s approve this exact content%s %s skip (ask again next time) %s quit: ' \
"$k_a" "$open_hint" "$k_s" "$k_q"
else
printf ' %s approve this exact content %s ignore this %s from now on %s skip (ask again next time) %s quit: ' \
"$k_a" "$k_i" "$ign_noun" "$k_s" "$k_q"
printf ' %s approve this exact content%s %s ignore this %s from now on %s skip (ask again next time) %s quit: ' \
"$k_a" "$open_hint" "$k_i" "$ign_noun" "$k_s" "$k_q"
fi
{ read -r key <&3; } 2>/dev/null || { aborted=1; break; }
{ read -r key <&3; } 2>/dev/null || { aborted=1; break 2; }
case "$key" in
o)
if [ -z "$open_path" ]; then
echo " nothing to open: this finding is not about a file that exists."
continue
fi
open_in_editor "$open_path"
# Re-read after the editor: the content may have moved on,
# and what gets approved below is whatever is there when
# the key is pressed, not what was rendered above.
render_finding "$rid" "$sc"
shown_this_run["$rid"$'\t'"$(current_sha_for_id "$rid")"]=1
continue ;;
esac
break
done
[ "$aborted" -eq 1 ] && break
case "$key" in
q|$'\e'|'') aborted=1; break ;;
i)
@@ -1783,7 +2010,7 @@ case "$cmd" in
sc_done+=("$rid")
fi
;;
s) ;;
s) [ "$ackable" -eq 1 ] && left_undecided=1 ;;
a)
if [ "$ackable" -ne 1 ]; then
echo " cannot acknowledge: this finding cannot be acknowledged."
@@ -1826,11 +2053,19 @@ case "$cmd" in
echo ""; echo "dejima audit: nothing recorded."
exit 0
fi
# Written once the report has been walked to its end, and only when
# it actually showed something that cannot be acknowledged - there is
# nothing else to stop those from re-prompting forever.
if { [ "$unackable_shown" -eq 1 ] || [ "$removed_cleared" -eq 1 ]; } \
&& [ "$report_reviewed" -eq 0 ]; then
# Written once the report has been walked to its end with nothing
# left undecided. It used to be written only for a report carrying
# something that cannot be acknowledged, which left a report whose
# findings were all approved with no record that it had been finished
# - so selection kept returning it and the ones behind it were never
# reached (#195).
#
# The undecided check is what keeps "skip (ask again next time)"
# honest. Retiring the report retires every finding in it, so a marker
# written over a skipped finding would turn that promise into "never
# ask again" - silently, and for a finding the operator had looked at
# and declined to approve.
if [ "$report_reviewed" -eq 0 ] && [ "$left_undecided" -eq 0 ]; then
pending+=("reviewed $(report_key "$report")")
fi
if [ "${#pending[@]}" -gt 0 ]; then
@@ -1873,7 +2108,7 @@ case "$cmd" in
# Next report, if there is one. Asked rather than assumed: an operator
# who has decided enough for now should not have to abort out of a
# report they have not read to stop.
next_report="$(select_report "$report_dir")" || next_report=""
next_report="$(select_next_report "$report_dir")" || next_report=""
if [ -n "$next_report" ] && [ "$next_report" != "$report" ]; then
read -r n_all n_crit <<< "$(count_reports "$report_dir")"
printf '\n%s report(s) still waiting' "$n_all"
+34 -1
View File
@@ -42,6 +42,8 @@ core.harness provider:harness
core.forge provider:forge none
core.workflow provider:workflow local-only
core.command ?identifier
core.identity ?identifier
core.tools ?list:identifier
core.firewall bool on
core.share_dir path ~/.local/share/dejima/shared
core.ssh_key ?path
@@ -68,6 +70,8 @@ remote-access.base_url ?url
remote-access.ttyd_port int:1:65535 7681
remote-access.ttyd_font_size ?int:8:72
remote-access.ttyd_tmux_prefix ?chord
remote-access.ttyd_focus_on_tap ?bool
remote-access.ttyd_button_colors ?bool
remote-access.debounce int:0:86400 60
EOF
}
@@ -247,8 +251,23 @@ valid_tarball_spec() {
# A declared domain has to survive being written into a dnsmasq
# server=/domain/ line. Here rather than in dejima-project-lib.sh as well, so
# there is one definition and no pair of them to drift apart.
# A hostname, label by label. The matcher is a trust boundary: what it accepts
# becomes a dnsmasq `server=` line and an ipset entry, so it should accept only
# what it means to rather than only what it can see a use for.
#
# The previous form allowed an EMPTY label - `a..b.com` matched - because its
# repeated group could consume a dot having matched no characters before it.
# Harmless in practice, since nothing that shape resolves, and fixed on the
# principle rather than on a known abuse: the published bypass in this space
# came from a matcher accepting a string the resolver read differently.
#
# Per label: 1-63 characters, starting and ending alphanumeric, hyphens in
# between. That also newly refuses a label with a leading or trailing hyphen,
# which is not a hostname either. A single label with no dot stays legal - an
# internal name resolved by the host's own resolver is a real case.
valid_domain() {
[[ "$1" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,62}\.)*[a-zA-Z0-9-]{1,63}$ ]]
local label='[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?'
[[ "$1" =~ ^$label(\.$label)*$ ]]
}
# Extension point for types this layer cannot judge on its own. A stub here,
@@ -350,6 +369,20 @@ _config_valid_value() {
identifier)
[[ "$value" =~ ^[A-Za-z0-9._-]+$ ]]
;;
text)
# A sentence rather than a token - a person's name, an email
# address, a one-line description. An identifier has no room for a
# space and a path refuses a colon, and all of those are legal
# here. Refuses only what cannot survive the trip: a newline splits
# the value where every consumer expects one line, and a control
# character reaching a terminal is the escape-sequence hazard
# docs/security.md names. Everything else is the operator's
# business, the same reading `path` takes of a space.
case "$value" in
*[$'\n\r\t']*) return 1 ;;
esac
[[ ! "$value" =~ [[:cntrl:]] ]]
;;
chord)
# A tmux control chord, as the remote terminal page's PREFIX,
# window and ZOOM buttons send. Narrower than tmux's own prefix
+52 -6
View File
@@ -16,6 +16,39 @@
# Usage: dejima-conversation-ship <workspace>
set -uo pipefail # not -e: a torn line or a Loki hiccup must not kill the tailer
# --help before anything else runs: no libraries sourced, no config read. The
# moment someone reaches for it is often the moment the config is wrong.
# stdout and exit 0, where usage() stays on stderr and exits nonzero.
case "${1:-}" in
-h|--help)
cat <<'DEJIMA_CONVERSATION_SHIP_HELP'
dejima-conversation-ship - ship a session's conversation to Loki
Usage: dejima-conversation-ship <workspace>
Started and stopped by bin/dejima around a session. Running it by hand is only
useful for debugging.
Ships prompts, replies and tool invocations - never tool output, which is most
of the volume and was not asked for. Runs on the host rather than in the
container, because a log of a session that the session shares a filesystem and
a signal namespace with is one it can silence.
What that does not buy is trust in the contents. The transcript is written by
the session, so this ships what was recorded without attesting to it.
Off unless [logging] loki_url is set AND [logging] conversation is on. Both are
required because session content is a different sensitivity class from the DNS
and firewall logs, which ship on loki_url alone.
Where the transcripts are, and how one becomes a log line, are the selected
harness's business; the batching and the shipping are dejima's.
See docs/configuration.md.
DEJIMA_CONVERSATION_SHIP_HELP
exit 0 ;;
esac
# shellcheck source=dejima-provider-lib.sh
source "$(dirname "$(readlink -f "$0")")/dejima-provider-lib.sh"
# shellcheck source=dejima-slug-lib.sh
@@ -64,16 +97,29 @@ POLL_SECONDS=3
push() {
# $1 is a newline-terminated block of "<ns timestamp>\t<line>" records.
local raw="$1" values payload
local raw="$1" payload
[ -n "$raw" ] || return 0
values="$(printf '%s' "$raw" | jq -R -s '
# One jq, and nothing through argv. A single argument is capped at 128 KiB
# (MAX_ARG_STRLEN), and both the intermediate --argjson value and the
# finished body used to be passed that way - so a batch of long lines
# failed with "argument list too long" and the batch was dropped.
#
# Twenty lines at up to sixteen kilobytes each is well past that cap, and
# the transform's own clip is what sets those numbers, so this was not a
# remote possibility: it is what a harness whose tool results are large
# produces as a matter of course. Found running Cline.
#
# The payload is built in one pass and handed to curl on stdin with -d @-.
# A shell variable is bounded by memory rather than by MAX_ARG_STRLEN, so
# holding it is fine; passing it is not.
payload="$(printf '%s' "$raw" | jq -R -s '
split("\n") | map(select(length > 0))
| map(split("\t") | [.[0], (.[1:] | join("\t"))])
| {streams: [{stream: {job: "dejima-conversation", source: "dejima"}, values: .}]}
')" || return 0
payload="$(jq -n --argjson values "$values" \
'{streams: [{stream: {job: "dejima-conversation", source: "dejima"}, values: $values}]}')" || return 0
curl -s -o /dev/null --max-time 5 -H "Content-Type: application/json" \
-XPOST "$LOKI_URL/loki/api/v1/push" -d "$payload" || true
printf '%s' "$payload" | curl -s -o /dev/null --max-time 5 \
-H "Content-Type: application/json" \
-XPOST "$LOKI_URL/loki/api/v1/push" -d @- || true
}
# Which transcript files belong to this workspace. The harness's plugin
+36
View File
@@ -17,6 +17,42 @@
# for a project are the union of its global and project approvals.
set -euo pipefail
# --help before anything else runs: no libraries sourced, no config read. The
# moment someone reaches for it is often the moment the config is wrong.
# stdout and exit 0, where usage() stays on stderr and exits nonzero.
case "${1:-}" in
-h|--help)
cat <<'DEJIMA_DEP_HELP'
dejima-dep - approve a package a project needs, so its image carries it
Usage: dejima-dep <add|remove|list> [--global | --project [path]] [<spec>]
add <spec> approve it; the image is rebuilt with it at the next launch
remove <spec> withdraw the approval
list show what is approved in the scope given
Scope:
--project [path] one project, every session in it. Path defaults to $PWD.
--global every project on this host.
(none) same as --project for the current directory.
A spec is <apt|npm|pip|cargo>:<name>, optionally @<version>, or a release
archive as tarball:<name>@<sha256>:<url>. The digest is required and checked at
build time, because a tarball is code that will run in every future session.
Approving is a separate act from declaring. A project declares what it needs in
.dejima/dependencies, which is inside the workspace and therefore writable by
the session; nothing is installed until an operator approves it here. Use
`dejima-project status` to see what a project is asking for.
Nothing is installed into a running container: a rebuild happens at the next
launch, so an approval made mid-session takes effect on the one after it.
See docs/configuration.md.
DEJIMA_DEP_HELP
exit 0 ;;
esac
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=dejima-project-lib.sh
source "$SCRIPT_DIR/dejima-project-lib.sh"
+325 -8
View File
@@ -21,22 +21,335 @@
# file it came from. This preserves what was recorded, it does not attest to
# it.
#
# Usage: dejima-epitaph save <workspace>
# Usage: dejima-epitaph save <workspace>
# dejima-epitaph list [--all] [workspace]
# dejima-epitaph search [--all] <pattern> [workspace]
# dejima-epitaph show [n | name] [workspace]
#
# `save` is what bin/dejima's exit trap runs. The other three are the
# operator's, and they only ever read from the host: nothing here gives a
# container a path to the store, and nothing here hands an epitaph back to a
# session. That second one is the load-bearing half. A session can write its
# own transcript, so it can author what its own epitaph says - delivering
# that to a later session automatically would plant instructions in its
# premises, which is the channel the never-mounted store exists to close
# (#104). Printing to the operator's terminal keeps a person in the middle,
# and that is the whole design rather than a missing feature.
set -uo pipefail
# --help before anything else runs: no libraries sourced, no config read. The
# moment someone reaches for it is often the moment the config is wrong.
# stdout and exit 0, where usage() stays on stderr and exits nonzero.
case "${1:-}" in
-h|--help)
cat <<'DEJIMA_EPITAPH_HELP'
dejima-epitaph - keep what a session worked out before its context was compacted
Usage: dejima-epitaph save <workspace>
dejima-epitaph list [--all] [workspace]
dejima-epitaph search [--all] <pattern> [workspace]
dejima-epitaph show [n | name] [workspace]
save extract any compaction summaries from that workspace's transcripts
and keep them. Run by bin/dejima when a session exits, not by hand
list what is kept, oldest first, and whether the session behind each is
still resumable - where it is, resuming beats reading a summary
search case-insensitive regex over the bodies, reporting each hit's
position in the listing list prints
show open one in $VISUAL or $EDITOR, or print it where there is no
terminal to open an editor on
--all widen list and search to every project rather than this one
A harness that compacts a long session writes a summary of what it is about to
forget, and it goes away with the container. This keeps it, host-side, in a
store nothing mounts - so one session cannot read what an earlier one concluded
or edit what this one is about to record.
Reading works with [epitaph] enabled off: turning the feature off is when
reviewing and clearing the store is most likely.
Nothing is kept when the selected harness does not lose anything to compaction,
which is not a failure - it means the transcript already holds it all.
See docs/security.md.
DEJIMA_EPITAPH_HELP
exit 0 ;;
esac
# shellcheck source=dejima-provider-lib.sh
source "$(dirname "$(readlink -f "$0")")/dejima-provider-lib.sh"
# shellcheck source=dejima-slug-lib.sh
source "$(dirname "$(readlink -f "$0")")/dejima-slug-lib.sh"
# shellcheck source=dejima-harness-lib.sh
source "$(dirname "$(readlink -f "$0")")/dejima-harness-lib.sh"
# shellcheck source=dejima-color-lib.sh
source "$(dirname "$(readlink -f "$0")")/dejima-color-lib.sh"
config_validate || exit 1
color_init 1
cmd="${1:-}"; ws_arg="${2:-}"
usage() {
cat >&2 <<'EOF'
usage: dejima-epitaph save <workspace>
dejima-epitaph list [--all] [workspace]
dejima-epitaph search [--all] <pattern> [workspace]
dejima-epitaph show [n | name] [workspace]
n is a position in the listing `list` and `search` print, so it means what
those just showed - including under --all, which numbers every project's
epitaphs in one sequence.
EOF
exit 2
}
cmd="${1:-}"
shift 2>/dev/null || true
# --all anywhere, so it reads naturally either side of a search pattern.
ALL=0; args=()
for a in ${@+"$@"}; do
case "$a" in
--all) ALL=1 ;;
-h|--help) usage ;;
*) args+=("$a") ;;
esac
done
set -- ${args[@]+"${args[@]}"}
[ "$(config_get epitaph enabled)" = "on" ] || exit 0
STORE="$(config_get epitaph dir)"
# `enabled` gates writing, not reading. Turning epitaph off must not strand
# what it already kept behind a config key: an operator who switches it off
# is the one most likely to want to look at, and get rid of, the existing
# store.
case "$cmd" in
save) [ "$(config_get epitaph enabled)" = "on" ] || exit 0 ;;
list|search|show) ;;
*) usage ;;
esac
# Every epitaph belonging to $1, oldest first. The sort key is the basename,
# not the path: names begin with the timestamp, so this is chronological
# across a workspace's several slugs rather than grouped by whichever slug
# directory each file happens to sit in.
_epitaphs_in() {
local f
[ -d "$1" ] || return 0
for f in "$1"/*.md; do
[ -f "$f" ] || continue
printf '%s\t%s\n' "$(basename "$f")" "$f"
done
}
epitaph_files() {
local ws="$1" slug d
{
if [ "$ALL" = 1 ]; then
for d in "$STORE"/*/; do _epitaphs_in "${d%/}"; done
else
while IFS= read -r slug; do
[ -n "$slug" ] || continue
_epitaphs_in "$STORE/$slug"
done < <(workspace_slugs_sorted "$ws")
fi
} | sort | cut -f2-
}
# The workspace to read from: the one given, else this checkout. Tries the
# worktree top and then the main checkout that worktree belongs to, taking
# the first that actually has epitaphs - the same shape as dejima-audit's
# resolve_ws, and it matters more here. A worktree only has epitaphs of its
# own if dejima was launched against the worktree itself; run from one after
# working in the main checkout, the answer is the main checkout.
resolve_ws() {
local given="$1" top common main c
if [ -n "$given" ]; then
( cd "$given" 2>/dev/null && pwd ) || printf '%s' "$given"
return
fi
top="$(git rev-parse --show-toplevel 2>/dev/null)" || top=""
common="$(git rev-parse --git-common-dir 2>/dev/null)" || common=""
main=""
if [ -n "$common" ]; then
common="$(readlink -f "$common" 2>/dev/null)" || common=""
[ -n "$common" ] && main="$(dirname "$common")"
fi
for c in "$top" "$main" "$PWD"; do
[ -n "$c" ] || continue
[ -n "$(epitaph_files "$c")" ] || continue
printf '%s' "$c"
return
done
printf '%s' "$PWD"
}
case "$cmd" in
list)
ws="$(resolve_ws "${1:-}")"
mapfile -t FILES < <(epitaph_files "$ws")
if [ "${#FILES[@]}" -eq 0 ]; then
if [ "$ALL" = 1 ]; then echo "dejima epitaph: nothing kept yet." >&2
else echo "dejima epitaph: nothing kept for $ws." >&2; fi
exit 0
fi
if [ "$ALL" = 1 ]; then printf '%s epitaph(s), all projects\n\n' "${#FILES[@]}"
else printf '%s epitaph(s) for %s%s%s\n\n' "${#FILES[@]}" "$C_PATH" "$ws" "$C_RESET"; fi
# One python pass rather than one per file, and python rather than three
# greps and a stat because this reads the header of every epitaph in the
# store under --all.
#
# Deliberately no topic column. Every Claude Code summary opens on the
# same boilerplate, the "Primary Request" section that follows is written
# four different ways across the summaries here, and what it opens on is
# usually scene-setting rather than the request. Nothing extractable
# tells the reader which session this was, and a column repeating the
# same sentence down eight rows reads as a broken tool while leaving
# discoverability exactly where it was. `search` is the answer to "which
# one was it"; this is the answer to "what is here".
#
# Which sessions still have a transcript is the harness's answer, not
# something to work out from a filename: the id is in the name for one
# harness and is the directory for the next. Under --all the workspace is
# not passed, because a store key is a slug and a slug does not resolve
# back to a path - the plugin answers for every key it holds instead. A
# harness that does not implement the hook returns nothing, and every row
# then says so rather than claiming the transcript is gone.
harness_plugin_load "$(config_get core harness)"
live=""
if [ "$ALL" = 1 ]; then live="$(harness_hook harness_live_sessions)"
else live="$(harness_hook harness_live_sessions "$ws")"; fi
C_KEY="$C_KEY" C_DIM="$C_DIM" C_OK="$C_OK" C_RESET="$C_RESET" \
DEJIMA_LIVE_SESSIONS="$live" DEJIMA_HAS_LIVE="$(declare -F harness_live_sessions >/dev/null && echo 1)" \
python3 - "$ALL" ${FILES[@]+"${FILES[@]}"} <<'PY'
import os, re, sys
show_slug = sys.argv[1] == "1"
# "<key>\t<session-id>" per live transcript. The distinction that matters is
# an empty set from a harness that answered versus no answer at all: the
# first means aged out, the second means unknown.
known = os.environ.get("DEJIMA_HAS_LIVE") == "1"
live = set(os.environ.get("DEJIMA_LIVE_SESSIONS", "").splitlines())
KEY, DIM, OK, RESET = (os.environ.get(k, "") for k in ("C_KEY", "C_DIM", "C_OK", "C_RESET"))
# The index is padded before it is coloured: a width in the format string
# counts the escape sequence's bytes and knocks every row out of line the
# moment colour is on.
ROW = "%s %-16s %-6s %6s %5s %s"
print(DIM + ROW % ("%4s" % "#", "when", "how", "ctx", "size", "transcript") + RESET)
for i, path in enumerate(sys.argv[2:], 1):
try:
with open(path, errors="replace") as fh:
head = [next(fh, "") for _ in range(12)]
except OSError:
continue
h = {}
for l in head:
m = re.match(r"- (session|at|trigger|tokens): (.*)", l)
if m:
h[m.group(1)] = m.group(2).strip()
sid = h.get("session", "")
# How much context the session had accumulated by the time it compacted:
# the one field here that separates a long session from a short one, and
# it is recorded rather than guessed at.
m = re.match(r"(\d+) before", h.get("tokens", ""))
ctx = "%dk" % (int(m.group(1)) / 1000) if m else "?"
# The store key is the directory this epitaph sits in, which is dejima's
# own arrangement; pairing it with the session id is what makes the answer
# unambiguous under --all, where two projects can hold the same id.
if not known:
state = DIM + "?" + RESET
elif "%s\t%s" % (os.path.basename(os.path.dirname(path)), sid) in live:
state = OK + "resumable" + RESET
else:
state = DIM + "aged out" + RESET
row = ROW % (KEY + "%4d" % i + RESET,
h.get("at", "")[:16].replace("T", " ") or "?",
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
print(row)
PY
printf '\n %sdejima-epitaph show <n>%s opens one; %sdejima-epitaph search <pattern>%s finds one.\n' \
"$C_KEY" "$C_RESET" "$C_KEY" "$C_RESET"
# `resumable` means the transcript this was distilled from is still on
# disk, so `claude --resume <session>` gives the real session rather than
# a summary of it. That is the better recovery whenever it is available;
# the epitaph is what remains once the harness has aged the transcript out.
exit 0
;;
search)
pattern="${1:-}"; [ -n "$pattern" ] || usage
ws="$(resolve_ws "${2:-}")"
mapfile -t FILES < <(epitaph_files "$ws")
[ "${#FILES[@]}" -gt 0 ] || { echo "dejima epitaph: nothing kept for $ws." >&2; exit 0; }
# Case-insensitive extended regex, so a bare word works and a pattern is
# available without a second flag to remember.
gcolor=never; [ -n "$C_RESET" ] && gcolor=always
i=0; hits=0
for f in ${FILES[@]+"${FILES[@]}"}; do
i=$((i + 1))
grep -qiE -- "$pattern" "$f" 2>/dev/null || continue
hits=$((hits + 1))
# The index is this file's position in the SAME listing `list`
# prints, so `show <n>` after a search means what the search showed.
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 \
| head -3 | sed 's/^/ /' | cut -c1-100
done
if [ "$hits" -eq 0 ]; then
printf 'dejima epitaph: no epitaph matches %s (searched %s).\n' "$pattern" "$i" >&2
else
printf '\n%s of %s epitaph(s) match. %sdejima-epitaph show <n>%s opens one.\n' \
"$hits" "$i" "$C_KEY" "$C_RESET"
fi
exit 0
;;
show)
sel="${1:-}"; ws_arg="${2:-}"
# A lone argument that names a directory is the workspace, not a
# selector: `dejima-epitaph show ~/workspace/thing` is the obvious thing
# to type and would otherwise be read as a name to match.
if [ -n "$sel" ] && [ -z "$ws_arg" ] && [ -d "$sel" ]; then ws_arg="$sel"; sel=""; fi
ws="$(resolve_ws "$ws_arg")"
mapfile -t FILES < <(epitaph_files "$ws")
[ "${#FILES[@]}" -gt 0 ] || { echo "dejima epitaph: nothing kept for $ws." >&2; exit 0; }
file=""
case "$sel" in
"") file="${FILES[-1]}" ;;
*[!0-9]*)
for f in ${FILES[@]+"${FILES[@]}"}; do
case "$(basename "$f")" in "$sel"*) file="$f"; break ;; esac
done ;;
*)
[ "$sel" -ge 1 ] 2>/dev/null && [ "$sel" -le "${#FILES[@]}" ] \
&& file="${FILES[$((sel - 1))]}" ;;
esac
[ -n "$file" ] || { echo "dejima epitaph: no epitaph '$sel' in ${#FILES[@]} kept for $ws." >&2; exit 1; }
# Piped or redirected, print it: `show | grep` must not try to open an
# editor, and neither must a test.
ed="${VISUAL:-${EDITOR:-}}"
if [ -z "$ed" ] || [ ! -t 1 ] || [ ! -t 0 ]; then
cat "$file"
exit 0
fi
# Through `sh -c` so $EDITOR may carry flags (`code -w`, `emacsclient
# -nw`), with the path passed as an argument rather than interpolated so
# a space in it stays one word.
sh -c "$ed \"\$1\"" sh "$file"
# An editor writes through a temp file and renames it into place, which
# leaves the result at the umask's mode rather than the 0600 `save`
# wrote. The 0700 directory means nothing else could read it either way;
# the mode is restated because it is what this store promises, not
# because the directory needs the help.
chmod 600 "$file" 2>/dev/null || true
exit 0
;;
esac
# Everything below is `save`. Reading the store is dejima's own business and
# needs the plugin only to label a row (see `list` above, which tolerates its
# absence); extracting an epitaph is entirely the plugin's, so from here a
# missing one is fatal rather than a blank column.
harness_plugin_load "$(config_get core harness)"
# With no harness selected nothing knows what a compaction looks like, so
@@ -52,15 +365,12 @@ if [ -z "$(config_get core harness)" ]; then
exit 0
fi
ws_arg="${1:-}"
ws="$ws_arg"
[ -n "$ws" ] || ws="$(pwd)"
ws="$(cd "$ws" 2>/dev/null && pwd)" || exit 0
case "$cmd" in
save) ;;
*) echo "usage: dejima-epitaph save <workspace>" >&2; exit 2 ;;
esac
# One file per summary, named for the moment it was written, so a directory
# listing is a timeline. Keyed on the summary record's own uuid: `save` runs
# on every exit, and a session compacted three times has three epitaphs, two
@@ -126,6 +436,13 @@ echo "dejima: wrote $saved epitaph(s) to $STORE" >&2
# operators who already centralise session logs. Host-side rather than
# through the container's log-shipper, so the same "never in the session's
# reach" property holds for the shipping path too.
#
# DEJIMA_EPITAPH_NO_SHIP is bin/dejima's --no-log reaching the one decision it
# cannot make on this tool's behalf. Honoured from the environment, which
# nothing else here is, because it can only ever suppress: a stale export
# keeps an epitaph off the wire and never puts one on it. The epitaph is still
# written either way - the store is the durable copy, and this is the bonus.
[ "${DEJIMA_EPITAPH_NO_SHIP:-0}" != 1 ] || exit 0
[ "$(config_get epitaph loki)" = "on" ] || exit 0
loki="$(config_get logging loki_url)"
[ -n "$loki" ] || exit 0
+1 -1
View File
@@ -40,7 +40,7 @@
# The hooks core will call. A plugin defines the ones its harness needs and
# omits the rest; anything else it defines is a name core does not call, and
# the static test refuses it rather than letting it look wired up.
DEJIMA_HARNESS_HOOKS="harness_prepare_config harness_state_mounts harness_register_notify harness_register_guards harness_host_exec_surfaces harness_config_dirs harness_staged_config harness_extract_epitaphs harness_transform_transcript harness_guards_registered harness_briefing_state_dir harness_transcript_files harness_seed"
DEJIMA_HARNESS_HOOKS="harness_prepare_config harness_state_mounts harness_register_notify harness_register_guards harness_host_exec_surfaces harness_config_dirs harness_staged_config harness_extract_epitaphs harness_transform_transcript harness_guards_registered harness_briefing_state_dir harness_transcript_files harness_live_sessions harness_seed"
# What an operator loses when a harness cannot host a given guard. The set
# itself is DEJIMA_KNOWN_GUARDS in dejima-provider-lib.sh, next to the
+29
View File
@@ -27,6 +27,35 @@
# markers can abort it and silence every other session's notifications.
set -euo pipefail
# --help before anything else runs: no libraries sourced, no config read. The
# moment someone reaches for it is often the moment the config is wrong.
# stdout and exit 0, where usage() stays on stderr and exits nonzero.
case "${1:-}" in
-h|--help)
cat <<'DEJIMA_NOTIFY_WATCH_HELP'
dejima-notify-watch - turn a session's notifications into phone pushes
Usage: dejima-notify-watch [--once]
(no argument) watch until killed, which is what the systemd unit runs
--once one scan, then exit
Started by a systemd USER unit on the host. Never as root, never inside a
container.
A session's hooks drop marker files when it wants attention or has finished;
this turns those into ntfy pushes. It runs host-side because it is the trust
boundary: a possibly-compromised session writes the markers, and the phone they
reach is outside the sandbox. Nothing the session writes is used as a command,
a URL or a topic - the marker only says which of a fixed set of events happened.
Does nothing unless [remote-access] enabled is on and a notify URL is set.
See docs/remote-access.md.
DEJIMA_NOTIFY_WATCH_HELP
exit 0 ;;
esac
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=dejima-project-lib.sh
source "$SCRIPT_DIR/dejima-project-lib.sh"
+65 -2
View File
@@ -8,6 +8,44 @@
# Usage: dejima-project <status|approve> [<path>]
set -euo pipefail
# --help before anything else runs: no libraries sourced, no config read. The
# moment someone reaches for it is often the moment the config is wrong.
# stdout and exit 0, where usage() stays on stderr and exits nonzero.
case "${1:-}" in
-h|--help)
cat <<'DEJIMA_PROJECT_HELP'
dejima-project - review and approve what a project declares it needs
Usage: dejima-project <status|approve> [path]
status [path] what the project declares, and whether each entry is
approved, pending, rejected as malformed, or beyond the cap
approve [path] approve everything pending, after showing it and asking
path defaults to the current directory.
A project declares its requirements in files inside its own checkout:
.dejima/dependencies packages its image should carry
.dejima/allowed-domains hosts its sessions may reach
.dejima/corpus rules about how its history is recorded
.dejima/tools optional tools its sessions should have
Those are declarations, not grants. They live in the workspace, which a session
can write, so nothing they contain takes effect until it is approved here and
recorded in a store outside the container's reach. That is the whole point of
the split: a session can ask, and only an operator can agree.
An entry that fails validation is reported as rejected and never offered for
approval, so a malformed declaration cannot be waved through by answering yes.
approve is interactive and shows everything before it asks once.
See docs/configuration.md.
DEJIMA_PROJECT_HELP
exit 0 ;;
esac
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=dejima-project-lib.sh
source "$SCRIPT_DIR/dejima-project-lib.sh"
@@ -127,12 +165,15 @@ case "$action" in
report allowed-domains valid_domain
echo "corpus:"
report corpus valid_corpus_rule
echo "tools:"
report tools valid_tool_name
;;
approve)
pending_deps=$(classify dependencies valid_dep_spec | sed -n 's/^pending //p')
pending_doms=$(classify allowed-domains valid_domain | sed -n 's/^pending //p')
pending_corpus=$(classify corpus valid_corpus_rule | sed -n 's/^pending //p')
rejected=$( { classify dependencies valid_dep_spec; classify allowed-domains valid_domain; classify corpus valid_corpus_rule; } | sed -n 's/^rejected //p')
pending_tools=$(classify tools valid_tool_name | sed -n 's/^pending //p')
rejected=$( { classify dependencies valid_dep_spec; classify allowed-domains valid_domain; classify corpus valid_corpus_rule; classify tools valid_tool_name; } | sed -n 's/^rejected //p')
trunc_deps=$(classify dependencies valid_dep_spec | sed -n 's/^truncated //p')
trunc_doms=$(classify allowed-domains valid_domain | sed -n 's/^truncated //p')
if [ -n "$trunc_deps" ]; then
@@ -146,7 +187,8 @@ case "$action" in
sed 's/^/ /' <<< "$rejected" >&2
echo >&2
fi
if [ -z "$pending_deps" ] && [ -z "$pending_doms" ] && [ -z "$pending_corpus" ]; then
if [ -z "$pending_deps" ] && [ -z "$pending_doms" ] && [ -z "$pending_corpus" ] \
&& [ -z "$pending_tools" ]; then
echo "Nothing pending approval for $project."
exit 0
fi
@@ -154,6 +196,12 @@ case "$action" in
[ -n "$pending_deps" ] && printf ' dependency: %s\n' $pending_deps
[ -n "$pending_doms" ] && printf ' domain: %s\n' $pending_doms
[ -n "$pending_corpus" ] && printf ' corpus: %s\n' $pending_corpus
# Named as what it is rather than as a package: approving one adds a
# program to the image every session on this project then has on
# PATH. dejima hardens what it ships (docs/tools.md), which is what
# makes this a shorter conversation than a dependency - but it is
# still a change to what the sandbox contains.
[ -n "$pending_tools" ] && printf ' tool: %s (installed into the image)\n' $pending_tools
echo
printf 'Approve all of the above? [y/N] '
read -r reply || reply=n
@@ -196,6 +244,15 @@ case "$action" in
# RUNNING container's firewall or image; a corpus rule changes what the
# next session is told and nothing about a live one, so there is
# nothing to patch and no CLI to route through.
tool_failed=""
tool_approved=0
for tool in $pending_tools; do
if approve_entry "$(ensure_project_store "$project")" tools "$tool"; then
tool_approved=1
else
tool_failed="$tool_failed $tool"
fi
done
corpus_failed=""
corpus_approved=0
for rule in $pending_corpus; do
@@ -212,6 +269,12 @@ case "$action" in
if [ "$corpus_approved" = 1 ]; then
echo "Approved corpus rules for $project. They take effect at the next launch."
fi
if [ -n "$tool_failed" ]; then
echo "NOT approved for $project (could not record):$tool_failed" >&2
fi
if [ "$tool_approved" = 1 ]; then
echo "Approved tools for $project. The image rebuilds on the next launch."
fi
if [ -n "$dep_failed" ]; then
echo "NOT approved for $project (dejima-dep failed):$dep_failed" >&2
echo "Fix the cause and re-run; nothing was recorded for those." >&2
+51 -2
View File
@@ -236,6 +236,7 @@ _approved_validator() {
allowed-domains) printf 'valid_domain' ;;
dependencies) printf 'valid_dep_spec' ;;
corpus) printf 'valid_corpus_rule' ;;
tools) printf 'valid_tool_name' ;;
*) return 1 ;;
esac
}
@@ -249,6 +250,18 @@ _approved_validator() {
# not. There is one today.
DEJIMA_CORPUS_RULES="commit-per-step"
# An optional tool a project asks for. A name dejima ships a manifest for and
# nothing else: an entry naming a tool that does not exist would approve
# cleanly, sit in the store and install nothing, which reads as the tool being
# in force when it is not - the same reasoning as the corpus rules above.
#
# Refusing at declaration time also means the approval prompt cannot offer an
# operator a name that would silently do nothing.
valid_tool_name() {
valid_provider_name "$1" || return 1
provider_dir tool "$1" >/dev/null 2>&1
}
valid_corpus_rule() {
case " $DEJIMA_CORPUS_RULES " in
*" $1 "*) return 0 ;;
@@ -423,8 +436,22 @@ unapprove_entry() {
#
# -xaf rather than -xzf: compression is detected from the archive, so xz and
# zstd releases work without the spec having to say which it is.
# DEJIMA_TARBALL_STRIP picks between the two archive shapes. "prefix" is the
# default and the one every dependency uses: everything under a single
# directory, stripped so bin/ lands in the same place whatever the release
# called its top level. "binary" is an archive that IS the executable, with no
# enclosing directory at all - stripping a component there strips the binary
# and extracts nothing, silently, leaving a layer that built fine and contains
# no tool.
#
# A parameter rather than a second emitter, because the digest-before-unpack
# below is the part that must not exist twice.
_emit_tarball_layers() {
local spec rest name sha url
local spec rest name sha url dest strip
case "${DEJIMA_TARBALL_STRIP:-prefix}" in
binary) strip="" ;;
*) strip="--strip-components=1 " ;;
esac
for spec in "$@"; do
[ -n "$spec" ] || continue
rest="${spec#tarball:}"
@@ -436,7 +463,12 @@ _emit_tarball_layers() {
printf ' wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \\\n'
printf ' -O /tmp/%s.tar "%s" && \\\n' "$name" "$url"
printf ' echo "%s /tmp/%s.tar" | sha256sum -c - && \\\n' "$sha" "$name"
printf ' tar -C /opt/dejima/%s --strip-components=1 -xaf /tmp/%s.tar && \\\n' "$name" "$name"
# A bare binary goes straight into the bin/ a prefix archive would
# have brought with it, so the PATH line below is the same either way.
dest="/opt/dejima/$name"
[ -n "$strip" ] || dest="$dest/bin"
printf ' mkdir -p %s && \\\n' "$dest"
printf ' tar -C %s %s-xaf /tmp/%s.tar && \\\n' "$dest" "$strip" "$name"
printf ' rm /tmp/%s.tar\n' "$name"
printf 'ENV PATH=$PATH:/opt/dejima/%s/bin\n' "$name"
done
@@ -625,6 +657,23 @@ remote_ttyd_font_size() {
# interpolated into the page's client options, and a named-chord set is one
# fewer place an escaping question can be got wrong. A stock tmux does not
# use either of those two anyway.
# Whether tapping the terminal page raises the soft keyboard. Unset is the
# useful default and is deliberately NOT a bool default of off: the page
# decides per client from the pointer type, which a host-wide setting cannot
# see. Only a value the operator actually wrote is passed on.
# Whether the terminal page colour-codes its toolbar. Unset means on, which
# the page decides for itself - so, like the tap setting above, only a value
# the operator actually wrote is passed on.
remote_ttyd_button_colors() {
config_is_set remote-access ttyd_button_colors || return 0
config_get remote-access ttyd_button_colors
}
remote_ttyd_focus_on_tap() {
config_is_set remote-access ttyd_focus_on_tap || return 0
config_get remote-access ttyd_focus_on_tap
}
remote_ttyd_tmux_prefix() {
config_get remote-access ttyd_tmux_prefix
}
+296 -4
View File
@@ -44,6 +44,11 @@ _provider_axis_dir() {
forge) printf 'forges' ;;
workflow) printf 'workflows' ;;
harness) printf 'harnesses' ;;
# Not an axis in the select-one sense the other three are: a launch
# enables zero or more tools, and none is the default. It is here
# because everything else about it is the same - a manifest that is
# data, validated by the same schema machinery, never sourced.
tool) printf 'tools' ;;
*) echo "unknown provider axis: $1" >&2; return 1 ;;
esac
}
@@ -53,6 +58,7 @@ _provider_manifest_name() {
forge) printf 'forge.conf' ;;
workflow) printf 'workflow.conf' ;;
harness) printf 'harness.conf' ;;
tool) printf 'tool.conf' ;;
*) return 1 ;;
esac
}
@@ -143,14 +149,34 @@ capabilities ?list:identifier
pr_command ?command
issue_command ?command
merge_block ?command
repo_flags ?list:flag
repo_env ?identifier
identity_keys ?list:identifier
disclose ?disclosures
keys ?list:keyspec
requires ?list:identifier
host_keys ?list:identifier
url_keys ?list:identifier
fixed_hosts ?list:hostname
hosts_allowlisted_by_core bool off
install ?list:relpath
install ?list:installspec
env_export ?list:export
EOF
;;
# An optional tool. Deliberately small: a tool declares what it is,
# what installs it, what its environment needs and what the session
# should be told about it - and nothing that could change what dejima
# permits. There is no key here for a hook, a mount, a domain or a
# guard, because a tool that needed one of those would be a change to
# the boundary rather than a program on PATH, and would have to be
# argued for rather than declared.
tool) cat <<'EOF'
description text
install depspec
install_layout enum:prefix|binary prefix
image_env ?list:envpair
command identifier
instructions ?relpath
EOF
;;
workflow) cat <<'EOF'
@@ -270,11 +296,69 @@ _provider_valid_value() {
# and nothing shell-active does.
[[ "$value" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*( [A-Za-z0-9][A-Za-z0-9._-]*)*$ ]]
;;
flag)
# A command-line option name, compared against argv by
# docker/forge-guard.sh. The leading dash is required: without it
# the guard would be comparing a word that could just as well be a
# subcommand or a value.
[[ "$value" =~ ^--?[A-Za-z0-9][A-Za-z0-9-]*$ ]]
;;
relpath)
# Joined to a provider directory, so no absolute paths and no
# traversal - a manifest may only name a file it ships.
[[ "$value" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]] && [[ "$value" != *".."* ]]
;;
disclosures)
# Which of this forge's commands write a body a person authored,
# and what kind of artifact each one writes. Comma-separated
# because every entry is multi-word, unlike every other list here.
#
# disclose = pr:<cli> create, issue:<other cli> create
#
# The kind selects the marker; the marker text itself is core's
# (docker/forge-guard.sh), so that what dejima says about agent
# authorship cannot come out differently per forge.
# An empty entry is a typo, not an entry to skip - the same
# reading the list types take of a trailing separator. Checked on
# the raw string, because word splitting discards it silently.
case "$value" in ,*|*,) return 1 ;; esac
[[ "$value" =~ ,[[:space:]]*, ]] && return 1
local entry kind rest
local IFS=,
for entry in $value; do
# Leading and trailing space around each comma-separated entry.
entry="${entry#"${entry%%[![:space:]]*}"}"
entry="${entry%"${entry##*[![:space:]]}"}"
[ -n "$entry" ] || return 1
case "$entry" in
pr:*|issue:*|comment:*) ;;
*) return 1 ;;
esac
kind="${entry%%:*}"
rest="${entry#*:}"
[ -n "$kind" ] || return 1
_provider_valid_value command "$rest" || return 1
done
;;
installspec)
# A CLI this forge puts on PATH: either a file it ships, or
# "system:<name>" for one the image already provides.
#
# The second form exists because a forge CLI has to be reachable
# behind the shadow whether or not this repo wrote it - that is
# where docker/forge-guard.sh execs the real command through, and
# a forge naming a command that never gets there has a merge block
# that cannot be enforced. Declaring it here rather than special
# -casing the name in the image build is also what keeps the
# manifest a complete account of what this forge puts in /usr/local
# /bin, which is the reading the audit depends on.
case "$value" in
system:*)
[[ "${value#system:}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]
;;
*) _provider_valid_value relpath "$value" ;;
esac
;;
homepath)
# A directory the harness needs inside the sandbox user's home,
# created in that harness's image layer. Relative to $HOME and
@@ -548,11 +632,54 @@ _config_extra_section_known() {
case "$section" in
forge.*) axis=forge; name="${section#forge.}" ;;
harness.*) axis=harness; name="${section#harness.}" ;;
# An identity is the operator's own name for a set of credentials, so
# there is no directory to check it against - only that it is a name
# at all. Unlike a provider, an unused one is not a misconfiguration:
# having [identity.oss] on a machine that never selects it is the
# normal state of a second account.
identity.*)
name="${section#identity.}"
valid_provider_name "$name"
return
;;
*) return 1 ;;
esac
provider_dir "$axis" "$name" >/dev/null 2>&1
}
# The keys an [identity.<name>] section may carry, as "<key>\t<type>" lines.
#
# ssh_key and the git identity are core's, because every forge needs the same
# two answers to "who is pushing". Everything else is declared by a forge's
# identity_keys and typed by that forge's own keys entry, so the identity
# inherits the validation the forge already wrote rather than a second copy of
# it that could disagree.
#
# A key name declared by two forges resolves to one type here, which is why a
# static test refuses that collision rather than this function guessing.
_identity_key_specs() {
printf 'ssh_key ?path
'
printf 'user_name ?text
'
printf 'user_email ?text
'
local forge key spec
for forge in $(provider_list forge); do
for key in $(manifest_get forge "$forge" identity_keys); do
for spec in $(_provider_keys_field forge "$forge"); do
[ "${spec%%:*}" = "$key" ] || continue
# The forge's own type, with its default deliberately dropped:
# a default belongs to the forge's section, and repeating it
# here would make an identity that names no token look like one
# that named the default path.
printf '%s\t?%s\n' "$key" \
"$(cut -f2 <<< "$(_provider_keyspec_parse "$spec")" | sed 's/^?//')"
done
done
done
}
# Looks up one [forge.<name>]/[harness.<name>] key's TYPE<TAB>DEFAULT in that
# provider's `keys` manifest entry instead of a row hardcoded in core.
_config_extra_spec() {
@@ -560,6 +687,17 @@ _config_extra_spec() {
case "$want" in
forge.*) axis=forge ;;
harness.*) axis=harness ;;
identity.*)
rest="${want#identity.}"
key="${rest#*.}"
[ "$key" != "$rest" ] || return 1
while IFS=$'\t' read -r pname ptype; do
[ "$pname" = "$key" ] || continue
printf '%s\t' "$ptype"
return 0
done < <(_identity_key_specs)
return 1
;;
*) return 1 ;;
esac
rest="${want#*.}"
@@ -580,6 +718,75 @@ _config_extra_spec() {
return 1
}
# What the selected identity says about one key, if anything.
#
# An identity answers for the credentials and the git name a PERSON brings to
# a project, which is the half a project cannot declare: .dejima/ is committed
# and shared, and a key path is per-user and per-machine. The project (or the
# operator) names an identity; the identity supplies the paths.
#
# Returns 1 for a key no identity owns, which is most of them - the caller
# then reads the config file as before. Deliberately narrow: an identity that
# could set any key would be a second config file with different rules about
# what may be in it.
#
# core.identity itself is excluded, and not merely by convention: the caller
# that consults this is bin/dejima's config_get wrapper, so answering for the
# key that names the identity would recurse into itself.
# Whether the named identity exists in the config at all - any of its keys
# being present is enough, because a section with no keys is not a section
# the parser records.
#
# The check that matters: [core] identity naming a section nobody wrote is a
# silent fall-through to the machine's defaults, which is the wrong direction
# to be wrong in. Pushing under the wrong key fails loudly; pushing under the
# default key when you asked for another one does not fail at all.
identity_exists() {
local name="$1" key
[ -n "$name" ] || return 1
valid_provider_name "$name" || return 1
while IFS=$'\t' read -r key _; do
config_is_set "identity.$name" "$key" && return 0
done < <(_identity_key_specs)
return 1
}
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
case "$section" in
core) [ "$key" = ssh_key ] || return 1 ;;
forge.*) _forge_owns_identity_key "${section#forge.}" "$key" || return 1 ;;
identity.*) return 1 ;;
*) 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.
_forge_owns_identity_key() {
local forge="$1" key="$2" k
for k in $(manifest_get forge "$forge" identity_keys); do
[ "$k" = "$key" ] && return 0
done
return 1
}
# The git name and address the identity carries, for the gitconfig copy the
# container gets. Not core config keys: the host's own ~/.gitconfig is where
# they come from without an identity, and adding a second place to set them
# globally would be a new way to get the same answer.
identity_git_field() {
local field="$1" name
name="$(config_get core identity)" || return 1
[ -n "$name" ] || return 1
config_get "identity.$name" "$field"
}
# One key of the SELECTED harness, rather than of a harness named literally
# at the call site.
#
@@ -655,6 +862,23 @@ 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
# A name outside this set is a hard error rather than an unknown guard
# treated as absent: a typo would otherwise silently turn a guard off,
# which is the failure this whole declaration exists to prevent.
@@ -899,6 +1123,10 @@ What this means:
briefing.{{writable_mounts}} Everything else is disposable: installed system
packages, anything outside the mounted paths, and the container itself go
away when this run ends.
- A headless browser is installed: playwright, with chromium, importable
from any directory in either module system. If you change something with a
user interface, look at it rather than describing what it should look like,
and leave the screenshot in ~/shared.
- Outbound network is restricted to an allowlist (Anthropic's API, the npm
registry, {{network_allowlist}}, plus anything
approved via dejima-allow). Requests to anything else will simply
@@ -911,9 +1139,9 @@ What this means:
it appears to. If a task needs one more domain and nothing else, that's
a quick fix, not a reason to stop. Tell the user to run, from a terminal
outside the sandbox, whichever of these matches why the domain is needed:
bin/dejima-allow --container {{container_id}} <domain> # just this session
bin/dejima-allow --project <path> <domain> # this project, always
bin/dejima-allow --global <domain> # every project
dejima-allow --container {{container_id}} <domain> # just this session
dejima-allow --project <path> <domain> # this project, always
dejima-allow --global <domain> # every project
Pick deliberately rather than asking for all three: a domain this project
needs is --project, not --global - a global grant widens every other
project's firewall too, which is rarely what one project's dependency
@@ -1281,6 +1509,61 @@ _briefing_substitute() {
# the resume command) - in that reading order. Every {{key}} is substituted
# except {{session_id}}/{{container_id}}, deferred as @@SESSION_ID@@ and
# @@CONTAINER_ID@@ for the hook that mounts this at session start.
# A tool's manifest, plus the one rule its schema cannot express.
#
# image_env becomes a Dockerfile ENV, which every process in the container
# inherits - not just the tool that declared it. A harness may set a bare name
# there because a harness IS the container's main process; a tool is one
# program among many and may not.
#
# This is not hypothetical. rtk shipped with NO_COLOR=1, reasoning that its
# output lands in a model's context where an escape sequence is noise. The
# reasoning was fine, the scope was wrong, and a launch with that tool enabled
# lost colour in the harness, git, ls and everything else. The rule that would
# have caught it is that a tool's variables must be namespaced to the tool.
#
# Uppercased, with anything that is not alphanumeric folded to an underscore,
# so a tool named `my-tool` claims `MY_TOOL_*`.
tool_validate() {
local name="$1" prefix pair varname
manifest_validate tool "$name" || return 1
prefix="$(printf '%s' "$name" | tr '[:lower:]' '[:upper:]' | tr -c '[:alnum:]' '_')_"
for pair in $(manifest_get tool "$name" image_env); do
varname="${pair%%=*}"
case "$varname" in
"$prefix"*) continue ;;
esac
echo "tool '$name' declares image_env $varname, which is not its own." >&2
echo "A tool's image_env reaches every process in the container, so a bare" >&2
echo "name like NO_COLOR or DO_NOT_TRACK changes the harness and everything" >&2
echo "else too. Name it ${prefix}something, or do not set it." >&2
return 1
done
}
# What the session is told about the optional tools this launch enabled.
#
# The heading is core's and the body is each tool's own instructions.md, so a
# tool describes itself once and every harness gets the same words. Nothing
# here can change what the session may do - a tool's manifest has no key for
# that - which is what makes rendering a project-selectable fragment into the
# briefing safe at all.
_briefing_core_tools() {
local tools="$1" tool frag dir out=""
out="Extra tools this session has, beyond the usual shell:"
for tool in $tools; do
dir="$(provider_dir tool "$tool")" || continue
frag="$(manifest_get tool "$tool" instructions)" || frag=""
out+=$'\n\n'
out+="- **$tool** - $(manifest_get tool "$tool" description)"
if [ -n "$frag" ] && [ -f "$dir/$frag" ]; then
out+=$'\n\n'
out+="$(sed 's/^/ /' "$dir/$frag")"
fi
done
printf '%s' "$out"
}
briefing_compose() {
local forge="$1" workflow="$2" harness="$3"
local caps autonomy workflow_frag harness_frag text
@@ -1310,6 +1593,15 @@ briefing_compose() {
text+="$(_briefing_core_corpus)"
text+=$'\n\n'
fi
# Optional tools, from DEJIMA_TOOLS. A tool the session is not told about
# is one it will not use, so the manifest's instructions fragment is the
# whole of the wiring for a harness: dejima renders it rather than letting
# the tool's own installer patch a config, which is the difference
# docs/tools.md draws between a hardened tool and a bare install.
if [ -n "${DEJIMA_TOOLS:-}" ]; then
text+="$(_briefing_core_tools "${DEJIMA_TOOLS}")"
text+=$'\n\n'
fi
# The harness fragment closes the briefing: it supplies both the resume
# command closing_a has just promised and the sentence about where the
# session history it resumes from actually lives. Both are the harness's
+159
View File
@@ -0,0 +1,159 @@
#!/bin/bash
# Makes Ctrl-Z suspend the whole launch instead of disappearing into the
# container. Started in the background by bin/dejima for the duration of the
# container run; not something to run by hand.
#
# `docker compose run` puts the terminal in raw mode to proxy the container's
# TTY, and raw mode has ISIG off - so Ctrl-Z is not a signal at all, it is the
# byte 0x1a, delivered to whatever is running in the container. A harness that
# implements Ctrl-Z itself (nvim and Claude Code both do) then restores the
# terminal and stops, and nothing inside a container can continue it: there is
# no job-control shell in there. The session is wedged, the host never noticed,
# and there is no job to `fg`. That is #179.
#
# So the one character that means "suspend" is taken back for the host, and
# only that one: intr and quit stay undefined, so Ctrl-C and Ctrl-\ still reach
# the session as bytes, which is how a harness expects to be interrupted.
#
# Everything host-side is in one process group - this script, the container
# run, the credential proxy, the conversation shipper - so the kernel stops and
# continues all of it together. Nothing here forwards signals.
#
# On resume the terminal has to be put back. The shell restores its OWN modes
# when it takes the terminal at the prompt, and puts those same modes back on
# `fg` rather than the ones the job was using - measured on both bash and zsh -
# so without this the session comes back line-buffered and echoing. The SIGCONT
# trap re-applies the modes the container run was using.
#
# It then has to make the harness redraw, over whatever the shell printed while
# the session was away. That used to be `kill -WINCH`, on the reasoning that a
# resize is what makes a full-screen program repaint. The reasoning was right
# and the signal was not enough: a program that answers SIGWINCH by re-reading
# the size, seeing the same numbers and doing nothing gets a poke that costs it
# nothing to ignore.
#
# Measured against the real programs rather than a stand-in, on a pty, counting
# the bytes each wrote in a fixed window after each kind of poke:
#
# idle same-size SIGWINCH size changed and back
# Claude Code 0 0 3337
# Cline 0 0 17810
# nvim (control) 0 3356 6724
#
# Both shipped harnesses ignore the signal outright. So the size is changed
# and changed straight back, which is a genuine resize twice over: TIOCSWINSZ
# raises SIGWINCH on the foreground process group by itself, the container
# client answers a resize by resizing the container's TTY, and the harness
# inside sees dimensions that actually moved.
set -u
usage() {
cat <<'EOF'
Usage: dejima-tty-suspend
Reclaims Ctrl-Z for the host while a dejima session runs, so that suspending
backgrounds the whole launch and `fg` brings back a working session. Without
it Ctrl-Z is delivered into the container as a keystroke and the session is
left suspended with no way to resume it.
Started in the background by bin/dejima and stopped when the session ends.
Running it by hand does nothing useful: it acts on the terminal it inherits
and exits on its own if no container run takes that terminal over.
Options:
-h, --help Show this help.
EOF
}
case "${1:-}" in
-h|--help) usage; exit 0 ;;
"") ;;
*) usage >&2; exit 1 ;;
esac
# A terminal to act on, or nothing to do. bin/dejima starts this in the
# background, where the shell gives stdin /dev/null, so every stty below reads
# the terminal by name rather than from a descriptor.
[ -e /dev/tty ] || exit 0
stty -a < /dev/tty >/dev/null 2>&1 || exit 0
# Wait for the container run to take the terminal, which is what "-isig" in the
# settings means. Applying the reclaim before that would just be overwritten by
# it. Bounded rather than open-ended: a launch that fails before attaching
# should leave this script gone, not waiting.
attached=0
for _ in $(seq 1 100); do
if stty -a < /dev/tty 2>/dev/null | grep -q -- '-isig'; then
attached=1
break
fi
sleep 0.05
done
[ "$attached" = 1 ] || exit 0
# isig turns the signal characters back on; the three that are not wanted are
# undefined by name, so only susp survives. dsusp is not on Linux terminals but
# is named anyway for the cost of nothing, and ignored where it does not exist.
stty isig susp '^Z' intr undef quit undef < /dev/tty 2>/dev/null ||
stty isig susp '^Z' intr undef quit undef dsusp undef < /dev/tty 2>/dev/null ||
exit 0
# What the session's terminal looks like now, to be put back on resume.
session_modes="$(stty -g < /dev/tty 2>/dev/null)" || exit 0
on_cont() {
stty "$session_modes" < /dev/tty 2>/dev/null || true
# One row shorter, held, and back. Only the kernel's idea of the size
# changes - the terminal emulator is not told and does not need to be,
# because what a program reads is the kernel's value. It ends where it
# started, so a failure partway leaves the size wrong rather than the
# session unusable, which is why the restore is unconditional.
#
# The hold is the whole of it, and the first version of this did not have
# one. Two ioctls back to back are not a resize a program can see: the
# signal arrives, the handler reads the size as it is NOW, and by then it
# is back where it started. Measured on a pty, bytes written after the
# poke:
#
# no hold 0.1s 0.2s
# Cline 0 0 17810
# Claude Code 0 3328 3328
#
# Cline sets the floor at something between 0.1 and 0.2 seconds. This uses
# half a second, which is 2.5x that - headroom for the hop this could not
# measure, since a real session's resize has to travel from the host tty
# through the container client and the daemon to the container's own tty
# before the harness sees it at all.
#
# The cost is that the terminal is one row short for half a second during
# a resume, while the harness is redrawing anyway.
local size rows
size="$(stty size < /dev/tty 2>/dev/null)" || size=""
rows="${size%% *}"
case "$rows" in
''|*[!0-9]*) rows="" ;;
*) [ "$rows" -gt 1 ] || rows="" ;;
esac
if [ -n "$rows" ]; then
stty rows "$((rows - 1))" < /dev/tty 2>/dev/null || true
sleep 0.5
stty rows "$rows" < /dev/tty 2>/dev/null || true
fi
# Kept as well as the resize, and not redundant: nvim is the measured
# example of a program that repaints on the bare signal, and a session
# running one under `--cmd` should not be worse off than a harness. The
# whole process group, since this script does not know the container
# client's pid and SIGWINCH is ignored by default everywhere else in it.
kill -WINCH 0 2>/dev/null || true
}
trap on_cont CONT
# Idle until the session ends and bin/dejima stops this. `wait` rather than a
# bare sleep: a trap runs the moment it interrupts a wait, where it would
# otherwise be held until the sleep finished.
while :; do
sleep 300 &
wait $! 2>/dev/null || true
done
+45
View File
@@ -28,6 +28,35 @@
# must match the run-id shape before it is used as a path component.
set -euo pipefail
# --help before anything else runs: no libraries sourced, no config read. The
# moment someone reaches for it is often the moment the config is wrong.
# stdout and exit 0, where usage() stays on stderr and exits nonzero.
case "${1:-}" in
-h|--help)
cat <<'DEJIMA_TTYD_HELP'
dejima-ttyd - serve the web terminal that fronts dejima-attach
Usage: dejima-ttyd
Takes no arguments. Started by systemd/dejima-ttyd.service; running it by hand
is only useful for debugging.
Reads ttyd_port from [remote-access] in the config so the port has exactly one
source of truth - the systemd unit deliberately does not repeat it.
Binds 127.0.0.1 only, and that is a requirement rather than a default worth
overriding. Attaching gives a tmux session on the host, so a listener on any
address a container can route to is an escape hatch out of the sandbox;
loopback is not one. Put nginx in front of it, bound to one LAN address - see
docs/nginx-dejima.conf.example.
Serves nothing until [remote-access] enabled is on.
See docs/remote-access.md.
DEJIMA_TTYD_HELP
exit 0 ;;
esac
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=dejima-project-lib.sh
source "$SCRIPT_DIR/dejima-project-lib.sh"
@@ -62,6 +91,16 @@ font_size="$(remote_ttyd_font_size)"
# C-b; see docs/remote-access.md.
tmux_prefix="$(remote_ttyd_tmux_prefix)"
# Whether a tap on the terminal raises the soft keyboard. Passed on only when
# the operator set it: unset means the page decides from the pointer type,
# which is right for almost everyone and is something a host-wide setting
# cannot see. See docs/remote-access.md.
focus_on_tap="$(remote_ttyd_focus_on_tap)"
# Whether the toolbar is colour-coded. Passed on only when set, for the same
# reason as the line above: the page has a default and it is the right one.
button_colors="$(remote_ttyd_button_colors)"
# Checked rather than left to exec, whose failure is bash's "ttyd: not found"
# on a line number - true, and useless to an operator who needs to know that
# the missing piece is a package.
@@ -134,9 +173,15 @@ font_opt=()
prefix_opt=()
[ -z "$tmux_prefix" ] || prefix_opt=(-t "tmuxPrefix=$tmux_prefix")
focus_opt=()
[ -z "$focus_on_tap" ] || focus_opt=(-t "focusOnTap=$focus_on_tap")
colors_opt=()
[ -z "$button_colors" ] || colors_opt=(-t "buttonColors=$button_colors")
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[@]}"} \
"$SCRIPT_DIR/dejima-attach"
+30
View File
@@ -444,6 +444,36 @@ harness_transcript_files() {
done < <(workspace_slugs_sorted "$ws")
}
# Which sessions still have a transcript on disk, as "<key>\t<session-id>"
# lines. `dejima-epitaph list` matches an epitaph's own session id against
# this to say whether resuming the real session is still on offer, and it has
# no way to work that out for itself: the id is in the filename here and is
# the directory name for the next harness.
#
# $1 is a workspace, or absent for every key this harness holds transcripts
# under - which is what `list --all` needs, since a store key is a slug and a
# slug does not resolve back to a path.
harness_live_sessions() {
local ws="${1:-}" projects key f d
projects="$(harness_config_get projects_dir)"
[ -n "$projects" ] || return 0
if [ -n "$ws" ]; then
while IFS=$'\t' read -r key f; do
[ -n "$f" ] || continue
printf '%s\t%s\n' "$key" "$(basename "$f" .jsonl)"
done < <(harness_transcript_files "$ws")
return 0
fi
for d in "$projects"/*/; do
[ -d "$d" ] || continue
key="$(basename "$d")"
for f in "$d"/*.jsonl; do
[ -f "$f" ] || continue
printf '%s\t%s\n' "$key" "$(basename "$f" .jsonl)"
done
done
}
# Compaction summaries out of this harness's transcripts.
#
# Claude Code writes line-delimited JSON, one record per line, with the
+11 -7
View File
@@ -167,6 +167,8 @@ harness_prepare_config() {
fi
base_url="$(harness_config_get base_url)"
local ctx
ctx="$(harness_config_get context_window)"
# The Anthropic path is the credential proxy's path, and it only works in
# the shape that was actually tested: the proxy on, terminating TLS for
@@ -216,7 +218,7 @@ harness_prepare_config() {
echo "yourself, which needs no key. That means a loopback or RFC 1918" >&2
echo "address, or a host you have allowlisted:" >&2
echo "" >&2
echo " bin/dejima-allow --project <path> <host>" >&2
echo " dejima-allow --project <path> <host>" >&2
echo "" >&2
echo "The allowlist is what decides whether the container can reach it" >&2
echo "at all, so an endpoint that fails this check is one the session" >&2
@@ -246,22 +248,24 @@ harness_prepare_config() {
tmp="$(mktemp "$settings/.providers.XXXXXX")" || return 1
if [ -n "$base_url" ]; then
jq -n --arg m "$model" --arg u "$base_url" \
--arg t "$now" \
--arg t "$now" --arg c "$ctx" \
'{version: 1, lastUsedProvider: "openai-compatible", modes: {},
providers: {"openai-compatible": {
settings: {provider: "openai-compatible", model: $m,
baseUrl: $u, apiKey: "dejima-no-credential-needed"},
settings: ({provider: "openai-compatible", model: $m,
baseUrl: $u, apiKey: "dejima-no-credential-needed"}
+ (if $c == "" then {} else {contextWindow: ($c|tonumber)} end)),
updatedAt: $t, tokenSource: "manual"}}}' > "$tmp"
else
# The placeholder is what Cline sends; the host-side proxy replaces it
# with the real credential on the way out. Same arrangement as Claude
# Code's faked .credentials.json, confirmed end to end against the API.
jq -n --arg m "$model" \
--arg t "$now" \
--arg t "$now" --arg c "$ctx" \
'{version: 1, lastUsedProvider: "anthropic", modes: {},
providers: {anthropic: {
settings: {provider: "anthropic", model: $m,
apiKey: "sandbox-credential-proxy-placeholder"},
settings: ({provider: "anthropic", model: $m,
apiKey: "sandbox-credential-proxy-placeholder"}
+ (if $c == "" then {} else {contextWindow: ($c|tonumber)} end)),
updatedAt: $t, tokenSource: "manual"}}}' > "$tmp"
fi || { rm -f "$tmp"; return 1; }
chmod 600 "$tmp"
+11
View File
@@ -301,6 +301,17 @@ COPY docker/install-forge-bins.sh /tmp/install-forge-bins.sh
RUN bash /tmp/install-forge-bins.sh /tmp/forges /usr/local/lib/dejima/forge /usr/local/bin \
&& rm -rf /tmp/forges /tmp/install-forge-bins.sh
# The other half of NODE_PATH, which only ever applied to require(): ESM
# resolution ignores it entirely and walks up from the importing file looking
# for node_modules, so `import { chromium } from "playwright"` fails from
# every directory a script is likely to be written in while require() of the
# same package succeeds. At the root, that walk always ends here.
#
# It is worth a line because of how the failure reads. An agent writing the
# modern form gets ERR_MODULE_NOT_FOUND, concludes the image has no browser,
# and describes what a page should look like instead of going and seeing.
RUN ln -s /usr/local/share/npm-global/lib/node_modules /node_modules
RUN chmod +x /usr/local/bin/dns-proxy.sh /usr/local/bin/init-firewall.sh /usr/local/bin/log-shipper.sh /usr/local/bin/entrypoint.sh /usr/local/bin/dejima-harness-wrapper /usr/local/lib/dejima/git-guard.sh /usr/local/lib/dejima/forge-guard.sh /usr/local/lib/dejima/dejima-briefing-render.sh && \
echo 'export PATH="$PATH:/usr/local/share/npm-global/bin"' > /etc/profile.d/npm-global-path.sh
+41
View File
@@ -42,9 +42,50 @@ services:
# ten per run - becomes a permanent zombie, and a long session eventually
# exhausts the cgroup pids limit and can no longer fork at all.
init: true
# An explicit floor rather than Docker's default set plus two. The default
# carries about fourteen capabilities and this container uses seven: it
# writes and chowns files, runs the firewall, drops privilege twice (gosu
# for the session, `ulogd -u` for the log daemon), and signals dnsmasq.
# FSETID, MKNOD, SETFCAP, SETPCAP, SYS_CHROOT, AUDIT_WRITE and
# NET_BIND_SERVICE are used by nothing here - the last one because the
# sysctl below is what lets the TLS terminator bind :443 unprivileged.
#
# KILL is the one worth explaining, because it is not obvious from reading
# entrypoint.sh and was missing from the first version of this list. It is
# not for anything the entrypoint does. `dejima-allow --container` adds a
# domain to a RUNNING session by appending a server= line and sending
# dnsmasq a SIGHUP, from outside via docker exec - and dnsmasq drops to its
# own user (`user=dnsmasq`), so root in this container cannot signal it
# without CAP_KILL. Without this line, adding a domain mid-session fails
# with "Operation not permitted", which is a documented feature every
# session briefing tells the agent to ask for.
#
# The general lesson, since the next capability will be the same shape:
# what the container needs is not only what its entrypoint does. Things
# reach into it later.
#
# This narrows the ROOT phase. The session user's capabilities were already
# empty: they are cleared on the gosu drop, which runtime.bats asserts by
# reading CapEff of the running process. What this adds is that the set was
# never there to be regained.
cap_drop:
- ALL
cap_add:
- NET_ADMIN
- NET_RAW
- CHOWN
- DAC_OVERRIDE
- FOWNER
- SETUID
- SETGID
- KILL
# No path back up. gosu drops privilege rather than gaining it, so this
# does not interfere with it - what it forbids is a setuid binary or a file
# capability raising privileges on exec, which is the shape any escalation
# from the session user would have to take. The gosu claim becomes
# structural instead of only observed.
security_opt:
- no-new-privileges:true
# The credential-proxy TLS terminator binds :443 as the unprivileged
# sandbox user (see entrypoint.sh). Docker already defaults this to 0, but
# pinning it makes the dependency explicit: a future default change then
+38 -20
View File
@@ -117,26 +117,44 @@ if [ -n "${DEJIMA_PUSH_HOSTS:-}${DEJIMA_BLOCKED_BRANCHES:-}" ]; then
ln -sf /usr/local/lib/dejima/git-guard.sh /usr/local/bin/git
fi
if [ -n "${DEJIMA_MERGE_BLOCK:-}" ]; then
# The first word of the block names the CLI to shadow. The real one is
# already behind the symlink this replaces (see install-forge-bins.sh), so
# the guard has a fixed path to exec.
_blocked_cli="${DEJIMA_MERGE_BLOCK%% *}"
case "$_blocked_cli" in
*/*|.*|'')
echo "entrypoint: refusing merge-block command name: $_blocked_cli" >&2
exit 1 ;;
esac
if [ -x "/usr/local/lib/dejima/forge/$_blocked_cli" ]; then
ln -sf /usr/local/lib/dejima/forge-guard.sh "/usr/local/bin/$_blocked_cli"
else
# A workflow blocking a command this forge does not ship is a
# misconfiguration that would otherwise be silent - the guard would
# never be reached and the block would look enforced.
echo "entrypoint: [workflow] merge_block names '$_blocked_cli', which forge" >&2
echo "'${DEJIMA_FORGE_NAME:-?}' does not install. Nothing is being blocked." >&2
exit 1
fi
# Every CLI the shadow has something to do about: the one whose merge a
# workflow blocks, and every one that writes a body the session authored. More
# than one, because a forge can have a CLI per artifact rather than one with
# subcommands, and because a forge with nothing blocked can still have
# something to disclose.
#
# The first word of each record names the CLI. The real one is already behind
# the symlink this replaces (see install-forge-bins.sh), so the guard has a
# fixed path to exec.
_shadow_clis() {
[ -z "${DEJIMA_MERGE_BLOCK:-}" ] || printf '%s\n' "${DEJIMA_MERGE_BLOCK%% *}"
# "<kind> <cli> <subcommand>...", so the CLI is the second word.
[ -z "${DEJIMA_DISCLOSE:-}" ] || printf '%s\n' "$DEJIMA_DISCLOSE" |
while read -r _ _cli _; do
[ -z "$_cli" ] || printf '%s\n' "$_cli"
done
}
_shadow_cli_list="$(_shadow_clis | sort -u)"
if [ -n "$_shadow_cli_list" ]; then
while read -r _cli; do
[ -n "$_cli" ] || continue
case "$_cli" in
*/*|.*|'')
echo "entrypoint: refusing forge command name: $_cli" >&2
exit 1 ;;
esac
if [ -x "/usr/local/lib/dejima/forge/$_cli" ]; then
ln -sf /usr/local/lib/dejima/forge-guard.sh "/usr/local/bin/$_cli"
else
# Naming a command this forge does not ship is a misconfiguration
# that would otherwise be silent - the guard would never be
# reached, and the block or the disclosure would look enforced.
echo "entrypoint: this forge's manifest names '$_cli', which forge" >&2
echo "'${DEJIMA_FORGE_NAME:-?}' does not install. Nothing is being enforced." >&2
exit 1
fi
done <<< "$_shadow_cli_list"
fi
# Put the harness wrapper on PATH under the harness's own command name,
+280 -36
View File
@@ -1,12 +1,19 @@
#!/bin/bash
# Shadows a forge CLI on PATH so the one subcommand a workflow blocks is
# refused whatever ran it.
# Shadows a forge CLI on PATH, so that three rules hold whatever ran it: the
# one subcommand a workflow blocks is refused, a command aimed at another
# repository is refused, and a body the session authored says so.
#
# The rule is the one hooks/merge-guard.sh applies - a merge is the operator's
# decision, so the sandbox opens a PR and stops - moved off the harness's hook
# protocol and onto the command. It works for a harness that cannot intercept
# a tool call, and it compares argv rather than regex-matching a command
# string, so quoting, spacing and pipelines stop mattering.
# The first rule is the one hooks/merge-guard.sh applies - a merge is the
# operator's decision, so the sandbox opens a PR and stops - moved off the
# harness's hook protocol and onto the command. It works for a harness that
# cannot intercept a tool call, and it compares argv rather than
# regex-matching a command string, so quoting, spacing and pipelines stop
# mattering.
#
# The second is here for the same reason it is not in the forge CLIs: dejima
# writes two of them and not the third, so a disclosure living in the tools is
# one that depends on which forge an operator picked. This is the one place
# every forge passes through.
#
# Fast-fail, not a boundary, exactly as the hook was: what actually decides
# whether a merge is possible is the scope of the token the forge issued. This
@@ -15,6 +22,14 @@
# DEJIMA_MERGE_BLOCK the full command prefix to refuse, e.g. "gitea-pr
# merge". Its first word is the command this shadows;
# the rest is the subcommand sequence being refused.
# DEJIMA_DISCLOSE one record per line, "<kind> <command prefix>", e.g.
# "pr gitea-pr create". Both are resolved host-side by
# bin/dejima from the forge's manifest, never hardcoded
# to a forge here.
# DEJIMA_FORGE_REPO the "<owner>/<name>" of the repository the session
# was launched against, and DEJIMA_FORGE_REPO_FLAGS /
# DEJIMA_FORGE_REPO_ENV the spellings this forge's CLI
# accepts for naming a different one.
# DEJIMA_FORGE_REAL_DIR where the real CLIs live, behind the shadow.
set -u
@@ -26,37 +41,266 @@ if [ ! -x "$real" ]; then
exit 127
fi
# Outside a dejima container, or with nothing blocked, this is a pass-through.
if [ -z "${DEJIMA:-}" ] || [ -z "${DEJIMA_MERGE_BLOCK:-}" ]; then
# 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
exec "$real" "$@"
fi
read -ra blocked <<< "$DEJIMA_MERGE_BLOCK"
# The first word names the command; only the rest is a subcommand sequence to
# compare against argv. A block naming a different command than the one being
# run is not this invocation's business.
[ "${#blocked[@]}" -ge 1 ] || exec "$real" "$@"
[ "${blocked[0]}" = "$self" ] || exec "$real" "$@"
want=("${blocked[@]:1}")
if [ "${#want[@]}" -eq 0 ]; then
# The whole command is blocked, not one of its subcommands.
echo "Blocked by dejima's forge guard: '$self' is not available in the sandbox." >&2
exit 1
fi
# argv has to begin with the blocked sequence. Flags before the subcommand are
# skipped so `gitea-pr --verbose merge 7` is still a merge.
args=("$@")
j=0
for w in "${want[@]}"; do
while [ "$j" -lt "${#args[@]}" ] && [ "${args[$j]#-}" != "${args[$j]}" ]; do
j=$((j + 1))
done
[ "$j" -lt "${#args[@]}" ] || exec "$real" "$@"
[ "${args[$j]}" = "$w" ] || exec "$real" "$@"
j=$((j + 1))
# The words of a command prefix, minus the CLI name, appear as a run of
# consecutive non-flag arguments. Flags are dropped first, so `gitea-pr
# --verbose merge 7` is still a merge.
#
# Anywhere in that run rather than at the start of it, because a flag's value
# is indistinguishable from a subcommand here: in `gh --repo o/r pr merge 7`
# the first non-flag argument is `o/r`, and requiring the match to begin there
# let the most ordinary way to name a PR from outside its checkout walk
# straight past the block. Knowing which flags take a value would mean knowing
# each CLI, which is the coupling this guard exists to avoid.
#
# The cost is a command that happens to pass the words as consecutive values -
# `--title pr --body merge` - matching. For the merge block that is a loud
# refusal against a merge going through unnoticed; for a disclosure it is a
# line of text on something an agent did write anyway. Both are the direction
# to be wrong in.
plain=()
for arg in "$@"; do
case "$arg" in -*) continue ;; esac
plain+=("$arg")
done
echo "Blocked by dejima's forge guard: merging is a human decision in the sandbox - open a PR/MR, then the operator merges. Report its URL and stop." >&2
exit 1
# argv_has <word>... - true when those words appear consecutively in `plain`.
argv_has() {
local want=("$@") n="${#plain[@]}" m="$#" i=0 k
[ "$m" -gt 0 ] || return 1
while [ "$((i + m))" -le "$n" ]; do
k=0
while [ "$k" -lt "$m" ] && [ "${plain[$((i + k))]}" = "${want[$k]}" ]; do
k=$((k + 1))
done
[ "$k" -lt "$m" ] || return 0
i=$((i + 1))
done
return 1
}
# --- the merge block --------------------------------------------------------
if [ -n "${DEJIMA_MERGE_BLOCK:-}" ]; then
read -ra blocked <<< "$DEJIMA_MERGE_BLOCK"
# The first word names the command; only the rest is a subcommand sequence
# to compare against argv. A block naming a different command than the one
# being run is not this invocation's business.
if [ "${#blocked[@]}" -ge 1 ] && [ "${blocked[0]}" = "$self" ]; then
if [ "${#blocked[@]}" -eq 1 ]; then
# The whole command is blocked, not one of its subcommands.
echo "Blocked by dejima's forge guard: '$self' is not available in the sandbox." >&2
exit 1
fi
if argv_has "${blocked[@]:1}"; then
echo "Blocked by dejima's forge guard: merging is a human decision in the sandbox - open a PR/MR, then the operator merges. Report its URL and stop." >&2
exit 1
fi
fi
fi
# --- the repository the session was launched against ------------------------
#
# A forge whose CLI can be aimed elsewhere declares how (repo_flags,
# repo_env). The gitea CLIs declare neither, because they resolve the repo
# from origin and refuse another host; `gh` declares both, so on that path the
# most ordinary way to name a PR from outside its checkout is also the way to
# file an issue on a repository the session has nothing to do with (#187).
#
# Fast-fail, not a boundary, for the same reason the merge block is not one:
# the session holds the token and could call the API directly. What bounds
# this is the scope of push_token_file.
#
# Refused rather than skipped when the launch resolved no repository at all: a
# workspace with no origin has nothing for --repo to legitimately name, and
# "cannot confirm" is not a reason to let it through.
if [ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ] || [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ]; then
refuse_repo() {
echo "Blocked by dejima's forge guard: this session was launched against ${DEJIMA_FORGE_REPO:-no repository}, and $1 names '$2'. Act on the repository the sandbox mounts." >&2
exit 1
}
# Case-insensitively: forges treat an owner and a repository name as
# case-preserving but not case-sensitive, and a refusal over capitalisation
# would be a false one.
same_repo() {
[ -n "${DEJIMA_FORGE_REPO:-}" ] && [ "${1,,}" = "${DEJIMA_FORGE_REPO,,}" ]
}
if [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ]; then
env_value="${!DEJIMA_FORGE_REPO_ENV:-}"
if [ -n "$env_value" ] && ! same_repo "$env_value"; then
refuse_repo "$DEJIMA_FORGE_REPO_ENV" "$env_value"
fi
fi
if [ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ]; then
read -ra repo_flags <<< "$DEJIMA_FORGE_REPO_FLAGS"
args_left=("$@")
while [ "${#args_left[@]}" -gt 0 ]; do
arg="${args_left[0]}"
args_left=("${args_left[@]:1}")
for flag in "${repo_flags[@]}"; do
value=""
case "$arg" in
"$flag")
# The value is the next argument, and its absence is
# the CLI's error to report rather than this one's.
[ "${#args_left[@]}" -gt 0 ] || continue
value="${args_left[0]}"
;;
"$flag"=*) value="${arg#*=}" ;;
*)
# A one-dash flag may carry its value with no
# separator - `-Rowner/name` - which two-dash flags do
# not do.
case "$flag" in
--*) continue ;;
-?) ;;
*) continue ;;
esac
case "$arg" in "$flag"?*) value="${arg#"$flag"}" ;; *) continue ;; esac
;;
esac
[ -n "$value" ] || continue
same_repo "$value" || refuse_repo "$flag" "$value"
done
done
fi
fi
# --- the authorship disclosure ---------------------------------------------
[ -n "${DEJIMA_DISCLOSE:-}" ] || exec "$real" "$@"
# The wording is core's, not any forge's, so that dejima says one thing about
# agent authorship however the session reached the forge. The kind is what a
# manifest picks per command.
marker_for() {
case "$1" in
pr) printf '**Summary generated by LLM agent**' ;;
issue) printf '**Filed by LLM agent**' ;;
comment) printf '**Comment by LLM agent**' ;;
esac
}
kind=""
while read -r record_kind record_rest; do
[ -n "$record_kind" ] || continue
read -ra words <<< "$record_rest"
[ "${#words[@]}" -ge 1 ] || continue
[ "${words[0]}" = "$self" ] || continue
if argv_has "${words[@]:1}"; then
kind="$record_kind"
break
fi
done <<< "$DEJIMA_DISCLOSE"
[ -n "$kind" ] || exec "$real" "$@"
marker="$(marker_for "$kind")"
[ -n "$marker" ] || exec "$real" "$@"
# The body is rewritten into a temp file and the argument replaced with its
# path. That covers every spelling including `--body-file -`, where reading
# stdin here is the only way to prepend to it at all and the CLI behind this
# cannot read it twice. Turning --body-file into --body instead would put the
# whole body in argv, where a long one meets the 128KB per-argument limit.
#
# A command with no body argument is passed through untouched: there is
# nothing to disclose on. `gh pr create --fill` is that case - its body comes
# from commit messages, which are prose the session wrote somewhere else.
# The CLI behind this validates its own --body-file and then never gets the
# chance to: what reaches it is a temp file that always exists. So the check
# happens here instead, and refuses rather than sending something the operator
# did not write.
#
# Without it a mistyped path produced a PR whose entire body was the marker -
# created, not refused - and a --body-file naming something unreadable-forever
# (/dev/zero) filled a temp file until the disk did. Both are shapes the CLIs
# already refused before this shim stood in front of them.
body=""
read_body_or_die() {
if [ "$1" = "-" ]; then
body="$(cat)"
return 0
fi
if [ ! -f "$1" ]; then
echo "dejima: --body-file names something that is not a readable file: $1" >&2
exit 1
fi
body="$(cat -- "$1")" || exit 1
}
# The path to a temp file holding the marker and the body. Set outside the
# command substitution as well, so the caller can delete it afterwards.
tmp=""
disclosed() {
tmp="$(mktemp)" || exit 1
printf '%s\n\n%s' "$marker" "$1" > "$tmp" || exit 1
}
# Only the first body argument is rewritten. A second would mean the CLI is
# being passed two, which is its own error to report.
args=()
rewrote=0
while [ "$#" -gt 0 ]; do
if [ "$rewrote" = 0 ]; then
case "$1" in
--body-file)
# Left alone when the value is missing: that is the CLI's
# error to give, not this script's to pre-empt.
if [ "$#" -ge 2 ]; then
read_body_or_die "$2"
disclosed "$body"
args+=(--body-file "$tmp")
rewrote=1
shift 2
continue
fi
;;
--body-file=*)
read_body_or_die "${1#--body-file=}"
disclosed "$body"
args+=("--body-file=$tmp")
rewrote=1
shift
continue
;;
--body)
if [ "$#" -ge 2 ]; then
args+=(--body "$marker
$2")
rewrote=1
shift 2
continue
fi
;;
--body=*)
args+=("--body=$marker
${1#--body=}")
rewrote=1
shift
continue
;;
esac
fi
args+=("$1")
shift
done
# Run rather than exec, only on this path: exec would replace this process and
# leave the temp file behind, and a body is not always small.
if [ -n "$tmp" ]; then
"$real" "${args[@]}"
status=$?
rm -f "$tmp"
exit "$status"
fi
exec "$real" "${args[@]}"
+46 -1
View File
@@ -49,14 +49,59 @@ ipset destroy github-domains 2>/dev/null || true
# level, e.g. no ip6_tables module) - once it's confirmed to work at all, a
# failure partway through must fail the whole script (set -e, no more
# blanket `|| true`) rather than silently leaving IPv6 wide open.
#
# The `else` below used to log that the family was presumably off at the
# kernel level, and continue. That inferred a fact about the WORLD from a fact
# about a TOOL, and the two are not the same condition: a container whose kernel
# lacks ip6_tables, on a host with IPv6 enabled, took that branch, logged a
# reassuring line, and ran with IPv6 live and unfiltered. Nothing failed,
# because the only thing that had happened was a check that could not tell.
#
# So the branch asks the kernel instead. /proc/net/if_inet6 lists every IPv6
# address on every interface, and it is the netns's own view - present with
# `::1` alone when the stack is up and nothing is reachable, absent entirely
# when IPv6 is compiled out.
#
# Loopback does not count. Refusing to start over `::1` would refuse every
# container that has no IPv6 connectivity at all, which is a false refusal
# for the common case. What matters is an address on an interface that leaves
# the box.
_ipv6_off_box() {
[ -e /proc/net/if_inet6 ] || return 1
# address ifindex prefixlen scope flags devname
awk '$6 != "lo" { found = 1 } END { exit !found }' /proc/net/if_inet6
}
if ip6tables -L >/dev/null 2>&1; then
ip6tables -F
ip6tables -X
ip6tables -P INPUT DROP
ip6tables -P OUTPUT DROP
ip6tables -P FORWARD DROP
elif _ipv6_off_box; then
echo "ip6tables is unusable here, and IPv6 is live on an interface that" >&2
echo "leaves this container. Turning the family off in this network" >&2
echo "namespace, since it cannot be filtered." >&2
# Written to /proc directly rather than through sysctl(8), which is one
# more thing that has to be in the image for the firewall to hold. Per
# interface as well as `all`, because which of those flushes existing
# addresses has varied across kernels.
for _conf in /proc/sys/net/ipv6/conf/*/disable_ipv6; do
[ -w "$_conf" ] || continue
echo 1 > "$_conf" 2>/dev/null || true
done
if _ipv6_off_box; then
echo "" >&2
echo "IPv6 is still up and there is no way to filter it from here, so" >&2
echo "every rule below would be bypassable by an AAAA-resolved address." >&2
echo "Refusing to start rather than reporting a boundary that is not" >&2
echo "there. Either give the container a usable ip6tables (the" >&2
echo "ip6_tables module on the host), or disable IPv6 for its network." >&2
exit 1
fi
echo "IPv6 is off in this namespace."
else
echo "ip6tables unusable - assuming IPv6 is unavailable at the kernel level"
echo "ip6tables unusable, and no IPv6 address outside loopback - nothing to filter"
fi
# Reinsert just the rules captured above - nothing else Docker had set up in
+26
View File
@@ -57,7 +57,33 @@ for conf in "$root"/*/forge.conf; do
declared=""
for rel in $value; do
# "system:<name>" is a CLI the image already provides - `gh`, which
# comes from a package rather than from this repo. It still has to sit
# behind the shadow, because that is the fixed path forge-guard.sh
# execs the real command through, and a forge whose CLI never gets
# there has a merge block the entrypoint cannot enforce. A symlink
# rather than a copy: it is not ours, and it can be large.
case "$rel" in
system:*)
name="${rel#system:}"
case "$name" in
''|*/*|*..*)
echo "forge $forge declares a bad system command: $rel" >&2
exit 1 ;;
esac
# Resolved now rather than trusted: a name that is not in the
# image yet would otherwise become a dangling symlink, and the
# first sign of it would be the guard exiting 127 mid-session.
path="$(command -v "$name" 2>/dev/null || true)"
if [ -z "$path" ]; then
echo "forge $forge declares $rel, which this image does not provide" >&2
exit 1
fi
ln -sf "$path" "$real/$name"
ln -sf "$real/$name" "$dest/$name"
declared="$declared $name"
declared_any=1
continue ;;
/*|*..*|"")
echo "forge $forge declares an out-of-tree install path: $rel" >&2
exit 1 ;;
+133 -11
View File
@@ -16,12 +16,18 @@ set to get a working sandbox: uncomment what you need. `dejima --print-config`
prints the resolved schema with real values, which is the fastest way to see
what a given launch will actually use.
Every tool takes `--help`, which describes its own arguments and what they do.
That is the fastest answer to "what does this flag mean"; this page is for the
config file behind them.
| Key | Default | Purpose |
| --- | --- | --- |
| `[core] harness` | *(none - must be set)* | agent harness the container runs; refused if unset, so a later default cannot change your selection. Also settable per launch with `--harness`, which is how a host keeps one harness as its default and reaches another when it wants to |
| `[core] forge` | `none` | forge integration: `gitea` (`gitea-pr`/`gitea-issue`), `github` (`gh`, opens `ssh.github.com:22` for push), or `none` (no forge at all) |
| `[core] forge` | `none` | forge integration: `gitea` (`gitea-pr`/`gitea-issue`), `github` (`gh`/`github-pr`, opens `ssh.github.com:22` for push), or `none` (no forge at all) |
| `[core] workflow` | `local-only` | workflow `sandbox-context.sh` tells the agent to follow: `worktree-pr` (push + open a PR), `branch-push` (push, stop at the branch), or `local-only` (commit locally, push nothing) |
| `[core] command` | `claude` | container's main process (e.g. `nvim`), also settable per launch with `--cmd` |
| `[core] 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] 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] 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 |
@@ -29,6 +35,10 @@ what a given launch will actually use.
| `[harness.claude-code] projects_dir` | `~/.claude/projects` | where Claude Code session transcripts live |
| `[harness.claude-code] file_history_dir` | `~/.claude/file-history` | Claude Code's edit-undo record, mounted per session rather than whole |
| `[harness.claude-code] statusline_path` | `~/.claude/statusline.sh` | statusline script path |
| `[harness.cline] model` | *(none - must be set)* | model id the endpoint serves; no default, because any shipped one goes stale with the next Cline release. Also settable per launch with `--model` |
| `[harness.cline] base_url` | unset | OpenAI-compatible endpoint. Unset means Anthropic through dejima's credential proxy. Accepts a loopback or RFC 1918 address, or a host you have allowlisted. Also settable per launch with `--base-url` |
| `[harness.cline] context_window` | unset | tokens Cline may use before compacting. Needed for a model it has no catalogue entry for, which is every self-served one. Also settable per launch with `--context-window` |
| `[harness.cline] state_dir` | `~/.local/share/dejima/cline` | per-project Cline state; one directory per workspace |
| `[forge.gitea] ssh_host` | unset | Gitea/Forgejo SSH hostname: skip if you only push to GitHub |
| `[forge.gitea] api` | unset | `https://<gitea host>/api/v1`: skip if you only push to GitHub |
| `[forge.gitea] token_file` | `~/.config/gitea/token` | path to the Gitea/Forgejo API token (the file, not the token itself) |
@@ -39,20 +49,24 @@ what a given launch will actually use.
| `[mounts] nvim_config` | unset | nvim config dir, used when `[core] command = nvim` |
| `[credentials] proxy` | `on` | host-side credential proxy; `off` mounts the real OAuth credential into the container directly |
| `[credentials] transport` | `tls` | set to `loopback` to fall back to a plaintext forwarder with an explicit `ANTHROPIC_BASE_URL`, keeping the credential out of the container but reinstating the base-URL fingerprint: for when certificate pinning breaks the default TLS path (see [security notes](docs/security.md)) |
| `[logging] loki_url` | unset | ship DNS/firewall logs to a Loki endpoint |
| `[epitaph] enabled` | `on` | keep the summary Claude Code writes when a session compacts |
| `[logging] loki_url` | unset | ship DNS/firewall logs to a Loki endpoint; `--no-log` clears it for one launch, which silences everything downstream of it |
| `[epitaph] enabled` | `on` | keep the summary a harness writes when a session compacts; `--no-epitaph` skips it for one launch |
| `[epitaph] dir` | `~/.local/share/dejima/epitaphs` | where they are kept; never mounted into a container |
| `[epitaph] loki` | `off` | also push each epitaph to `[logging] loki_url`, host-side |
| `[logging] conversation` | `off` | opt-in: also ship session transcripts to Loki (needs `loki_url`); see "Conversation logging (opt-in)" in docs/security.md |
| `[logging] conversation` | `off` | opt-in: also ship session transcripts to Loki (needs `loki_url`); see "Conversation logging (opt-in)" in docs/security.md. `--no-conversation` turns it off for one launch, and `--no-log` implies it |
| `[audit] dir` | `~/.local/share/dejima/audit` | where exit-time audit findings are stored |
| `[audit] baselines_dir` | `~/.local/share/dejima/baselines` | where audit baselines are stored |
| `[audit] keep` | `10` | how many past findings/baselines to retain |
| `[audit] strict` | `off` | refuse a headless (non-tty) launch over unacknowledged CRITICAL findings |
| `[audit] diffs` | `on` | keep the diff of what changed alongside each finding; `off` records that something changed without retaining the content |
| `[audit] strict` | `off` | refuse a headless (non-tty) launch over unacknowledged CRITICAL findings; also settable per launch with `--strict-audit` |
| `[remote-access] enabled` | `off` | the switch: nothing in "Remote access" below runs until this is `on` |
| `[remote-access] ntfy_url` | unset | ntfy topic URL for session notifications |
| `[remote-access] base_url` | unset | public URL of the web front door, for notification links |
| `[remote-access] ttyd_port` | `7681` | port `ttyd` listens on |
| `[remote-access] ttyd_font_size` | unset | `ttyd` terminal font size |
| `[remote-access] ttyd_tmux_prefix` | unset | the tmux prefix the page's buttons send; the page assumes stock `C-b` without it |
| `[remote-access] ttyd_focus_on_tap` | unset | `on` lets a tap on the terminal raise the phone keyboard; unset means only the KBD button does |
| `[remote-access] ttyd_button_colors` | unset | `off` returns the toolbar to monochrome |
| `[remote-access] debounce` | `60` | seconds between repeat notifications for the same session |
**No forge is the default**: `[core] forge = none` with
@@ -377,12 +391,23 @@ configuration can work at all, which is worth saying at launch rather than as
an auth error three layers down. See
[development.md](development.md#worked-example-cline).
If the endpoint serves a model Cline has no catalogue entry for, Cline has no
context budget for it either, and will keep growing the conversation until the
server rejects the request rather than compacting. dejima does not set one:
`contextWindow` belongs to the model you are serving, and a wrong value is
worse than none. If you hit `exceeds the available context size`, that is what
it is.
If the endpoint serves a model Cline has no catalogue entry for - which is every
model you serve yourself - Cline has no context budget for it either, and will
grow the conversation until the *server* rejects it rather than compacting:
```
rpc error: ... request (16465 tokens) exceeds the available context size (16384 tokens)
```
Set `[harness.cline] context_window` to stop that. With it, Cline compacts
before it reaches the limit instead of overflowing.
Set it a little **under** whatever the server allows. Cline's accounting is
approximate, and a compaction does not always land under target when a single
tool result is large, so a value equal to the server's limit still overflows
occasionally. There is no default: the right number belongs to the model being
served, and a wrong one is worse than none - too high changes nothing, too low
compacts constantly.
`[harness.cline] model` has no default and must be set. Any model id shipped
here would go stale with the next Cline release, and choosing the model an
@@ -399,6 +424,55 @@ editing the base `Dockerfile`: a dependency added that way is declared,
approved and layered like any other, and it survives a rebuild. Editing the
Dockerfile directly puts it outside that record.
## Identities
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.
```ini
[identity.work]
ssh_key = ~/.ssh/id_work
user_name = A Name
user_email = name@work.example
token_file = ~/.config/gitea/work-token
[identity.oss]
ssh_key = ~/.ssh/id_oss
user_email = name@personal.example
push_token_file = ~/.config/gh/oss-push
```
Select one with `[core] identity = work`, or per launch with
`--identity oss`.
**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
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.
**A name with no section is refused.** `[core] identity = wrok` does not
quietly use this machine's own credentials - it stops the launch and says so.
Pushing under the wrong key fails loudly; pushing under your default key when
you asked for another one does not fail at all, which is the direction worth
being careful about.
**`user_name` and `user_email` reach the container's git config**, appended to
the copy dejima already makes of your `~/.gitconfig`. Your real one is not
touched, which was already true and is what the copy exists for.
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.
## Headless git push without agent forwarding
**The reason to do this is identity, not uptime.** A forwarded agent
@@ -562,6 +636,54 @@ means anything you left in it is readable by every session after the one you
left it for. A credential dropped in there to get one job done, and then
forgotten, is a credential you have handed to every session since.
## Reading what a session left behind
When a session compacts, the summary it writes is the only durable account of
what a long session worked out - and it goes with the container. dejima keeps
a copy host-side, in `[epitaph] dir`, from `bin/dejima`'s exit trap. That
directory is never mounted into a container.
Three commands read it, all defaulting to the repository you are standing in:
```sh
dejima-epitaph list # what is kept, oldest first
dejima-epitaph search "dns proxy" # which one was it
dejima-epitaph show 12 # open it in $VISUAL or $EDITOR
```
`show` takes a position from the listing that `list` and `search` print, or
an epitaph's filename, and defaults to the newest. It opens an editor when
there is a terminal to open one on and writes to stdout when there is not, so
`dejima-epitaph show | grep` behaves. `--all` widens `list` and `search` to
every project on the machine, which is also how to see everything the store
holds.
**`list` says whether each session is still resumable.** Where it is, the
transcript the summary came from is still on disk, and `claude --resume
<session>` gives you the session itself rather than a summary of it. The
epitaph is what remains after the harness ages that transcript out.
**Nothing here hands an epitaph back to a session, and that is deliberate.**
A session can write its own transcript, so it can author what its own epitaph
says. Delivering that to a later session automatically would plant text in
its premises as established background, which is the channel the
never-mounted store exists to close. Reading one and deciding what to do with
it is the operator's, which is why `show` opens an editor rather than
injecting anything: the header it opens on records that this preserves what
was written rather than vouching for it.
**Kept until you delete them.** There is no rotation. Epitaphs are an
archive, and a count-based cap would delete oldest-first - the entries most
likely to hold reasoning nobody remembers. They are small (tens of KB each,
against transcripts a hundred times that), so the store is a housekeeping
question rather than a disk one: `[epitaph] dir` is a plain directory of
plain markdown, and `rm` is the current answer.
Setting `[epitaph] enabled = off` stops new ones being kept. It does not hide
what is already there: `list`, `search` and `show` keep working, because
turning the feature off is the point at which wanting to review and clear the
store is most likely.
## Testing frontend changes
Playwright (Chromium) is pre-installed at image build time, browser
+6 -1
View File
@@ -32,12 +32,15 @@ run them.
| `bin/dejima-config-lib.sh` | The config file's schema, parser and value validators, plus the dependency-spec grammar. The base layer: it sources nothing, so everything else can source it. Not on PATH |
| `bin/dejima-provider-lib.sh` | Parses forge/workflow/harness manifests and composes the session briefing; not on PATH: see [docs/providers.md](providers.md) |
| `forges/`, `workflows/`, `harnesses/` | Provider directories for the three axes selected by `[core] forge`/`workflow`/`harness` (data, never sourced or executed); see [docs/providers.md](providers.md) |
| `tools/` | Optional tools a launch can add to its image - not an axis, since a launch enables zero or more. Same manifest rules, and a bar for what may be added: see [docs/tools.md](tools.md) |
| `docs/release.md` | The pass a `development` -> `main` release goes through, and why each part of it is not a test |
| `forges/gitea/bin/` | The gitea forge's own CLIs, baked into the image so the sandbox can act on them: `gitea-pr` (create a PR, read its comments, merge it), `gitea-issue` (list, view, create, comment on, close and reopen issues) and `gitea-actions` (read CI runs and job logs) |
| `forges/github/bin/` | The github forge's one CLI: `github-pr comments`, which shows unresolved review threads. `gh` covers everything else, but thread resolution lives only in GitHub's GraphQL API, so `gh pr view --comments` cannot tell a closed thread from an open one |
| `bin/dejima-notify-watch` | Host-side watcher: turns markers into ntfy pushes composed entirely from host data |
| `bin/dejima-project` | Review and approve project domains and dependencies from the host |
| `bin/dejima-project-lib.sh` | Shared library sourced by `bin/dejima-dep`, `bin/dejima-project`, `bin/dejima` and the remote-access scripts; not on PATH |
| `bin/dejima-conversation-ship` | Host-side: ships session transcripts to Loki when `[logging] conversation` is on. Reads them where they already are, so the session is not in the path of its own oversight log |
| `bin/dejima-epitaph` | Host-side: keeps the summary a session leaves when its context is compacted away, in a store nothing mounts. Run from `bin/dejima`'s exit trap |
| `bin/dejima-epitaph` | Host-side: keeps the summary a session leaves when its context is compacted away, in a store nothing mounts. `save` runs from `bin/dejima`'s exit trap; `list`/`search`/`show` are the operator's, and only ever read |
| `bin/dejima-harness-lib.sh` | Loads the selected harness's plugin and provides the `emit_*` functions it declares through; the guarded-store refusal runs in `emit_mount`. Not on PATH |
| `bin/harness/<name>.sh` | One per supported harness: what dejima does for that harness that no manifest value could state. Trusted computing base, sourced by `bin/dejima` |
| `bin/dejima-color-lib.sh` | The colour and terminal-detection helpers the CLIs share. Not on PATH |
@@ -47,6 +50,7 @@ run them.
| `docs/providers.md` | The manifest contract for the forge/workflow/harness axes, key by key, with the shipped manifests as worked examples |
| `docs/ci.md` | What push access grants a session on your CI host, and how to bound it - runner hardening, and an egress allowlist read from `main` |
| `docs/threat-model.md` | What is assumed hostile, and where each layer's compromise stops. Carries the blast-radius table `docs/security.md` also shows; a static test keeps the two identical |
| `docs/research-review.md` | When to check dejima's reasoning against the field's, the standing questions that are the agenda, and the queue of bypasses not yet run against this implementation |
| `docs/nginx-dejima.conf.example` | Reverse-proxy template for the web front door; deliberately not loadable as written |
`bin/dejima` is the entry point: it resolves the project path,
@@ -494,6 +498,7 @@ harness-specific path resolving to nothing is the honest answer.
| State layout | the project key - cwd with every non-alphanumeric replaced by `-` - is Claude Code's scheme, and dejima reproduces it in four places | `bin/dejima-slug-lib.sh`, `bin/dejima-project-lib.sh`, `docker/log-shipper.sh` |
| Per-project scoping | `projects/` is mounted one slug at a time and `file-history/` one session at a time, the second resolved by pairing `projects/<slug>/<id>.jsonl` with `file-history/<id>` (#104). In the plugin | `bin/harness/claude-code.sh` |
| Transcript discovery | where this workspace's transcripts are, what they are called and how they are nested. `harness_transcript_files` in the plugin, answering one `<key>\t<path>` line per file; core takes the key as the store it files under and refuses one that is not a slug this workspace owns. Both `dejima-epitaph` and `dejima-conversation-ship` used to carry Claude Code's layout directly | `bin/harness/<name>.sh`, `bin/dejima-epitaph`, `bin/dejima-conversation-ship` |
| Live sessions | which sessions still have a transcript, as `<key>\t<session-id>` lines. `harness_live_sessions` in the plugin, because the id is in the filename for one harness and is the directory name for the next; `dejima-epitaph list` matches an epitaph's own session id against the answer to say whether resuming the real session is still on offer. Optional: a plugin that does not implement it leaves the column unknown rather than claiming a transcript is gone | `bin/harness/<name>.sh`, `bin/dejima-epitaph` |
| Transcript update model | whether a transcript is appended to or replaced wholesale. The manifest's `transcript_update`; the shipper implements both and neither is guessed | `harnesses/<name>/harness.conf` |
| Transcript format | the transform from transcript bytes to log lines. In the plugin; the discovery, shipping, batching and Loki labels stay core's | `bin/harness/<name>.sh` |
| Compaction format | what a compaction looks like in a transcript, given one file at a time. In the plugin; the store, which keys are legitimate, its modes and the provenance note stay core's | `bin/harness/<name>.sh` |
+125 -2
View File
@@ -122,14 +122,18 @@ operator can set, resolved the same way as any other config key
| `capabilities` | `?list:identifier` | (empty) | What this forge can do - `pr`, `issues`, etc. A workflow's own `requires` is checked against this set at launch; a workflow asking for a capability this forge lacks is refused. |
| `pr_command` | `?command` | (empty) | The PR-management command name the forge's briefing prose and `env_export`/`merge_block` refer to. |
| `issue_command` | `?command` | (empty) | The issue-management command name, same idea. |
| `merge_block` | `?command` | (empty) | The literal command prefix `hooks/merge-guard.sh` refuses inside the sandbox (e.g. `gitea-pr merge`) - only when the workflow's `block_merge = on`. |
| `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_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. |
| `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. |
| `host_keys` | `?list:identifier` | (empty) | Config keys (from `keys`) that hold a literal hostname - unioned by `forge_hosts` into the firewall allowlist and the push guard's allowed hosts. |
| `url_keys` | `?list:identifier` | (empty) | Config keys that hold a URL - `forge_hosts` strips scheme and path and unions the bare host in the same way as `host_keys`. |
| `fixed_hosts` | `?list:hostname` | (empty) | Hostnames this forge always needs reachable, independent of any config value (`github` uses this - `github.com`, `ssh.github.com`, `api.github.com` are the same three hosts for every operator, so there's nothing to configure and nothing to get wrong). |
| `hosts_allowlisted_by_core` | `bool` | `off` | `on` means this forge's hosts are ones core already allowlists in its own right - GitHub's published ranges, which every session can read from over 443 whatever forge is selected. Core then keeps them out of `DEJIMA_FORGE_HOSTS` (the extra-domains path, which resolves a name into the port-unrestricted `allowed-domains` ipset and exempts it from DNS-rebind protection - right for an internal self-hosted host, a silent widening for a public one) and, when the workflow pushes, opens core's own GitHub push grant to them instead: port 22 to those same ranges, logged and verified by the firewall's startup self-check. They still reach `DEJIMA_PUSH_HOSTS`, so `git-push-guard.sh` permits the push. Name-free in core, but GitHub-specific in effect: this key means "core already allowlists these hosts in its own right", which today is only true of GitHub's ranges, so a future forge whose hosts are not among those must leave it `off` - setting it `on` for anything else grants no firewall allowance at all plus an irrelevant GitHub push grant. |
| `install` | `?list:relpath` | (empty) | Files under this forge's own directory that ship into `/usr/local/bin` of the image - the only manifest key naming something that actually gets copied and executed. The list is the gate, not a description of one: `docker/install-forge-bins.sh` installs exactly what is declared, and fails the build both when a declared file is missing and when a forge ships a file under its `bin/` that it does not declare. A forge with `install =` empty therefore puts nothing executable in the image. |
| `install` | `?list:installspec` | (empty) | The CLIs this forge puts on PATH in the image - the only manifest key naming something that actually gets copied and executed. Either a path under the forge's own directory, or `system:<name>` for a command the image already provides from a package. The list is the gate, not a description of one: `docker/install-forge-bins.sh` installs exactly what is declared, and fails the build when a declared file is missing, when a declared `system:` command is not in the image, and when a forge ships a file under its `bin/` that it does not declare. Every command a forge names anywhere - `pr_command`, `issue_command`, `merge_block`, `disclose` - has to appear here, because a CLI that is not behind the shadow is one the guards cannot reach: the entrypoint refuses to start rather than leave a block or a disclosure looking enforced. |
| `env_export` | `?list:export` | (empty) | `NAME=key` (the config key's resolved value) or `NAME=@key` (the *contents* of the file the config key names) pairs - the environment variables `bin/dejima` passes into the container for this forge. The `@` form is how a token reaches the container without its path being mistaken for the secret. |
Worked example, `forges/gitea/forge.conf`:
@@ -256,6 +260,26 @@ resuming the session on the host is `claude --resume <session id>`, with
`{{session_id}}` filled in at composition time; and its briefing fragment is
`briefing.md`.
## Tool
Not an axis: a launch selects one forge, one workflow and one harness, and
enables zero or more tools. Everything else about it is the same - a manifest
that is data, validated by the same schema machinery, never sourced.
The keys are deliberately few. A tool declares what it is, what installs it,
what its environment needs and what the session should be told about it. There
is no key for a hook, a mount, a domain or a guard, because a tool that needed
one of those would be a change to the boundary rather than a program on PATH.
| Key | Type | Default | Meaning |
| --- | --- | --- | --- |
| `description` | `text` | *(required)* | One line, shown when the layer builds and in the session briefing. |
| `install` | `depspec` | *(required)* | The same grammar a dependency uses. In practice a `tarball:` spec, because a tool has to be pinned by digest - see [docs/tools.md](tools.md) for why that is a rule rather than a preference. |
| `install_layout` | `enum:prefix\|binary` | `prefix` | The archive's shape. `prefix` is one enclosing directory, stripped so `bin/` lands in the same place whatever the release called its top level. `binary` is an archive that IS the executable: stripping a component there would strip the binary and extract nothing, silently. |
| `image_env` | `?list:envpair` | (empty) | `NAME=VALUE` pairs the layer sets. **Every name must be namespaced to the tool** - `RTK_*` for `rtk` - and a launch refuses one that is not. This becomes a Dockerfile `ENV`, which every process in the container inherits, so a bare `NO_COLOR` or `DO_NOT_TRACK` changes the harness and everything else too. A harness manifest may use bare names there because a harness is the container's main process; a tool is one program among many. |
| `command` | `identifier` | *(required)* | What it puts on PATH. |
| `instructions` | `?relpath` | (empty) | A markdown fragment appended to the session briefing under a core-owned heading, so a tool describes itself once and every harness gets the same words. This is the whole of the per-harness wiring: dejima renders it rather than letting the tool's installer patch a harness config. |
## Session briefing composition
The text a `SessionStart` hook injects into the agent is assembled
@@ -345,6 +369,12 @@ itself:
- `DEJIMA_BLOCKED_BRANCHES` - the workflow's `blocked_branches`.
- `DEJIMA_MERGE_BLOCK` - the forge's `merge_block`, but only when the
workflow's `block_merge = on`; otherwise empty.
- `DEJIMA_FORGE_REPO_FLAGS` and `DEJIMA_FORGE_REPO_ENV` - the forge's
`repo_flags` and `repo_env`, with `DEJIMA_FORGE_REPO` holding the
`<owner>/<name>` of the workspace's `origin` remote. Empty when the forge
declares neither, and also when the workspace has no origin to read - in
which case a command naming any repository is refused, since there is
nothing it could legitimately be naming.
- Whatever the forge's own `env_export` names (e.g. `GITEA_API`,
`GITEA_SSH_HOST`, `GITEA_TOKEN`).
@@ -412,6 +442,84 @@ every launch:
| harness | `claude-code` | hosts every guard; **not a default** - `[core] harness` must be set |
| harness | `cline` | provider-agnostic, so it can be pointed at an open-weight model served locally; hosts no guards, and needs `[harness.cline] model` set and a CPU with AVX2 |
### Harness integration maturity
Both harnesses are supported, and supported means the same thing for each: a
manifest, a plugin, tests, and the capability bar in
[development.md](development.md) either met or declared unmet. It does not mean
they have had the same amount of use, and the gap is wide enough to say so
rather than let the word "supported" imply otherwise.
**Claude Code is the harness dejima was built around.** Nearly every mechanism
here - the credential proxy, the briefing, the per-project state mounts, the
exit audit - was designed against it and generalised afterwards. It hosts all
four guards. Its rough edges were found by running it for real work, over and
over, which is a kind of coverage no test suite substitutes for.
**Cline is new, and has barely been used.** Its integration was built
deliberately and is tested, but a test only covers what someone thought to
check. The first run against a live model immediately turned up a bug no test
had: Cline silently discards a provider entry with no `updatedAt` and falls back
to its own hosted endpoint, which presents as a connection failure to a host
nobody configured. The first real session turned up a second, in a component
Cline does not own at all - the conversation shipper passed its payload as a
command-line argument, which Cline's larger tool results overflow. It hosts no
guards, so every launch names what is not in force.
Neither of those was a bad integration. That is the normal rate of discovery
for something this new, and it is the reason to expect more.
So: if you are choosing between them with no other reason to prefer one, choose
`claude-code`. Choose `cline` for what it offers that the other does not - it
can be pointed at any endpoint, including a model you serve yourself - and
expect to be the person who finds the next crack.
### Forge integration maturity
The same caveat the harnesses get, for the same reason: two forges listed in
one table invites the reading that each got the same consideration, and they
did not.
**gitea is the forge dejima is developed on.** Every PR in this repo was
opened through it, every issue filed through it, and its three CLIs are
dejima's own - which is why they do things `gh` does not, like showing only
the review threads still outstanding. Its rough edges were found by using it.
**GitHub has never been run end to end.** It was built from the manifest
outward and tested at the config layer, and what that missed is instructive:
`forge = github` could not start a container at all under `worktree-pr`, the
only workflow that opens PRs, because the merge shadow had no `gh` to shadow.
Ten tests exercised the forge and none of them reached the entrypoint. Fixing
that immediately turned up a second thing, that `gh --repo owner/name pr merge
N` walked straight past the merge block. Neither was a hard bug to find once
anyone looked.
Two differences from the gitea path remain, both now closed in code and worth
knowing about because they are what a second forge costs:
- **Review feedback needs its own tool.** `gh pr view --comments` prints
reviews and comments with no resolution state anywhere in the output,
because the REST API it uses does not carry any - `isResolved` exists only
on `reviewThreads` in GraphQL. So this forge ships `github-pr comments`,
which does that one query and otherwise matches `gitea-pr comments`:
unresolved threads by default, `--all` to include the resolved ones marked
as such, and a count of what was hidden. Everything else `gitea-pr` does has
a `gh` subcommand already.
- **`gh` can be aimed past the sandboxed repository, and is stopped.** The
gitea CLIs refuse an origin that is not on the configured host, so they can
only act on the repo the sandbox was launched against. `gh` takes `--repo`
on nearly every subcommand 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
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`.
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.
**The shipped defaults are deliberately the non-pushing pair**
(`forge = none`, `workflow = local-only`). A fresh install has no forge host
and no forge credential, so any default naming a real forge would be an
@@ -423,6 +531,21 @@ that is true of every fresh install: this sandbox pushes nowhere yet.
Pushing is opted into by naming both a pushing workflow and a forge that
resolves.
**A PR, issue or comment a session writes says an agent wrote it.** The body
is prepended with a marker - "Summary generated by LLM agent" on a PR,
"Filed by LLM agent" on an issue, "Comment by LLM agent" on a comment -
before it reaches the forge. This happens in `docker/forge-guard.sh`, which
shadows the forge's CLI on PATH, rather than in the CLIs themselves: dejima
wrote two of the three and a marker living in the tools would mean the
answer to "does dejima disclose this" depended on which forge an operator
picked. It applies on edits as well as creates, because the marker describes
where the text came from rather than the event that wrote it.
Two things it does not cover. A command that sends no body has none added -
`gh pr create --fill` takes its body from commit messages, and inventing one
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.
**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)
+117
View File
@@ -0,0 +1,117 @@
# Releasing: development to main
`main` is what an operator installs from, and what the CI hardener reads
`.dejima/allowed-domains` from. Work lands on `development` continuously; a
release is the deliberate act of moving it across, and this is what that act
consists of.
`test/release-pass.sh` runs the parts that can be run and prints the parts that
cannot, chosen from what the release actually changes. Start there.
## What is actually required, at this size
This project has one maintainer and few users, and a release gate nobody has
the bandwidth to complete is a gate that gets skipped rather than met. So the
bar is deliberately two things:
1. **An agentic security review of the whole diff**, against the invariants in
section 2. This is the gate. It is cheap, it is repeatable, and it is the
one thing that catches the class of mistake this project cannot afford.
2. **The maintainer runs the build.** Normal usage for a while, and a launch on
the other configured harness to confirm it starts. That is the smoke test,
and it is stated as what it is rather than dressed up as coverage.
Everything in section 4 is what a mature version of this project would verify
and this one does not. It is printed by `release-pass.sh` and kept in this
document because knowing what is unverified is worth more than pretending
otherwise - not because a release is blocked on it. When there are more users
than one person can apologise to, that changes.
## Why a pass at all
Every check below exists because something got through without it. The pattern
is always the same shape: the change was correct, the tests were green, and the
thing that broke was outside what any test could see - a container that could
not start, a body that arrived empty, a suite that passed in CI and failed on
the machine the work was done on.
A release is the last point where anyone looks at the whole diff rather than
one change at a time, which makes it the only place some of these are visible
at all.
## 1. The suites
`test/run-confined.sh` and `test/shellcheck.sh` run anywhere.
`test/runtime.bats` needs Docker, which a sandbox does not have. It is the only
thing that exercises the mounts, the firewall and the entrypoint end to end, so
a release is exactly when not running it stops being acceptable. Either run it
on the host, or **check that the privileged CI job ran** - it is pinned to one
self-hosted runner and queues silently when that runner is down, so a pull
request can sit with the runtime half neither green nor red.
"CI covers it" is not "it passed."
## 2. Security review
Not a generic checklist - this project's product is an argument that a boundary
holds, so the review is about that boundary. Over the whole `main...development`
diff:
- **Does a mount widen?** A path that was read-only, a scope that was one
project and is now the home directory.
- **Does a guard move after the thing it guards?** Order is the whole content
of a check.
- **Does a credential cross the boundary that did not before?** Including
indirectly: a variable that reaches the container, a file whose mask moved.
- **Does `.dejima/allowed-domains` gain a name?** That file is read from `main`
by dejima-ci-hardener, which treats what it finds there as reviewed. Adding a
domain widens every future CI job on that runner, from the moment it merges.
- **Did a refusal become a warning?** Or a hard failure become a fallback. Both
are how a boundary stops holding without anyone deciding it should.
- **Did a test stop asserting what its name claims?** A test that still passes
while the behaviour under it is gone is worse than no test.
The reviewer should be told to look for those specifically. A review briefed
only as "look for security problems" reliably returns generic advice about
input validation, because that is what a diff looks like without the context of
what this repository is defending.
## 3. Documentation review
- Every changed behaviour has its documentation changed in the same release.
- No document describes something that no longer exists.
- Nothing is claimed as working that has never been run. Where that is the
case, say so where a reader will meet it - the forge and harness maturity
sections in [providers.md](providers.md) are what this looks like.
## 4. Manual testing, which is mostly not done
`test/release-pass.sh` prints the list for the paths this release touches. It
is an inventory of what nothing has checked, not a checklist that must be
completed - see "What is actually required" above. The standing entries, and
why each is not a test:
| Touched | What to do, and why no test does it |
| --- | --- |
| `bin/dejima`, the entrypoint, compose | Launch a real session: mounts, proxy and exit audit only meet each other here |
| `forges/`, the forge guard | Open a pull request through each forge and read the body that arrived |
| any guard | Try the refused thing by the route an agent would take, not by the route the test takes |
| `bin/dejima-tty-suspend`, the launch | Ctrl-Z a real session and `fg` it: the tests use a stand-in for the container run |
| `web/` | Load it on a phone; nothing here can see a soft keyboard |
| `harnesses/` | Launch the harness against a real model |
## 5. The release pull request
Say what was run and what was not, in the body. At this size that will mostly
be "not", and writing it down is the whole point: an unverified path that is
named is one somebody can reach for when a bug turns up, and an unverified
path that is implied to be tested costs a day of looking in the wrong place.
Worth its own line: **whether the release can be rolled back.** A config key
added in a release makes the previous `main` refuse to start once an operator
adopts it, because an unknown key fails validation - so the rollback that
looks like the safety net is not one. Name the keys. A queued privileged job, a
manual check that was skipped, a forge that has still never been launched -
those are the things a reader of `main` is entitled to know, and the release
PR is where they are still true and still remembered.
+37 -2
View File
@@ -63,6 +63,8 @@ approvals):
| `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://`
@@ -494,8 +496,8 @@ contribution is delivering the drag.
### Toolbar
```
ESC CTRL TAB ← ↓ ↑ →
PREFIX ◀W W▶ ZOOM DRAG PASTE KBD
ESC CTRL TAB ← ↓ ↑ → ⏎
PREFIX ◀W W▶ SESS ZOOM DRAG PASTE KBD
```
- `CTRL` is sticky: one tap arms it for the next key, a second tap locks it on,
@@ -507,15 +509,48 @@ PREFIX ◀W W▶ ZOOM DRAG PASTE KBD
- `◀W` and `W▶` are prefix + `p` / prefix + `n`. They are the answer to the
window names in the status bar being unhittable on a phone: two large targets
that are always in the same place.
- `SESS` goes back to the session picker, for switching to another sandbox
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.
- `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
markers when the application has asked for them. It needs a secure context;
on an `http://` front door the browser refuses the read and the page says so
rather than doing nothing.
- `⏎` sends Enter, which is the key a session asks for most and the one a soft
keyboard puts furthest from a thumb.
- `KBD` raises the soft keyboard, which is otherwise awkward to get back once
it has been dismissed.
**The buttons are colour-coded by what they are for**, in the same gruvbox
palette `dejima-attach` uses for the session picker - the two sit either side
of one attach and should not look like different programs.
The colours are a hierarchy, not decoration. `KBD` is filled orange because on
a phone it is how you type at all, and it is the one key that has to be
findable without looking for it. `⏎` is filled green, being the key a session
asks for most. The window and arrow keys are outlined in blue: reached for
constantly, but never the thing you are hunting for. Everything else is
furniture and stays grey. A latched `CTRL` or `DRAG` still overrides whichever
tier its button is in, because the state a key is in matters more than the job
it does.
Set `ttyd_button_colors = off` for the grey every key had before - for a
terminal colour scheme this clashes with, or if you would rather it were
quiet.
**Tapping the terminal does not raise the keyboard.** On a phone, tapping is
also how you scroll and select, so every one of those taps used to summon the
keyboard and cost a second tap to put it away. `KBD` is the deliberate way to
ask for it. A desktop is unchanged - there is no soft keyboard to raise, and
clicking to focus is what a terminal does.
Set `ttyd_focus_on_tap = on` to get the old behaviour for every client, or add
`?focus=1` to the URL for one. `?focus=0` forces it off on a desktop.
Copying *out* works through OSC 52: anything that emits it - tmux with
`set-clipboard on`, nvim, your own yank binding - lands in the phone's
clipboard. Nothing needs to be installed for that, and nothing here assumes
+173
View File
@@ -0,0 +1,173 @@
# Reviewing the state of the art
[← dejima README](../README.md)
dejima's first month, from late July 2026, was spent reasoning from its own
threat model without checking it against anyone else's. The first sweep, in
August, turned up two things worth having found sooner than that:
- **A parser-differential bypass class against egress allowlists**, disclosed
against another vendor's agent network sandbox: a null byte in a SOCKS5
hostname, so the policy check read `attacker-host\x00.google.com` and matched
a `.google.com` suffix while the resolver read `attacker-host`. dejima's
matcher refused that shape and every neighbouring one, for structural reasons
now recorded beside it - it matches whole domains rather than suffixes, and
the decision and the dial are bound by the ipset being filled from the
answer the resolver returned. One laxity turned up and was fixed.
- **The closest comparable project ships the opposite default.** Trail of Bits'
Claude Code devcontainer lists network under "not sandboxed" and has full
outbound access unless an operator opts in. Working out why was worth more
than the comparison: their scope is the host filesystem for an auditor
watching a session, and network was never the thing they were containing.
Neither changed a control, which is the point. A month is not long to have gone
unchecked, and finding out was cheap - which is the argument for doing it on a
schedule rather than when something goes wrong.
This is the process. It is deliberately small: the failure mode of a research
review is producing a reading list nobody acts on.
## When to run one
**Before locking a control.** This repository's rule is to measure the premise
before designing on it. Extended one step: ask what measurement would falsify
the control, *and* whether someone has already run it - or already bypassed it.
- Before adding or changing a boundary: a mount, an egress path, a guard, a
credential that crosses.
- When a control is being justified by reasoning rather than by a test. That is
the moment to find out whether the reasoning is known to be wrong.
- When a bypass is published for anything in the same class. Another project's
egress allowlist bypass is a test case for this one.
- Quarterly as a standing sweep, whether or not anything is pending. A threat
model written eighteen months ago describes a different attacker.
That last one is the reason the schedule exists at all. Reported model
capability on offensive security tasks has moved fast enough that a sandbox
designed against a 2024 agent is not obviously adequate against a 2026 one, and
nothing else here re-examines that assumption on any schedule.
## The standing questions
The agenda. Each entry is a live claim dejima rests on, phrased so the field can
contradict it. Keep it current: a question nobody would answer "no" to is not
doing any work.
1. **Is a hardened container an adequate boundary for an agent assumed
hostile?** The field says no for untrusted code, and tiers gVisor and
per-workload VM kernels above it. dejima's answer is that escape moves a
layer - true where the layer exists. See
[Which deployment this describes](threat-model.md#which-deployment-this-describes).
2. **Does a name-based egress allowlist hold?** It holds against an agent that
must resolve a name. It does not hold against exfiltration through an
allowed domain or against DNS query-name channels, both of which are listed
in [What dejima does not sandbox](security.md#what-dejima-does-not-sandbox).
The open part is parser differentials between the matcher and the resolver.
3. **Where must egress enforcement sit?** Below the harness: a subprocess with
its own HTTP client bypasses anything the harness mediates. dejima enforces
at the container's network layer, which satisfies this. Settled unless the
architecture changes.
4. **Is "the operator merges" the right shape for human-in-the-loop?** The
field's phrasing is "human approval on irreversible actions", which is what
the merge and push guards implement. Corroborated; stop re-deriving it.
5. **What is the current bypass literature for agent sandboxes?** The most
valuable question and the one that ages fastest.
6. **Does anyone else treat the previous session's output as hostile input to
the next one?** The positioning question, and as of the first sweep the
answer is no.
7. **What does the field consider baseline that dejima does not do?** The
cheapest question to ask. It found the absence of timeouts, which is now a
[stated non-goal](security.md#why-there-are-no-timeouts) rather than a gap.
8. **Is a control argued in one layer honoured in the others?** The three
projects are three repositories with three vocabularies, and nothing checks
that a claim made in one reaches the next. The first sweep produced the
worked example: dejima blocks IPv6 outright and says why, `dejima-ci-hardener`
makes the same argument for its job network - and both sibling projects'
node-side firewalls were IPv4-only, written later, by people who had already
made that argument twice. Each project's tests pass, because each tests its
own layer against its own template. This question is the only thing looking
at the seams.
## How to run one
**1. Sweep, do not read.** Search broadly and read only summaries. This pass
produces a shortlist, not understanding. Search the standing questions, not
dejima's vocabulary - our terms are ours, and "guarded store" finds nothing.
**2. Read in full only what could change a control.** A source that agrees
changes nothing and can stay a citation. Read the ones describing a bypass of a
control that exists here, quantifying something asserted here, or occupying
ground assumed empty.
**3. Get the mechanism, not the headline.** "Egress allowlists are bypassable"
is not actionable. "The policy check saw one hostname and the resolver saw
another" is a test to write.
**4. Sort every finding into a bucket.**
| bucket | what it means | what to do |
| --- | --- | --- |
| **Prior art** | Someone built this | Read it properly, and start from where they ended. |
| **Corroboration** | They hold the same premise | Cite it, stop re-deriving it, and stop treating it as a differentiator. |
| **Their control** | They defend something dejima does not | Take the *distinction* if it is real. Do not take the *control* without asking what threat model produced it. |
| **Contradiction** | Their default is the opposite | The most valuable bucket. Work out whether the threat models differ before conceding or dismissing. |
| **Open ground** | Nobody does this | Say so, and say *why*. Usually a constraint others do not have; occasionally it means everyone else knows something. |
**5. Write down the contradiction even when it resolves in dejima's favour.**
The default-network difference above is the worked example: it reads as dejima
being ahead, and the reason turned out to be scope rather than rigour.
Recording the reason is what stops a different scope being mistaken for a
weaker one - by us, in six months, reading our own note.
**6. Turn every finding that changes a control into an issue.** A review that
ends in a document has not finished.
## What to record, and where it does not go
**A sweep is not committed here.** It is written to the shared mount on the
operator's machine, alongside the specs, plans and design notes that live
there for the same reason: a review is ephemeral, and what belongs in this
repository is the *action* it produced, not the reasoning that produced it.
What lands here is the outcome - an issue, a pull request, a paragraph in the
documentation section the finding actually bears on. This page is itself an
example: the August 2026 sweep produced a matcher fix, a
[not-sandboxed list](security.md#what-dejima-does-not-sandbox), a
[statement of which deployment the threat model describes](threat-model.md#which-deployment-this-describes),
and a stated non-goal on timeouts. The sweep that produced them stayed on the
operator's machine.
The sweep itself is one page, dated and sourced, in the house style, written
where the other ephemeral documents go. Two rules specific to reviews, because
both distinctions vanish within a week of writing:
- **Mark what was read in full and what came from a search summary.** A summary
of a bypass is enough to write a test case from and not enough to claim
dejima is safe from it.
- **Quote claims with their conditions.** "Containers are inadequate" is false
without "for untrusted code, where the host kernel is the boundary". The
honesty rules here apply to what is read as much as to what is written.
## The standing test queue
A bypass from the literature is a hypothesis about *their* implementation until
it is run against this one. dejima differs in ways that plausibly change the
answer: enforcement is `iptables` and a DNS proxy rather than an in-process
proxy, the matcher is an anchored shell regex rather than a string-suffix test,
the allowlist matches whole domains rather than suffixes, and the ipset is
populated from the answer the resolver returned rather than from a second parse
of a string.
Each entry states a prediction that can fail.
- **Hostile hostnames against `valid_domain`.** Null bytes, percent-encoding,
trailing dots, embedded newlines, IDNA, empty labels. Prediction: all refused.
*Run 2026-08-25: all refused except an empty label, which was accepted and
has since been fixed.*
- **A published SOCKS-style parser differential, run end to end** rather than
against the validator alone. Prediction: no path, because nothing in dejima
speaks SOCKS and the decision and the dial are bound by the ipset. Not yet
run.
- **Redirect chains to a host outside the allowlist.** Prediction: fail at the
ipset, because dejima does not mediate HTTP and the connection is judged by
address. Not yet run.
+89
View File
@@ -16,6 +16,61 @@ person thought to check, and no outside audit or adversarial testing has
happened. Read what follows as claims awaiting scrutiny rather than claims
that have survived it. See [SECURITY.md](../SECURITY.md).
## What dejima does not sandbox
Every item here is documented somewhere below, and a document organised by
control makes a reader assemble the negative space themselves. Most will not.
So it is collected here, at the top, where it is the first thing read rather
than the thing inferred from silence.
- **Exfiltration through a domain that is allowed.** The egress allowlist
decides *where* a session may connect, not what it may put in the request. A
session that can reach a host can encode data into a path or a query it
sends there. Allowing a domain at all creates that channel.
- **Data encoded into DNS query names.** The DNS proxy answers for allowlisted
domains; the names asked for are a channel of their own.
- **Anything below the container, where nothing is arranged there.** dejima's
blast-radius table has rows beneath the container, and their controls belong
to [dejima-devbox-hardener](https://gitbox.branderson.io/dejima/dejima-devbox-hardener).
Run on a laptop with no VM layer, the row below the container is the
operator's own machine, and dejima has nothing to say about it. See
[Which deployment this describes](threat-model.md#which-deployment-this-describes).
- **Anything finer than a session.** The unit of isolation is one container for
one session. An MCP server, a hook, or a tool the session runs inherits the
session's whole reach - dejima reports that a session *added* an MCP server
(see the audit surfaces below) and does not constrain what that server then
does. Per-tool granularity would need a second sandbox inside this one.
- **Resource exhaustion.** No time limit, no memory limit, no CPU limit. A
session that loops until someone notices is not prevented, and this is a
deliberate omission rather than an oversight - see
[Why there are no timeouts](#why-there-are-no-timeouts).
- **The first layer's controls against an agent that has stopped
cooperating.** The briefing and `.dejima.md` are advice. They shape what a
cooperating agent does and stop nothing that has decided otherwise. Only the
layers below them are enforcement.
## Why there are no timeouts
Every published checklist for sandboxing an agent lists timeouts alongside
non-root, egress filtering and read-only mounts. dejima has none, and the
reason is a judgement about what dejima is for rather than an item not yet
reached.
**A runaway session costs money and does not cross the boundary.** It burns
tokens and holds a CPU until someone looks. Nothing about it reaches the host,
widens the allowlist, or writes where a session may not write - the guarantees
dejima makes are all still held while it happens. A timeout would protect a
budget, which dejima has never claimed to protect.
**And "unattended" here does not mean "unobserved".** The sessions this is
built for run for days, and are checked at every issue and every pull request.
They need less babysitting, not none. A wall clock tuned for that is either so
long it prevents nothing or short enough to kill work in progress, and killing
a session mid-commit is a failure mode of its own.
If this changes it will be because a resource limit starts protecting the
boundary rather than the bill.
## Blast radius by layer
Where each layer's compromise stops, with and without the thing arranged
@@ -324,6 +379,15 @@ can make; it is one the audit reports. See
live on disk); a removed one stores nothing at all, because a deletion
diff *is* the whole former file.
What it prints is chosen by what changed, which is not always what you
need in order to judge it - three changed lines in a hook script are only
judgeable by reading the script. `[o]` at the prompt opens the file in
`$VISUAL`/`$EDITOR`, read-only where the editor understands how, and
returns to the same decision. It opens the file **as it is now**, 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.
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
@@ -701,6 +765,23 @@ session, where the credential deliberately never goes.
exfiltration path. Verified: capabilities (`NET_ADMIN`/`NET_RAW`) are
cleared from the effective/permitted sets on the `gosu` drop, and that
user gets a permission error running `iptables` directly.
- A forge credential reaches the container as `-e NAME`, never
`-e NAME=value`. The value comes from the launcher's own environment, so it
sits in `/proc/<pid>/environ` (mode 0400, owner only) rather than in
`/proc/<pid>/cmdline`, which is world-readable and which `ps` prints to
anyone. `docker inspect` still shows the resolved value to whoever can
reach the daemon - that is daemon access rather than any process at all,
and it is not something the launcher can change.
- The container runs with `no-new-privileges` and an explicit capability
floor - `cap_drop: ALL` plus the 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
privileges on exec, which is the shape any escalation from the session user
would have to take; the second means the capabilities nothing here uses were
never in the bounding set to be regained. Both are asserted against a running
container rather than against the compose file, and the capability list the
test uses is read out of that file so the two cannot drift.
- The approval store (`${DEJIMA_CONFIG_DIR:-$HOME/.config/dejima}`,
see
[Global and per-project domains and dependencies](../README.md#global-and-per-project-domains-and-dependencies)
@@ -846,6 +927,14 @@ strips AAAA answers): there's no IPv6 equivalent of the allowlist above, so
leaving it open would bypass the whole firewall on any host with IPv6
egress.
Where `ip6tables` cannot be used at all, the firewall does not conclude that
IPv6 is therefore absent - it asks the kernel, and treats an address on any
interface other than loopback as the family being live. It then turns IPv6
off in the container's own network namespace, and **refuses to start** if it
cannot. A tool that will not run and a family that is not there are different
conditions, and reporting the second on evidence of the first is how a
boundary comes to be described rather than held.
If your Gitea host only resolves via a LAN-local DNS server, make sure the
Docker host itself can resolve it: containers inherit the host's resolver.
+67 -1
View File
@@ -62,12 +62,26 @@ to instructions, because it arrives as instructions.
4. **Cross-project context poisoning.** A session writes another project's
agent memory or transcripts, and a later session there reads planted text
as established background.
5. **Container escape.** Real, and it recurs, but it needs an unpatched host
5. **The operator running something the session asked them to run.** dejima
tells agents to hand host-side work over rather than attempt it - a domain
to allow, a dependency to approve, a command only the host can run. That is
a designed, legitimate, frequent workflow, which is exactly what makes it a
delivery mechanism: a script that does what it says and one thing more is
indistinguishable at the moment of running it. Nothing here inspects what
crosses that boundary, and nothing can - the whole point of the handoff is
that a person decides.
6. **Container escape.** Real, and it recurs, but it needs an unpatched host
or a working zero-day, and the two rows it moves between are both already
in the table below.
The ordering is the point. Nothing above the last item requires an exploit.
Item 5 is not an argument for making the handoff harder; a session that cannot
ask is a session that works around you instead. It is an argument for being
able to answer, afterwards, what that actually did to the machine - which is
the layer below this repository's, and the reason the sister project is
looking at detection rather than at more enforcement.
## Blast radius by layer
Read the last two columns together: the middle one is what the layer reaches
@@ -127,6 +141,58 @@ has the two tiers and where each one's boundary sits.
Neither is required to run dejima, and the container row holds without them.
What they change is what the row below it costs.
**A survey of comparable projects found none with rows below the container at
all** - their boundary is between the agent and the host, and what is under
the host is outside their threat models. The same survey found nothing
treating a forge or a CI runner as part of an agent's blast radius, which is
what the second sister project exists for: a session that can open a pull
request can execute code on a machine dejima never touches.
Recorded as an observation from one day of secondary sources rather than as a
claim to have surveyed the field exhaustively. The reason it is here at all is
that it changes what to defend: if the layers below and beside the container
are ones nobody else is examining, they are also ones where nobody else's
mistakes are available to learn from.
## Which deployment this describes
The table above describes dejima **as deployed on a devbox VM with the two
sister projects arranged around it**. Say that plainly, because the README
also invites running dejima on a laptop, and there the table is shorter than
it looks: the container row holds exactly as described, and the row below it
is the operator's own machine with nothing arranged against it.
That matters more than it used to. The field's own ordering for isolating an
agent whose contents are assumed hostile puts a hardened container below
gVisor and below a per-workload VM kernel, on the grounds that a container
shares the host kernel and that container *runtime* vulnerabilities bypass
syscall filtering rather than being caught by it. dejima's answer to that
ordering is not to dispute it. It is that escape moves a layer instead of
ending the argument - which is a better answer where the next layer exists,
and no answer at all where it does not.
So `dejima-devbox-hardener` is load-bearing rather than optional-and-nice.
On a laptop, dejima is a hardened container and the honest description of what
is under it is "your home directory, and dejima has nothing to say about it".
**Load-bearing for what, exactly**, because the field's tiering invites a
reading that project does not accept and has never claimed. A per-workload VM
kernel is reached for elsewhere as a *containment* boundary. That is not what
the row below the container buys here. `dejima-devbox-hardener`'s own threat
model states in three places that its firewall bounds where a compromise can
**spread**, not whether it can phone home: an escape reopens internet egress,
deliberately, because deny-by-default earns its keep where the protected set
is enumerable and the internet is not.
Read the row as written, then: it cuts LAN reach to what is declared, and it
is enforced from outside the guest so root in the VM cannot flush it. It does
not restore the egress control the container layer provides. Anyone writing
this sentence more confidently than that is making a promise on another
repository's behalf.
**What this repository can do about that is say so**, which is the whole of
this section. Nothing in dejima can bound its own host.
## What this repository does not claim
- **That the egress allowlist bounds what reaches the model.** It bounds what
+130
View File
@@ -0,0 +1,130 @@
# Optional tools
A tool is a program dejima can put in a session's image that is not the
harness and not a project dependency: something an agent uses to work, that
dejima has looked at first.
None is enabled by default.
```ini
# ~/.config/dejima/config - on this machine, always
[core]
tools = rtk
```
```
# .dejima/tools in the project - shared, and approved before it takes effect
rtk
```
```sh
dejima --include rtk # or -i rtk, repeatable, for one launch
```
The three are a union rather than a precedence chain: they answer different
questions - what this machine always wants, what this project needs, what this
run is for - and none is a correction of another. The project's half is a
declaration like every other file in `.dejima/`: it grants nothing until
`dejima-project approve` records it in a store outside the container's reach.
## What "optional tool" does not mean
**It does not mean a bare install.** This is the part worth stating first,
because it is the whole difference between this and adding a line to
`.dejima/dependencies`.
A dependency is something a project says it needs and an operator agrees to.
Nobody claims to have read it. A tool is something **dejima ships**, and the
bar for adding one is that dejima has hardened it. That bar applies to every
tool added this way, not only the first:
- **Pinned and checksummed.** A published artifact at a fixed version with a
`sha256` in the manifest, verified before it is unpacked. Never the
project's own `curl | sh` installer, which fetches whatever its default
branch points at today - an image build that produces a different binary on
different days is one nothing has reviewed.
- **Its network behaviour established, not assumed.** Read the source for
outbound calls, then check the artifact you are actually shipping, because
the two can differ. Anything it wants to reach is off unless the firewall
allowlists it, and a tool that needs a domain allowlisted to function is one
to argue about rather than to add.
- **Telemetry off at the tool's own switch as well as at the firewall.** Two
independent offs, because either one can be wrong.
- **Wired up by dejima, not by the tool's installer.** Most of these ship an
`init` that patches a harness's config file, and what it patches is often
the same hook surface dejima's guards live on. dejima renders the wiring
itself so the ordering, and what the file ends up containing, stay dejima's.
- **The guards still hold with it enabled**, and a test says so.
- **It sets only its own environment.** A tool's `image_env` becomes a
Dockerfile `ENV` that every process in the container inherits, so a bare
name reaches the harness too. Names must be namespaced to the tool and a
launch refuses one that is not. This is the bar's newest line, and it is
here because rtk broke it: it shipped with `NO_COLOR=1`, and a session with
the tool enabled lost colour in the harness, git and everything else.
If a tool cannot clear that, it is not an optional tool. It might still be a
dependency a project declares and an operator approves, where the claim being
made is much smaller.
## What a tool may declare
Deliberately little. A tool's manifest names what it is, what installs it,
what its environment needs, and what the session should be told about it.
There is no key for a hook, a mount, a domain or a guard - a tool that needed
one of those would be a change to the boundary rather than a program on PATH,
and would have to be argued for rather than declared.
See `docs/providers.md` for the key-by-key table.
## rtk
[rtk](https://github.com/rtk-ai/rtk) (Apache-2.0) compresses command output
before it reaches the model: `rtk git status` runs git and prints a shorter
form of the result. It makes no model calls of its own - it is a deterministic
filter, so nothing about it sends your code anywhere.
What was checked, since the list above is only worth as much as its worst
entry:
- **Pinned** to the published `x86_64-unknown-linux-musl` release by digest,
taken from the artifact and confirmed against a second download.
- **Telemetry exists and the project's README does not mention it.** An HTTPS
ping is compiled into the release binary - visible in the shipped artifact's
strings, not only in the source - gated on a consent flag that defaults to
unset. It is off three ways here: consent unset, `RTK_TELEMETRY_DISABLED=1`
in the image environment, and the endpoint is not allowlisted, so the
request cannot leave the container even if the first two were wrong.
- **It resolves wrapped commands through PATH.** Measured: `rtk git status`
with a stub `git` earlier on PATH runs the stub. So dejima's PATH shadows -
the git guard and the forge guard - still apply to a command run through it,
which is the property that matters most here.
- **Its Claude Code integration installs a `PreToolUse` hook that rewrites
commands**, in the same slot dejima's guards occupy. dejima does not install
that hook. See below.
### Why the hook is not installed
`rtk init` registers a hook that rewrites a Bash command before it runs -
`git push origin main` becomes `rtk git push origin main`. That is the same
`PreToolUse`/`Bash` slot as `deny-dangerous-bash.sh`, `git-push-guard.sh` and
`merge-guard.sh`, and the ordering between a third-party rewrite and dejima's
guards is not something dejima would control once the tool's installer had
written it.
So dejima tells the session the tool exists instead, which is exactly what
rtk's own Cline integration does. That is weaker: the agent chooses when to
use it rather than getting it automatically. It is also the conservative first
position for the first tool of this kind, and the thing that would change it
is a measurement - establishing how Claude Code merges hooks across settings
layers, and whether a guard sees the original command or the rewritten one -
rather than a preference.
Note what is not being claimed: a session can run `rtk git push` itself
whether or not dejima installs the hook. The guards hold because they hold at
the PATH and hook layers, not because the tool is absent.
### Limitation
x86_64 only. The tarball spec names one architecture (issue #97), so an arm64
host cannot use this entry.
+7 -14
View File
@@ -9,6 +9,12 @@ set -euo pipefail
GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.config/gitea/token}"
# The marker saying an agent wrote the body is deliberately not here. It is
# applied by docker/forge-guard.sh, which shadows this command inside the
# sandbox, so that it reaches every forge and not only the ones whose CLI
# dejima happens to have written. Outside a sandbox this tool is being run by
# a person, and a person's body is not agent-generated.
function usage() {
echo "Usage: gitea-issue list [--state open|closed|all] [--limit N]"
echo " gitea-issue view NUMBER"
@@ -214,10 +220,6 @@ case "$subcommand" in
echo "gitea-issue create requires --title" >&2
exit 1
fi
body="**Filed by LLM agent**
${body}"
api_or_die "create issue" -X POST "$issues_api" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg title "$parsed_title" --arg body "$body" \
@@ -241,12 +243,7 @@ ${body}"
payload=$(echo "$payload" | jq --arg t "$parsed_title" '. + {title: $t}')
fi
if [ -n "$body" ]; then
# The same marker create writes. It describes where the body came
# from rather than the event that wrote it, so an edited body
# keeps the disclosure instead of quietly shedding it.
payload=$(echo "$payload" | jq --arg b "**Filed by LLM agent**
$body" '. + {body: $b}')
payload=$(echo "$payload" | jq --arg b "$body" '. + {body: $b}')
fi
api_or_die "edit issue #${number}" -X PATCH "${issues_api}/${number}" \
@@ -262,10 +259,6 @@ $body" '. + {body: $b}')
echo "gitea-issue comment requires --body or --body-file" >&2
exit 1
fi
body="**Comment by LLM agent**
${body}"
api_or_die "comment on issue #${number}" -X POST "${issues_api}/${number}/comments" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg body "$body" '{body: $body}')"
+32 -10
View File
@@ -3,6 +3,12 @@ set -euo pipefail
GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.config/gitea/token}"
# The marker saying an agent wrote the body is deliberately not here. It is
# applied by docker/forge-guard.sh, which shadows this command inside the
# sandbox, so that it reaches every forge and not only the ones whose CLI
# dejima happens to have written. Outside a sandbox this tool is being run by
# a person, and a person's body is not agent-generated.
function usage() {
echo "Usage: gitea-pr create [--title TITLE] [--body BODY | --body-file FILE] [--base BRANCH]"
echo " gitea-pr edit PR_NUMBER [--title TITLE] [--body BODY | --body-file FILE]"
@@ -220,9 +226,6 @@ case "$subcommand" in
parse_body_args "$@"
title="$parsed_title"
base="$parsed_base"
body="**Summary generated by LLM agent**
${body}"
current_branch=$(git rev-parse --abbrev-ref HEAD)
if [ "$current_branch" = "HEAD" ]; then
@@ -230,10 +233,34 @@ ${body}"
exit 1
fi
# Where the base comes from when --base was not given, most specific
# first. A project whose work does not target its default branch - one
# that merges through a development branch, say - can say so once with
#
# git config gitea.prBase development
#
# rather than every caller remembering a flag. Without that, the
# repository's default branch is the only thing there is to go on.
base_source="--base"
if [ -z "$base" ]; then
base="$(git config --get gitea.prBase 2>/dev/null)" || base=""
base_source="git config gitea.prBase"
fi
if [ -z "$base" ]; then
base=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's#refs/remotes/origin/##' || true)
base="${base:-main}"
base_source="the repository's default branch"
fi
if [ -z "$base" ]; then
base=main
base_source="a fallback, because nothing else said"
fi
# Said out loud, every time. A base that is wrong is not visible in
# anything this prints afterwards - the URL looks the same - so it was
# possible to open a run of pull requests against the wrong branch,
# report them as targeting the right one in good faith, and have
# someone else quietly retarget each one. Naming it here is what makes
# the mistake cost one line instead of a fortnight.
echo "gitea-pr: opening against '$base' ($base_source)." >&2
api_all_pages "check for an existing PR" "${GITEA_API}/repos/${owner}/${repo}/pulls?state=open"
existing_url=$(echo "$json" | jq -r --arg head "$current_branch" --arg base "$base" \
@@ -273,12 +300,7 @@ ${body}"
payload=$(echo "$payload" | jq --arg t "$parsed_title" '. + {title: $t}')
fi
if [ -n "$body" ]; then
# The same marker create writes. It describes where the body came
# from rather than the event that wrote it, so an edited body
# keeps the disclosure instead of quietly shedding it.
payload=$(echo "$payload" | jq --arg b "**Summary generated by LLM agent**
$body" '. + {body: $b}')
payload=$(echo "$payload" | jq --arg b "$body" '. + {body: $b}')
fi
api_or_die "edit PR #${pr_number}" -X PATCH \
+10
View File
@@ -2,6 +2,16 @@ capabilities = pr issues
pr_command = gitea-pr
issue_command = gitea-issue
merge_block = gitea-pr merge
# Every command that sends a body a person did not write. The wording of the
# marker is core's (docker/forge-guard.sh); this only says which commands carry
# one and what each of them is writing.
disclose = pr:gitea-pr create, pr:gitea-pr edit, issue:gitea-issue create, issue:gitea-issue edit, comment:gitea-issue comment
# Which of the keys above belong to a PERSON rather than to the project. A
# project's .dejima/ is committed and shared; a token path is per-user and
# per-machine, so a project may not declare one and an [identity.<name>]
# section may set one. One declaration drives both rules, so they cannot
# disagree about which key is which.
identity_keys = token_file
keys = ssh_host:?hostname api:?url token_file:path=~/.config/gitea/token
requires = ssh_host api token_file
host_keys = ssh_host
+215
View File
@@ -0,0 +1,215 @@
#!/bin/bash
# The one thing `gh` cannot do (#186).
#
# `gh pr view --comments` prints reviews and comments with no resolution state
# anywhere in the output, because the REST API it uses does not carry any.
# `isResolved` exists only on `reviewThreads` in the GraphQL API. So an agent
# or an operator working through PR feedback re-reads and re-answers threads
# that were closed a week ago, with nothing saying so - quiet in the direction
# that wastes the most work.
#
# Everything else `gitea-pr` does has a `gh` subcommand already, which is why
# this ships one command rather than a second CLI. It reads and never writes,
# so there is no authorship disclosure to apply: docker/forge-guard.sh shadows
# `gh`, not this.
set -euo pipefail
function usage() {
echo "Usage: github-pr comments PR_NUMBER [--all]"
echo ""
echo "Shows a pull request's comments, reviews and inline review threads."
echo "Unresolved threads only by default; --all includes the ones already"
echo "resolved, marked as such."
echo ""
echo "The repository is the 'origin' remote of the checkout you are in, and"
echo "must be on github.com. Authentication is gh's own - GH_TOKEN, or"
echo "whatever 'gh auth status' reports."
}
# Same shape as gitea-pr's: resolve from origin and refuse another host, so
# this tool cannot be pointed at a repository the checkout is not for. Covers
# ssh, scp-style and https remotes by taking the last two path components
# rather than parsing any of them as a URL.
function github_owner_repo() {
local remote_url stripped name rest owner
remote_url=$(git remote get-url origin 2>/dev/null) || {
echo "No 'origin' remote found in this repository" >&2
exit 1
}
case "$remote_url" in
*github.com[:/]*) ;;
*) echo "origin remote ($remote_url) is not on github.com; github-pr only supports this host" >&2
exit 1 ;;
esac
stripped="${remote_url%/}"
stripped="${stripped%.git}"
name="${stripped##*/}"
rest="${stripped%/*}"
owner="${rest##*[:/]}"
if [ -z "$owner" ] || [ -z "$name" ] || [ "$owner" = "$stripped" ]; then
echo "Could not read an owner/name out of the origin remote ($remote_url)" >&2
exit 1
fi
printf '%s/%s' "$owner" "$name"
}
# The number reaches gh as a typed GraphQL variable rather than being spliced
# into a path, so this is not the URL-traversal check gitea-pr's is. It is
# here so a mistyped argument fails with something a person can act on instead
# of a GraphQL type error.
function require_number() {
if ! [[ "${1:-}" =~ ^[0-9]+$ ]]; then
echo "Expected a PR number, got '${1:-}'" >&2
exit 1
fi
}
if [ "$#" -lt 1 ]; then
usage
exit 1
fi
subcommand="$1"
shift
case "$subcommand" in
-h|--help|help) usage; exit 0 ;;
comments) ;;
*) echo "Unknown subcommand: $subcommand" >&2; usage >&2; exit 1 ;;
esac
require_number "${1:-}"
pr_number="$1"
shift || true
show_all=0
while [ "$#" -gt 0 ]; do
case "$1" in
--all) show_all=1 ;;
*) echo "Unknown argument: $1" >&2
echo "Usage: github-pr comments PR_NUMBER [--all]" >&2
exit 1 ;;
esac
shift
done
owner_repo="$(github_owner_repo)"
owner="${owner_repo%%/*}"
repo="${owner_repo##*/}"
# gh's own message, then one line saying what was being asked for. Captured
# rather than piped straight into jq: on a 404 gh prints "Not Found" and exits,
# and jq then fails on the message text, so the last thing on the terminal is
# "Cannot index string with string" rather than the fact that the PR does not
# exist.
api_or_die() {
local what="$1"; shift
local out
if ! out="$("$@" 2>&1)"; then
printf '%s\n' "$out" >&2
echo "github-pr: could not $what for ${owner_repo}#${pr_number}" >&2
exit 1
fi
printf '%s' "$out"
}
echo "=== Issue comments ==="
api_or_die "fetch issue comments" \
gh api --paginate "repos/${owner}/${repo}/issues/${pr_number}/comments" \
| jq -r '.[] | "[\(.user.login) @ \(.created_at)]\n\(.body)\n"'
echo "=== Reviews ==="
api_or_die "fetch reviews" \
gh api --paginate "repos/${owner}/${repo}/pulls/${pr_number}/reviews" \
| jq -r '.[] | select(.body != "") | "[\(.user.login) @ \(.submitted_at) — \(.state)]\n\(.body)\n"'
echo "=== Inline review comments ==="
# reviewThreads, not the REST review comments, for the whole reason this tool
# exists: `isResolved` is only here. `isOutdated` comes with it and is worth
# printing - a thread against a hunk that no longer exists is usually already
# handled, and reads as outstanding otherwise.
#
# Paginated by hand rather than with `gh api --paginate`: that flag emits one
# JSON document per page for GraphQL, which would have to be reassembled here
# anyway, and the bound below is the point. A server that ignored `after`
# would otherwise loop forever, and this runs unattended.
PAGE_SIZE=50
PAGE_MAX=20
read -r -d '' QUERY <<'GRAPHQL' || true
query($owner: String!, $name: String!, $number: Int!, $size: Int!, $cursor: String) {
repository(owner: $owner, name: $name) {
pullRequest(number: $number) {
reviewThreads(first: $size, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
isResolved
isOutdated
path
line
comments(first: 100) {
pageInfo { hasNextPage }
nodes { author { login } body diffHunk createdAt }
}
}
}
}
}
}
GRAPHQL
threads='[]'
cursor=""
page=1
while [ "$page" -le "$PAGE_MAX" ]; do
if [ -z "$cursor" ]; then
json="$(api_or_die "fetch review threads" gh api graphql -f query="$QUERY" \
-f owner="$owner" -f name="$repo" -F number="$pr_number" -F size="$PAGE_SIZE")"
else
json="$(api_or_die "fetch review threads" gh api graphql -f query="$QUERY" \
-f owner="$owner" -f name="$repo" -F number="$pr_number" -F size="$PAGE_SIZE" \
-f cursor="$cursor")"
fi
# GraphQL answers a PR number that does not exist with a null pullRequest
# and no error at all, so this is the check that catches it rather than
# adding null to an array and failing with jq's wording.
if [ "$(jq -r '.data.repository.pullRequest // "null"' <<< "$json")" = "null" ]; then
echo "github-pr: no pull request ${owner_repo}#${pr_number}" >&2
exit 1
fi
threads="$(jq -n --argjson a "$threads" --argjson b "$json" \
'$a + $b.data.repository.pullRequest.reviewThreads.nodes')"
if [ "$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage' <<< "$json")" != "true" ]; then
break
fi
cursor="$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.endCursor' <<< "$json")"
page=$((page + 1))
done
if [ "$page" -gt "$PAGE_MAX" ]; then
echo "warning: stopped reading review threads after $PAGE_MAX pages - some may be missing" >&2
fi
# A thread whose replies did not all fit is reported rather than silently
# truncated, for the same reason the page bound is: a feedback tool that drops
# the comment someone is waiting on is worse than one that says it might have.
truncated="$(jq '[.[] | select(.comments.pageInfo.hasNextPage)] | length' <<< "$threads")"
[ "$truncated" -eq 0 ] || \
echo "warning: $truncated thread(s) have more replies than were fetched" >&2
resolved_count="$(jq '[.[] | select(.isResolved)] | length' <<< "$threads")"
if [ "$show_all" -eq 1 ]; then
visible="$threads"
else
visible="$(jq '[.[] | select(.isResolved | not)]' <<< "$threads")"
fi
# The hunk, not just the path, and the same layout gitea-pr prints: whoever
# reads one of these tools should not have to learn the other's output.
jq -r '.[]
| . as $t
| .comments.nodes[]
| "[\(.author.login // "?") @ \($t.path):\($t.line // "?")\(if $t.isResolved then " (resolved)" else "" end)\(if $t.isOutdated then " (outdated)" else "" end)]\n\($t.comments.nodes[0].diffHunk // "(no hunk recorded)")\n--- comment ---\n\(.body)\n"' \
<<< "$visible"
if [ "$show_all" -ne 1 ] && [ "$resolved_count" -gt 0 ]; then
echo "($resolved_count resolved thread(s) hidden. Re-run with --all to include them.)"
fi
+30 -1
View File
@@ -24,9 +24,38 @@ capabilities = pr issues
pr_command = gh pr
issue_command = gh issue
merge_block = gh pr merge
# How this CLI can be pointed at a repository other than the one the session
# was launched against. `gh` takes --repo/-R on essentially every subcommand
# and reads GH_REPO from the environment, so the ordinary path can file an
# issue or leave a comment anywhere the token reaches (#187). The gitea CLIs
# have no equivalent - they resolve the repo from origin and refuse another
# host - which is why these keys are empty for that forge rather than absent
# from the schema.
#
# Fast-fail, not a boundary, exactly as merge_block is: the session holds the
# token and could call the API directly. What actually bounds this is the
# scope of push_token_file, which is why docs/providers.md says a
# repository-scoped fine-grained PAT is the expected shape.
repo_flags = --repo -R
repo_env = GH_REPO
disclose = pr:gh pr create, pr:gh pr edit, issue:gh issue create, issue:gh issue edit, comment:gh issue comment
# See the gitea manifest's note: this is the key an identity supplies and a
# project may not. The read-only [github] token_file is core's rather than
# this forge's, and is not here for that reason.
identity_keys = push_token_file
keys = push_token_file:?path
requires = push_token_file
fixed_hosts = github.com ssh.github.com api.github.com
hosts_allowlisted_by_core = on
install =
# gh comes from a package, not from this repo, but it still has to be
# declared: the entrypoint links the merge shadow over a command only when it
# is behind /usr/local/lib/dejima/forge, and refuses to start otherwise rather
# than leave a merge block that looks enforced and is not.
#
# github-pr is dejima's, and exists for one thing gh cannot do: thread
# resolution lives only in the GraphQL API, so `gh pr view --comments` shows
# resolved and unresolved review threads alike and an agent working through
# feedback re-answers what was closed a week ago (#186). It reads and never
# writes, so nothing is shadowed over it.
install = system:gh bin/github-pr
env_export = GH_TOKEN=@push_token_file
+12 -1
View File
@@ -66,7 +66,18 @@ no_confirm_args = --auto-approve true
# base_url absent means the Anthropic path through dejima's credential proxy.
# Set, it means a self-hosted endpoint, and the plugin refuses one that is not
# host-local - see bin/harness/cline.sh for why that is an R5 check.
keys = state_dir:path=~/.local/share/dejima/cline model:?identifier base_url:?url
# context_window is the one Cline cannot work out for itself. It budgets
# against its own catalogue, and for a model it does not recognise - which is
# every self-served one - it has no budget at all, so it grows the conversation
# until the SERVER rejects it ("exceeds the available context size") instead of
# compacting. Set, Cline compacts before it gets there.
#
# No default, because the right value belongs to the model being served and a
# wrong one is worse than none: too high and nothing changes, too low and it
# compacts constantly. Set it a little under whatever the server allows -
# Cline's accounting is approximate and a compaction does not always land
# under target when a single tool result is large.
keys = state_dir:path=~/.local/share/dejima/cline model:?identifier base_url:?url context_window:?int:512:100000000
# Deliberately empty: resuming needs the state directory's path as well as the
# session id, briefing placeholders do not nest, and `template` forbids the
+1 -1
View File
@@ -207,7 +207,7 @@ if [ -f "$decl_dir/allowed-domains" ]; then
Tell the user to run, outside the sandbox:
dejima-project approve \"$project_dir\"
(or, for one domain right now:
bin/dejima-allow --container $container_id <domain>)"
dejima-allow --container $container_id <domain>)"
fi
fi
+10 -2
View File
@@ -198,8 +198,16 @@ fi
# the harness having declared it (see `guards` in its manifest).
echo
source "$SCRIPT_DIR/bin/dejima-harness-lib.sh"
_harness="$(config_get core harness 2>/dev/null)" || _harness=""
if [ -z "$_harness" ]; then
# A config that does not parse leaves config_get empty for EVERY key, so
# without this the operator is told no harness is set while looking at the
# harness line they just wrote. config_validate names the line actually at
# fault, on stderr, which is the answer to a question this cannot answer.
_harness=""
if config_exists && ! config_validate; then
echo "$CONFIG_FILE could not be read, so no guard hooks were registered."
echo "Fix what is reported above and re-run ./install.sh."
elif ! _harness="$(config_get core harness 2>/dev/null)" || [ -z "$_harness" ]; then
_harness=""
echo "No [core] harness is set in $CONFIG_FILE, so no guard hooks were"
echo "registered. Set one and re-run ./install.sh."
else
+129 -1
View File
@@ -651,6 +651,129 @@ EOF
[[ "$output" == *"settings.json"* ]] # the second report, same run
}
@test "ack retires a report it has finished, and reaches the ones behind it" {
# #195, as reported: "5 reports waiting" followed immediately by "nothing
# new - 32 finding(s) already dealt with", and the five stay waiting
# however many times ack is run. Selection kept returning a report it had
# already finished, so every other report was unreachable.
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
snap; mkdir -p "$WS/.claude"; echo '{}' > "$WS/.claude/settings.json"; cmp_ >/dev/null 2>&1
[ "$(find "$AUDITDIR/$slug" -name '*.txt' | wc -l)" -eq 2 ]
# Approve through both, in one run.
run bash -c "printf 'a\ny\na\n' | '$AUDIT' ack '$WS'"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *".envrc"* ]] && [[ "$output" == *"settings.json"* ]] \
|| { echo "did not reach both reports: $output"; return 1; }
# Nothing outstanding now. The reports stay on disk - they are the record
# of what those sessions did, and rotation evicts them, not ack - but
# neither ack nor show may claim work is waiting.
[ "$(find "$AUDITDIR/$slug" -name '*.txt' | wc -l)" -eq 2 ]
run bash -c "'$AUDIT' ack '$WS' 2>&1"
[ "$status" -eq 0 ]
[[ "$output" == *"nothing waiting"* ]] || { echo "ack: $output"; return 1; }
[[ "$output" != *"reports waiting"* ]] || { echo "still claims work: $output"; return 1; }
run bash -c "'$AUDIT' show '$WS' 2>&1"
[[ "$output" == *"nothing waiting"* ]] || { echo "show: $output"; return 1; }
# And it says what is still there, rather than "no reports" over a
# directory holding two.
[[ "$output" == *"2 report(s) kept"* ]] || { echo "show: $output"; return 1; }
}
@test "a report still holding an undecided finding is not retired with the rest" {
# The marker retires the report, not the decision. A finding the operator
# skipped is unbaselined and must come back - otherwise "ask again next
# time" silently becomes "never ask again".
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
run bash -c "printf 's\n' | '$AUDIT' ack '$WS'"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
run bash -c "printf 's\n' | '$AUDIT' ack '$WS' 2>&1"
[[ "$output" == *".envrc"* ]] \
|| { echo "a skipped finding did not come back: $output"; return 1; }
}
@test "a file already shown this run is not asked about again from an older report" {
# Two reports about the same file, walked in one invocation. Skipping it in
# the first leaves nothing in the baseline, so the second used to ask again
# - about content that is no longer what is on disk, which is a question
# with no useful answer.
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
snap; echo 'export X=2' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
[ "$(find "$AUDITDIR/$slug" -name '*.txt' | wc -l)" -eq 2 ]
# skip, continue, then the second report must not re-ask.
run bash -c "printf 's\ny\ns\n' | '$AUDIT' ack '$WS'"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$(grep -c '\[CRITICAL\]\|\[WARNING\]' <<< "$output")" -eq 1 ] \
|| { echo "asked more than once about one file:"; echo "$output"; return 1; }
[[ "$output" == *"already dealt with"* ]] \
|| { echo "dropped it silently instead of saying why: $output"; return 1; }
# Skipping is still skipping: it comes back on the next run.
run bash -c "printf 's\n' | '$AUDIT' ack '$WS' 2>&1"
[[ "$output" == *".envrc"* ]] \
|| { echo "a skipped finding did not come back: $output"; return 1; }
}
@test "ack opens the current file in the operator's editor, and comes back to the decision" {
# The report shows the part that changed. What a person needs in order to
# judge it is the file, and there was no way to get at it from the prompt.
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap; printf 'export X=1\n' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
local ed="$BATS_TEST_TMPDIR/fake-editor"
cat > "$ed" <<'EOF'
#!/bin/bash
# Records what it was asked to open, and what was in it at the time.
printf '%s\n' "$*" >> "$FAKE_EDITOR_LOG"
tail -n +1 -- "${@: -1}" >> "$FAKE_EDITOR_LOG"
EOF
chmod +x "$ed"
export FAKE_EDITOR_LOG="$BATS_TEST_TMPDIR/editor.log"
# open, then approve.
run bash -c "printf 'o\na\n' | VISUAL='$ed' '$AUDIT' ack '$WS'"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ -f "$FAKE_EDITOR_LOG" ] || { echo "the editor was never run: $output"; return 1; }
# The file on disk, not the text the report captured.
grep -q "$WS/.envrc" "$FAKE_EDITOR_LOG" \
|| { echo "opened something else: $(cat "$FAKE_EDITOR_LOG")"; return 1; }
grep -q 'export X=1' "$FAKE_EDITOR_LOG" \
|| { echo "did not open the current content: $(cat "$FAKE_EDITOR_LOG")"; return 1; }
# Opening is not a decision: the approve that followed it is what recorded.
[ "$(grep -c '^safe .envrc' "$BASEDIR/$slug")" -eq 1 ] \
|| { echo "opening did not return to the decision"; cat "$BASEDIR/$slug"; return 1; }
}
@test "ack offers no editor for a file that has since been removed" {
# The surface is gone, so there is nothing to read - and offering to open
# it would either fail or, worse, open an empty buffer that looks like the
# file's current content and gets approved on that basis.
snap; printf 'export X=1\n' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
rm -f "$WS/.envrc"
run bash -c "printf 's\n' | '$AUDIT' ack '$WS'"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *".envrc"* ]] || { echo "no finding to test: $output"; return 1; }
[[ "$output" != *"open the file"* ]] \
|| { echo "offered to open a file that is gone: $output"; return 1; }
}
@test "ack offers no editor for a finding with no file to open" {
# A git config key has no path. Offering to open one, and then failing to,
# is worse than not offering.
snap
git -C "$WS" config core.pager 'sh -c cat'
cmp_ >/dev/null 2>&1
run bash -c "printf 's\n' | '$AUDIT' ack '$WS'"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"git config"* ]] || { echo "no gitconfig finding to test: $output"; return 1; }
[[ "$output" != *"open the file"* ]] \
|| { echo "offered to open a file that does not exist: $output"; return 1; }
}
@test "ack stops at one report when told not to continue" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
@@ -834,7 +957,12 @@ REPORT
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[[ "$output" != *".envrc"* ]]
[[ "$output" == *"already dealt with"* ]]
# The second run does not re-walk a report it finished: it says the work
# is done and stops. That message used to come from walking the report
# again and finding every finding covered, which is what left a finished
# report selectable forever and the ones behind it unreachable (#195).
[[ "$output" == *"nothing waiting"* ]] || { echo "$output"; return 1; }
[[ "$output" == *"has been dealt with"* ]] || { echo "$output"; return 1; }
# And no second entry for the same decision.
[ "$(grep -c '^safe .envrc' "$BASEDIR/$slug")" -eq 1 ]
}
+382 -4
View File
@@ -487,6 +487,100 @@ write_config() {
|| { echo "the refusal does not name the key: $output"; return 1; }
}
# --- identities: the half of a project's config that belongs to a person -----
#
# A project's .dejima/ is committed and shared; an ssh key path and a token
# path are per-user and per-machine. An identity is where those live, so a
# project can name an account without naming a path.
_identity_fixture() {
ID_DIR="$(mktemp -d)"
: > "$ID_DIR/default-key"; : > "$ID_DIR/work-key"; : > "$ID_DIR/oss-key"
: > "$ID_DIR/default-token"; : > "$ID_DIR/work-token"
write_config '[core]' 'forge = gitea' "ssh_key = $ID_DIR/default-key" \
'[forge.gitea]' 'ssh_host = git.example' 'api = https://git.example/api/v1' \
"token_file = $ID_DIR/default-token" \
'[identity.work]' "ssh_key = $ID_DIR/work-key" 'user_name = A Name' \
'user_email = name@work.example' "token_file = $ID_DIR/work-token" \
'[identity.oss]' "ssh_key = $ID_DIR/oss-key"
}
@test "an identity supplies the ssh key and the forge credential" {
_identity_fixture
run "$REPO_ROOT/bin/dejima" --identity work --print-config
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qx "core.ssh_key=$ID_DIR/work-key" <<< "$output" || { echo "$output"; return 1; }
grep -qx "forge.gitea.token_file=$ID_DIR/work-token" <<< "$output" \
|| { echo "$output"; return 1; }
rm -rf "$ID_DIR"
}
@test "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.
_identity_fixture
run "$REPO_ROOT/bin/dejima" --identity oss --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 "forge.gitea.token_file=$ID_DIR/default-token" <<< "$output" \
|| { echo "oss has no token_file and did not fall through: $output"; return 1; }
rm -rf "$ID_DIR"
}
@test "an identity that names nothing is refused, not ignored" {
# The whole point of the refusal: falling through to this machine's own
# credentials would push under the wrong account with nothing saying so.
# Pushing under the wrong key fails loudly; pushing under the default key
# when you asked for another does not fail at all.
_identity_fixture
run "$REPO_ROOT/bin/dejima" --identity wrok --print-config
[ "$status" -ne 0 ] || { echo "a nonexistent identity was accepted"; return 1; }
[[ "$output" == *"wrok"* ]] || { echo "$output"; return 1; }
[[ "$output" != *"core.ssh_key="* ]] \
|| { echo "printed a resolved config under an identity that does not exist"; return 1; }
rm -rf "$ID_DIR"
}
@test "a flag beats the identity, and the identity beats the file" {
_identity_fixture
run "$REPO_ROOT/bin/dejima" --identity work --print-config
grep -qx "core.ssh_key=$ID_DIR/work-key" <<< "$output" || { echo "$output"; return 1; }
# [core] identity in the file, overridden by the flag.
write_config '[core]' 'identity = work' "ssh_key = $ID_DIR/default-key" \
'[identity.work]' "ssh_key = $ID_DIR/work-key" \
'[identity.oss]' "ssh_key = $ID_DIR/oss-key"
run "$REPO_ROOT/bin/dejima" --print-config
grep -qx "core.ssh_key=$ID_DIR/work-key" <<< "$output" || { echo "$output"; return 1; }
run "$REPO_ROOT/bin/dejima" --identity oss --print-config
grep -qx "core.ssh_key=$ID_DIR/oss-key" <<< "$output" \
|| { echo "--identity did not beat [core] identity: $output"; return 1; }
rm -rf "$ID_DIR"
}
@test "an identity may only set what a person owns" {
# Not a second config file with different rules about what may be in it.
_identity_fixture
write_config '[core]' 'forge = gitea' '[identity.work]' 'firewall = off'
run "$REPO_ROOT/bin/dejima" --identity work --print-config
[ "$status" -ne 0 ] || { echo "an identity set [core] firewall"; return 1; }
[[ "$output" == *"firewall"* ]] || { echo "$output"; return 1; }
rm -rf "$ID_DIR"
}
@test "an identity's git name reaches the container's gitconfig copy" {
# Appended to the COPY dejima already makes, so the real ~/.gitconfig is
# untouched - which was already true and is what the copy is for.
_identity_fixture
grep -q 'identity_git_field' "$REPO_ROOT/bin/dejima" \
|| { echo "the launcher no longer reads the identity's git name"; return 1; }
# Written with git itself rather than printf: the value is the operator's
# own text, and a name with a quote or a backslash in it has to survive
# into a file git reads back the same way.
grep -q 'git config -f "$SANDBOX_GITCONFIG_DIR/.gitconfig"' "$REPO_ROOT/bin/dejima" \
|| { echo "the git name is no longer written with git"; return 1; }
rm -rf "$ID_DIR"
}
@test "--harness selects a harness for one launch without changing the default" {
# A host keeps one harness as its default and reaches another when it wants
# to. Everything else was already in place - harnesses configure side by
@@ -639,6 +733,88 @@ write_config() {
[[ "$output" == *"core.command=nvim"* ]]
}
@test "the narrowing flags only ever narrow" {
# Each of these turns something off for one launch and has no counterpart
# that turns it on. A config saying off is a decision; a flag that could
# undo it in passing would make the file's answer the weaker one.
write_config '[logging]' 'loki_url = https://loki.example.com' 'conversation = on' \
'[epitaph]' 'enabled = on' \
'[audit]' 'strict = off'
run "$REPO_ROOT/bin/dejima" --print-config
[[ "$output" == *"logging.conversation=on"* ]] || { echo "$output"; return 1; }
run "$REPO_ROOT/bin/dejima" --no-conversation --print-config
[[ "$output" == *"logging.conversation=off"* ]] || { echo "$output"; return 1; }
# and only that: the DNS and firewall log still ships.
[[ "$output" == *"logging.loki_url=https://loki.example.com"* ]] \
|| { echo "--no-conversation took the whole log with it"; return 1; }
run "$REPO_ROOT/bin/dejima" --no-log --print-config
grep -qx 'logging.loki_url=' <<< "$output" || { echo "$output"; return 1; }
# --no-log implies --no-conversation: shipping the transcript needs an
# endpoint, and leaving the key on while clearing the endpoint would read
# as conversation logging still being in force.
[[ "$output" == *"logging.conversation=off"* ]] || { echo "$output"; return 1; }
run "$REPO_ROOT/bin/dejima" --no-epitaph --print-config
[[ "$output" == *"epitaph.enabled=off"* ]] || { echo "$output"; return 1; }
run "$REPO_ROOT/bin/dejima" --strict-audit --print-config
[[ "$output" == *"audit.strict=on"* ]] || { echo "$output"; return 1; }
# None of them takes a value, so none can be turned back on by passing one.
local f
for f in --no-log --no-conversation --no-epitaph --strict-audit; do
run "$REPO_ROOT/bin/dejima" "$f" on --print-config
[ "$status" -ne 0 ] || { echo "$f accepted a value: $output"; return 1; }
done
}
@test "a harness-key flag sets the selected harness's key" {
# --model is one flag over harnesses that spell the setting differently or
# do not have it at all, so what it resolves to depends on the selection.
write_config '[core]' 'harness = cline' \
'[harness.cline]' 'model = from-the-file'
run "$REPO_ROOT/bin/dejima" --print-config
[[ "$output" == *"harness.cline.model=from-the-file"* ]] || { echo "$output"; return 1; }
run "$REPO_ROOT/bin/dejima" --model from-the-flag --base-url http://127.0.0.1:8080 \
--context-window 60000 --print-config
[[ "$output" == *"harness.cline.model=from-the-flag"* ]] || { echo "$output"; return 1; }
[[ "$output" == *"harness.cline.base_url=http://127.0.0.1:8080"* ]] || { echo "$output"; return 1; }
[[ "$output" == *"harness.cline.context_window=60000"* ]] || { echo "$output"; return 1; }
}
@test "a harness-key flag is resolved after every flag, not where it appears" {
# --harness may come after --model, so which harness the key belongs to is
# not known until the whole line has been read. Resolving eagerly would
# bind the flag to whatever the config happened to say.
write_config '[core]' 'harness = claude-code'
run "$REPO_ROOT/bin/dejima" --model chosen --harness cline --print-config
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"harness.cline.model=chosen"* ]] || { echo "$output"; return 1; }
}
@test "a harness-key flag is refused for a harness without that key" {
# Silently doing nothing is the failure worth avoiding: --model against a
# harness with no model setting reads as having chosen one.
write_config '[core]' 'harness = claude-code'
run "$REPO_ROOT/bin/dejima" --model x --print-config
[ "$status" -ne 0 ] || { echo "--model was accepted for claude-code"; return 1; }
[[ "$output" == *"claude-code"* ]] || { echo "the refusal does not name the harness: $output"; return 1; }
[[ "$output" == *"model"* ]] || { echo "the refusal does not name the key: $output"; return 1; }
}
@test "a harness-key flag names itself in a validation failure" {
# _set_override reports the key by default, and --context-window's key is
# context_window - an option nobody can type.
write_config '[core]' 'harness = cline'
run "$REPO_ROOT/bin/dejima" --context-window 5 --print-config
[ "$status" -ne 0 ]
[[ "$output" == *"--context-window"* ]] || { echo "$output"; return 1; }
[[ "$output" != *"--context_window"* ]] || { echo "reported the key, not the flag: $output"; return 1; }
}
@test "a flag value is validated against the same schema type" {
write_config '[core]' 'firewall = on'
run "$REPO_ROOT/bin/dejima" --cmd 'rm -rf /' --print-config
@@ -1755,21 +1931,34 @@ _epitaph_fixture() {
EPI_SLUG="$(printf '%s' "$EPI_WS" | sed 's/[^a-zA-Z0-9]/-/g')"
mkdir -p "$EPI_TR/$EPI_SLUG"
# The transcripts directory reaches the tool through the harness's own
# config key now, not as an argument: where transcripts live is the
# harness's, and dejima-epitaph no longer knows the shape of it.
# config key, not as an argument: where transcripts live is the harness's,
# and dejima-epitaph no longer knows the shape of it. `list` needs the same
# key to report whether the session behind an epitaph is still resumable.
write_config '[epitaph]' "dir = $EPI_STORE" \
'[harness.claude-code]' "projects_dir = $EPI_TR"
}
# One compact_boundary carrying the metadata, then the summary itself.
# One compact_boundary carrying the metadata, then the summary itself. The
# session id matches the transcript's filename, as Claude Code's own do -
# that pairing is what `list` resolves to decide a session is still there.
_epitaph_transcript() {
local f="$EPI_TR/$EPI_SLUG/$1.jsonl" text="${2:-worked out X}" uid="${3:-aaaabbbb}"
local ts="${4:-2026-01-02T03:04:05.000Z}"
{
printf '{"type":"system","subtype":"compact_boundary","compactMetadata":{"trigger":"manual","preTokens":900,"postTokens":90}}\n'
printf '{"type":"user","isCompactSummary":true,"uuid":"%s","sessionId":"s-1","timestamp":"2026-01-02T03:04:05.000Z","message":{"content":"%s"}}\n' "$uid" "$text"
printf '{"type":"user","isCompactSummary":true,"uuid":"%s","sessionId":"%s","timestamp":"%s","message":{"content":"%s"}}\n' "$uid" "$1" "$ts" "$text"
} > "$f"
}
# Three epitaphs in a known order, so an index means something to assert on.
_epitaph_three() {
_epitaph_fixture
_epitaph_transcript s1 "the firewall drops IPv6" aaaa1111 2026-01-02T03:04:05.000Z
_epitaph_transcript s2 "the DNS proxy rewrote resolv" bbbb2222 2026-02-03T04:05:06.000Z
_epitaph_transcript s3 "worked out the audit baseline" cccc3333 2026-03-04T05:06:07.000Z
"$REPO_ROOT/bin/dejima-epitaph" save "$EPI_WS" >/dev/null 2>&1
}
@test "an epitaph is written out of the container's reach" {
_epitaph_fixture
_epitaph_transcript s1 "the thing this session worked out"
@@ -1842,6 +2031,128 @@ _epitaph_transcript() {
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
}
@test "list shows one row per epitaph, oldest first" {
_epitaph_three
run "$REPO_ROOT/bin/dejima-epitaph" list "$EPI_WS"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$(grep -cE '^ +[0-9]+ [0-9]{4}-' <<< "$output")" -eq 3 ] || { echo "$output"; return 1; }
# Chronological by the timestamp in the name, which is what makes an
# index stable enough for `show <n>` to mean anything.
grep -q '^ *1 2026-01-02' <<< "$output" || { echo "$output"; return 1; }
grep -q '^ *3 2026-03-04' <<< "$output" || { echo "$output"; return 1; }
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
}
@test "list says whether the session behind an epitaph is still resumable" {
# Resuming the real session beats reading a summary of it, so the listing
# has to say which of the two is still on offer.
_epitaph_three
run "$REPO_ROOT/bin/dejima-epitaph" list "$EPI_WS"
[ "$(grep -c 'resumable' <<< "$output")" -eq 3 ] || { echo "$output"; return 1; }
rm -f "$EPI_TR/$EPI_SLUG/s2.jsonl"
run "$REPO_ROOT/bin/dejima-epitaph" list "$EPI_WS"
[ "$(grep -c 'resumable' <<< "$output")" -eq 2 ] || { echo "$output"; return 1; }
grep -q '^ *2 .*aged out' <<< "$output" || { echo "$output"; return 1; }
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
}
@test "reading the store is not gated on [epitaph] enabled" {
# Turning epitaph off must not strand what it already kept: the operator
# who switches it off is the one most likely to want to look at, and get
# rid of, what is there.
_epitaph_three
write_config '[epitaph]' "dir = $EPI_STORE" 'enabled = off' \
'[harness.claude-code]' "projects_dir = $EPI_TR"
run "$REPO_ROOT/bin/dejima-epitaph" list "$EPI_WS"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$(grep -cE '^ +[0-9]+ [0-9]{4}-' <<< "$output")" -eq 3 ] || { echo "$output"; return 1; }
# Writing still is.
_epitaph_transcript s4 "should not be kept" dddd4444 2026-04-05T06:07:08.000Z
"$REPO_ROOT/bin/dejima-epitaph" save "$EPI_WS" >/dev/null 2>&1
[ "$(find "$EPI_STORE" -name '*.md' | wc -l)" -eq 3 ]
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
}
@test "list says the resumable state is unknown rather than guessing it" {
# Which sessions still have a transcript is the harness's answer. With no
# harness there is nobody to ask, and "aged out" would be a claim this
# cannot support - the transcripts may be sitting right there. The config
# is written directly because write_config seeds a harness otherwise.
_epitaph_three
printf '[epitaph]\ndir = %s\n' "$EPI_STORE" > "$CONFIG_DIR/config"
run "$REPO_ROOT/bin/dejima-epitaph" list "$EPI_WS"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$(grep -c 'resumable' <<< "$output")" -eq 0 ] || { echo "$output"; return 1; }
[ "$(grep -c 'aged out' <<< "$output")" -eq 0 ] || { echo "$output"; return 1; }
[ "$(grep -cE '^ +[0-9]+ [0-9]{4}-' <<< "$output")" -eq 3 ] || { echo "$output"; return 1; }
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
}
@test "search finds an epitaph by content and reports its listing index" {
# The names are a timestamp and a uuid, so nothing about a filename says
# which session it was. Search is the only way in.
_epitaph_three
run "$REPO_ROOT/bin/dejima-epitaph" search "resolv" "$EPI_WS"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -q '1 of 3' <<< "$output" || { echo "$output"; return 1; }
# The index it prints is the position in the same listing `list` shows,
# so `show <n>` straight after a search opens what the search found.
grep -qE '^ *2 20260203' <<< "$output" || { echo "$output"; return 1; }
run "$REPO_ROOT/bin/dejima-epitaph" show 2 "$EPI_WS"
grep -q 'the DNS proxy rewrote resolv' <<< "$output" || { echo "$output"; return 1; }
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
}
@test "search matching nothing says so without failing" {
_epitaph_three
run "$REPO_ROOT/bin/dejima-epitaph" search "nothing-in-any-of-them" "$EPI_WS"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -q 'no epitaph matches' <<< "$output" || { echo "$output"; return 1; }
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
}
@test "show writes to stdout when there is no terminal to open an editor on" {
# `dejima-epitaph show | grep` must not try to launch an editor, and a
# test is the same case as a pipe.
_epitaph_three
run env VISUAL=/bin/false EDITOR=/bin/false \
"$REPO_ROOT/bin/dejima-epitaph" show 1 "$EPI_WS"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -q 'the firewall drops IPv6' <<< "$output" || { echo "$output"; return 1; }
# Newest by default.
run env VISUAL=/bin/false "$REPO_ROOT/bin/dejima-epitaph" show "$EPI_WS"
grep -q 'worked out the audit baseline' <<< "$output" || { echo "$output"; return 1; }
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
}
@test "show opens the editor and leaves the epitaph at 0600" {
command -v script >/dev/null 2>&1 || skip "no script(1) to allocate a pty"
_epitaph_three
local ed; ed="$(mktemp -d)"
# Rewrites through a temp file and a rename, which is what an editor
# does and what drops the mode to the umask's.
printf '#!/bin/bash\nprintf "%%s\\n" "$1" > %s/got\nprintf edited > "$1.t" && mv "$1.t" "$1"\n' \
"$ed" > "$ed/ed"
chmod +x "$ed/ed"
local f; f="$(find "$EPI_STORE" -name '*.md' | sort | head -1)"
chmod 600 "$f"
run env VISUAL="$ed/ed" EDITOR="$ed/ed" script -qec \
"$REPO_ROOT/bin/dejima-epitaph show 1 $EPI_WS" /dev/null
[ -f "$ed/got" ] || { echo "the editor was never run: $output"; return 1; }
[ "$(cat "$ed/got")" = "$f" ] || { echo "opened $(cat "$ed/got"), wanted $f"; return 1; }
[ "$(stat -c %a "$f")" = "600" ] \
|| { echo "left at $(stat -c %a "$f") after the editor rewrote it"; return 1; }
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS" "$ed"
}
@test "an unknown epitaph subcommand is refused" {
_epitaph_fixture
run "$REPO_ROOT/bin/dejima-epitaph" delete-everything "$EPI_WS"
[ "$status" -eq 2 ] || { echo "$output"; return 1; }
grep -q 'usage:' <<< "$output" || { echo "$output"; return 1; }
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
}
@test "the epitaph store is not mounted into the container" {
# The whole point: a session must not be able to read what earlier
# sessions concluded, or edit what this one is about to record.
@@ -2039,6 +2350,26 @@ _epitaph_transcript() {
|| { echo "wrote a harness config anyway"; return 1; }
}
@test "install.sh names the broken line rather than blaming the harness" {
# A config that does not parse leaves config_get empty for every key, so
# the harness read comes back empty too - and reporting that as "no
# harness is set" sends the operator to a line that is already correct.
# The key here is real: it is one a newer dejima accepts, which is how an
# operator arrives with a config their checkout does not know yet.
printf '[core]\nharness = claude-code\nforge = none\nworkflow = local-only\n' \
> "$CONFIG_DIR/config"
printf '[harness.claude-code]\nno_such_key = 1\n' >> "$CONFIG_DIR/config"
run_install
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"no_such_key"* ]] \
|| { echo "did not name the offending key: $output"; return 1; }
[[ "$output" != *"No [core] harness is set"* ]] \
|| { echo "blamed the harness line, which is correct: $output"; return 1; }
[ ! -f "$CONFIG_DIR/.claude/settings.json" ] \
|| { echo "registered guards from a config it could not read"; return 1; }
}
@test "[mounts] readonly is refused when it names the filesystem root" {
# Root contains every guarded store, so mounting it hands the session all
# of them at once - the approval store, the audit reports, the epitaphs.
@@ -2165,6 +2496,53 @@ STUB
rm -rf "$workspace" "$RW_HOME" "$root" "${docker_log:-}"
}
@test "a forge credential never reaches the docker command line" {
# /proc/<pid>/cmdline is world-readable, so a secret passed as
# `-e NAME=value` is visible to every process on the host via ps - and
# from there into whatever captures ps output. That is how #226 was
# found: an operator ran ps while debugging something unrelated and
# pasted the result.
#
# `-e NAME` takes the value from the launcher's own environment instead,
# where it lives in /proc/<pid>/environ - mode 0400, owner only.
#
# This repository already made the argument one layer down: gitea-pr
# feeds curl its auth header on stdin rather than as an argument, and
# says why. The test is here so the two cannot drift apart again.
_rw_setup
workspace="$(mktemp -d)"
local tokfile="$RW_HOME/tok" secret="tok-$RANDOM-do-not-leak"
printf '%s\n' "$secret" > "$tokfile"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = gitea' 'workflow = local-only' \
'[forge.gitea]' 'ssh_host = git.example' 'api = https://git.example/api/v1' \
"token_file = $tokfile"
STUB_DIR="$(mktemp -d)"
local argv_log; argv_log="$(mktemp)"
cat > "$STUB_DIR/docker" <<'STUB'
#!/bin/bash
printf '%s\n' "$*" >> "$SANDBOX_TEST_ARGV_LOG"
exit 0
STUB
chmod +x "$STUB_DIR/docker"
HOME="$RW_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
SANDBOX_TEST_DOCKER_LOG=/dev/null SANDBOX_TEST_ARGV_LOG="$argv_log" \
run "$REPO_ROOT/bin/dejima" "$workspace"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
local argv; argv="$(cat "$argv_log")"
[ -n "$argv" ] || { echo "the stub captured no docker invocation"; return 1; }
# The whole point.
[[ "$argv" != *"$secret"* ]] \
|| { echo "the token is in the docker command line"; return 1; }
# And it is still passed, by name, or the forge simply has no credential.
[[ "$argv" == *"-e GITEA_TOKEN"* ]] \
|| { echo "GITEA_TOKEN is not passed at all: $argv"; return 1; }
# Specifically the bare form. `-e GITEA_TOKEN=` would also match above.
[[ "$argv" != *"-e GITEA_TOKEN="* ]] \
|| { echo "passed as NAME=value after all: $argv"; return 1; }
rm -rf "$workspace" "$RW_HOME" "$STUB_DIR" "$argv_log"
}
@test "the harness layer creates the harness's own state directories and environment" {
# These were in the base image, imposed on every image dejima builds. They
# are the selected harness's now, and have to actually reach its layer -
+27
View File
@@ -124,6 +124,33 @@ await_shipped() {
# The two loss paths a SIGTERM without a trap produces. bin/dejima kills this
# immediately after the container exits, and the tail of a transcript is the
# part worth having - it is usually the agent's final answer.
@test "a batch too large for an argv argument still ships" {
# A single argument is capped at 128 KiB (MAX_ARG_STRLEN). The payload used
# to be passed to jq and then to curl that way, so a batch of long lines
# failed with "argument list too long" and was dropped silently - the
# shipper keeps running, and only the conversation goes missing.
#
# Not a remote possibility: the transform clips a message at sixteen
# kilobytes and the batch holds twenty, so any harness whose tool results
# are large produces this as a matter of course. Found running Cline.
start_loki
write_config
mkdir -p "$(slug_dir)"
local big
big="$(head -c 60000 /dev/zero | tr '\0' 'x')"
local i
for i in 1 2 3 4 5; do
say "chunk-$i-$big" >> "$(slug_dir)/s1.jsonl"
done
start_ship
# The marker is at the START of one of those lines, so finding it means the
# whole oversized batch was accepted rather than one small line sneaking
# through beside a dropped one.
await_shipped "chunk-5-" || { echo "an oversized batch never arrived"; return 1; }
run jq -r '.streams[0].stream.job' <<< "$(shipped | head -1)"
[ "$output" = "dejima-conversation" ] || { echo "malformed payload: $output"; return 1; }
}
@test "a batched line survives the kill that ends the session" {
start_loki
write_config
+282
View File
@@ -0,0 +1,282 @@
#!/usr/bin/env python3
"""Drives bin/dejima-tty-suspend the way a user drives Ctrl-Z, and reports.
Ctrl-Z cannot be tested by sending a signal: the whole question is whether the
terminal turns that keystroke INTO a signal, or passes it through as a byte.
That needs a real terminal, and a real job-control shell to suspend a job and
bring it back - the kernel discards SIGTSTP sent to an orphaned process group,
which is what a launcher started any other way ends up in.
So: a pty, an interactive shell on it, and a stand-in for the container run
that does the one thing that matters here - put the terminal in raw mode and
proxy keystrokes onward. Docker is not available where these tests run, and is
not what is being tested; the terminal handling is.
Usage: tty-suspend-harness.py <helper-script> <shell>
Prints one "PASS <name>" or "FAIL <name> <detail>" per line, and exits nonzero
if any failed.
"""
import fcntl
import os
import pty
import select
import struct
import sys
import termios
import time
HELPER = os.path.abspath(sys.argv[1])
SHELL = sys.argv[2] if len(sys.argv) > 2 else "bash"
WORK = os.environ.get("TTY_SUSPEND_WORK") or "/tmp"
RECEIVED = os.path.join(WORK, "received")
LAUNCHER = os.path.join(WORK, "launcher.sh")
CONTAINER = os.path.join(WORK, "container.py")
# A stand-in for `docker compose run`: raw mode, then proxy stdin onward.
# "Onward" is a file, which is what lets a test see whether a keystroke reached
# the container or was turned into a signal before it could.
open(CONTAINER, "w").write('''#!/usr/bin/env python3
import fcntl, os, signal, struct, sys, termios, tty
fd = 0
saved = termios.tcgetattr(fd)
def _size():
return struct.unpack("HHHH", fcntl.ioctl(fd, termios.TIOCGWINSZ, b"\\0" * 8))[:2]
# Redraws only when the dimensions ACTUALLY moved, which is what both shipped
# harnesses do. Measured on a pty, bytes written in a fixed window after each
# kind of poke:
#
# idle same-size SIGWINCH size changed and back
# Claude Code 0 0 3337
# Cline 0 0 17810
# nvim (control) 0 3356 6724
#
# The first version of this stand-in redrew on the bare signal, like nvim. That
# is the one behaviour the programs dejima actually runs do not have, so the
# suite could not see that the resume poke was a no-op for both of them.
_last = [_size()]
def _on_winch(*a):
now = _size()
if now == _last[0]:
return
_last[0] = now
sys.stdout.write("CONTAINER-REDRAW\\r\\n")
sys.stdout.flush()
signal.signal(signal.SIGWINCH, _on_winch)
# The finding of #179, as something a test can observe: the client survives
# SIGSTOP and does not survive SIGTSTP, so the launcher has to start it with
# SIGTSTP already ignored. Read back rather than caught - installing a handler
# would REPLACE the inherited SIG_IGN and defeat the very thing being checked,
# which is what the first version of this did.
_ign = 0
for _line in open("/proc/self/status"):
if _line.startswith("SigIgn"):
_ign = int(_line.split()[1], 16)
if _ign & (1 << (signal.SIGTSTP - 1)):
sys.stdout.write("CONTAINER-TSTP-IGNORED\\r\\n")
sys.stdout.flush()
tty.setraw(fd)
# What a full-screen harness does on startup, and does not undo on a suspend.
sys.stdout.write("\\033[?25l")
sys.stdout.write("CONTAINER-ATTACHED\\r\\n"); sys.stdout.flush()
open(%r, "wb").close()
try:
while True:
try:
b = os.read(fd, 1)
except OSError:
break
if not b:
break
with open(%r, "ab") as fh:
fh.write(b)
if b == b"\\x1a":
# What a harness that implements Ctrl-Z does, and why the byte
# reaching the container is fatal: it stops itself, and there is no
# job-control shell in a container to continue it.
termios.tcsetattr(fd, termios.TCSADRAIN, saved)
sys.stdout.write("CONTAINER-SUSPENDED-ITSELF\\r\\n"); sys.stdout.flush()
os.kill(os.getpid(), signal.SIGSTOP)
tty.setraw(fd)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, saved)
''' % (RECEIVED, RECEIVED))
# dejima's shape: background children in one process group, the helper, then
# the container run in the foreground.
# TTY_SUSPEND_NO_HELPER leaves the helper out, which is dejima before this
# existed. It is the control: a harness that cannot see the bug cannot be
# trusted to see the fix either.
start_helper = "" if os.environ.get("TTY_SUSPEND_NO_HELPER") else \
'"%s" &\nhelper=$!' % HELPER
# bin/dejima's shape: the client inherits SIGTSTP ignored and is started with
# the terminal, and the keystroke is converted into an explicit SIGSTOP for it
# before the launcher stops itself. Without the conversion the client is the
# one thing that must not be stopped by a catchable signal.
open(LAUNCHER, "w").write('''#!/bin/bash
sleep 300 &
sleep 300 &
%s
trap '' TSTP
python3 %s < /dev/tty &
client=$!
trap - TSTP
on_tstp() {
kill -STOP "$client" 2>/dev/null || true
printf '\\033[?25h' > /dev/tty 2>/dev/null || true
trap - TSTP
kill -TSTP $$
trap on_tstp TSTP
kill -CONT "$client" 2>/dev/null || true
}
trap on_tstp TSTP
while kill -0 "$client" 2>/dev/null; do
wait "$client" && break
case "$?" in 146|148) continue ;; esac
break
done
kill "${helper:-0}" 2>/dev/null || true
echo LAUNCHER-DONE
''' % (start_helper, CONTAINER))
# 77 means "no terminal to test with", which bats turns into a skip. A machine
# that cannot allocate a pty cannot answer the question either way, and a
# failure there would say something untrue about the code.
try:
master, slave = pty.openpty()
except OSError as exc:
sys.stderr.write("no pty available: %s\n" % exc)
sys.exit(77)
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 40, 100, 0, 0))
pid = os.fork()
if pid == 0:
os.setsid()
fcntl.ioctl(slave, termios.TIOCSCTTY, 0)
os.dup2(slave, 0); os.dup2(slave, 1); os.dup2(slave, 2)
if slave > 2:
os.close(slave)
os.close(master)
env = dict(os.environ, PS1="PROMPT> ")
args = [SHELL, "--norc", "-i"] if "bash" in SHELL else [SHELL, "-f", "-i"]
os.execvpe(SHELL, args, env)
os.close(slave)
seen = []
failures = []
transcript_all = ""
def pump(seconds):
end = time.time() + seconds
while time.time() < end:
r, _, _ = select.select([master], [], [], 0.05)
if r:
try:
chunk = os.read(master, 4096)
except OSError:
return
if not chunk:
return
text = chunk.decode("utf-8", "replace")
seen.append(text)
global transcript_all
transcript_all += text
def expect(text, timeout=8.0):
"""Wait for text to appear in output since the last call to forget()."""
end = time.time() + timeout
while time.time() < end:
if text in "".join(seen):
return True
pump(0.1)
return text in "".join(seen)
def forget():
del seen[:]
def send(s):
os.write(master, s.encode())
pump(0.3)
def lflag():
return termios.tcgetattr(master)[3]
def received():
try:
with open(RECEIVED, "rb") as fh:
return fh.read()
except FileNotFoundError:
return b""
def check(name, ok, detail=""):
print(("PASS " + name) if ok else ("FAIL %s %s" % (name, detail)))
if not ok:
failures.append(name)
try:
pump(1.0)
forget()
send("bash %s\n" % LAUNCHER)
check("the container run attached", expect("CONTAINER-ATTACHED"))
for cycle in (1, 2):
before = received()
forget()
send("\x1a")
stopped = expect("Stopped", 5.0) or expect("suspended", 0.5)
check("cycle%d-suspends-the-job" % cycle, stopped)
check("cycle%d-nothing-reached-the-container" % cycle,
received() == before, repr(received()[-8:]))
forget()
send("echo SHELL-ALIVE-%d\n" % cycle)
check("cycle%d-shell-usable-while-suspended" % cycle,
expect("SHELL-ALIVE-%d" % cycle, 5.0))
forget()
send("fg\n")
expect("CONTAINER-REDRAW", 5.0)
flags = lflag()
check("cycle%d-raw-mode-restored" % cycle,
not (flags & termios.ICANON) and not (flags & termios.ECHO),
"ICANON=%s ECHO=%s" % (bool(flags & termios.ICANON), bool(flags & termios.ECHO)))
check("cycle%d-redraw-poked" % cycle, "CONTAINER-REDRAW" in "".join(seen))
check("cycle%d-session-alive" % cycle, "LAUNCHER-DONE" not in "".join(seen))
before = received()
send("typed\n")
pump(0.8)
check("input-reaches-the-resumed-session", received() != before)
before = received()
send("\x03")
pump(0.8)
check("ctrl-c-still-reaches-the-session", received().endswith(b"\x03"),
repr(received()[-4:]))
# The finding, asserted: the client is stopped by SIGSTOP, which it cannot
# observe, and never by SIGTSTP, which it cannot survive.
# A suspended session must not leave the prompt with an invisible cursor:
# the harness hid it and a suspend is not an exit, so somebody has to.
check("the-cursor-is-shown-again-when-suspended",
"\033[?25h" in transcript_all,
"the prompt inherits a hidden cursor, like a dead ssh session")
check("the-client-was-started-with-sigtstp-ignored",
"CONTAINER-TSTP-IGNORED" in transcript_all,
"it would be stopped by the one signal it cannot survive")
finally:
try:
os.killpg(os.getpgid(pid), 9)
except Exception:
pass
sys.exit(1 if failures else 0)
+108
View File
@@ -112,6 +112,35 @@ dockerfile_for() {
"$LAUNCH_DIR/dockerfile-log" 2>/dev/null
}
@test "valid_domain refuses the hostname shapes a resolver would read differently" {
# The matcher is a trust boundary: what it accepts becomes a dnsmasq
# `server=` line and an ipset entry. The published bypass in this space
# came from an egress control accepting a string its resolver then read as
# a different host, so the shapes below are the ones worth asserting on
# rather than only "does it reject evil.com".
#
# Run against this matcher during the 2026-08 prior-art sweep: every case
# here was already refused except the empty label, which was not.
local bad
for bad in 'a..b.com' 'github.com.' '.github.com' '-bad.com' 'bad-.com' \
'a.-b.com' 'evil.com%00.github.com' 'a_b.com' '' \
'evil.com
github.com'; do
run bash -c 'source "$1"; valid_domain "$2"' _ "$LIB" "$bad"
[ "$status" -ne 0 ] || { echo "accepted: $(printf %q "$bad")"; return 1; }
done
# And the shapes that must keep working, including the ones the tightening
# could plausibly have broken: an IDNA label starts with a letter and
# contains hyphens, and a single label with no dot is a real internal name.
local good
for good in 'crates.io' 'static.crates.io' 'xn--e1afmkfd.com' 'GitHub.com' \
'localhost' 'a' 'llm-backend.example.com'; do
run bash -c 'source "$1"; valid_domain "$2"' _ "$LIB" "$good"
[ "$status" -eq 0 ] || { echo "refused: $good"; return 1; }
done
}
@test "valid_domain accepts real domains and rejects shell-active input" {
run bash -c 'source "$1"; valid_domain "crates.io"' _ "$LIB"
[ "$status" -eq 0 ]
@@ -506,6 +535,85 @@ EOF
[ ! -f "$CONFIG_DIR/dependencies" ]
}
# Two running sandboxes, each with a working directory, plus a dejima-attach
# that lists them in the order its picker numbers them.
stub_two_containers() {
STUB_DIR="$(mktemp -d)"
cat > "$STUB_DIR/docker" <<'EOF'
#!/bin/bash
if [ "$1" = ps ]; then
printf 'dejima-run-aaaa\taaaaaaaaaaaa\n'
printf 'dejima-run-bbbb\tbbbbbbbbbbbb\n'
exit 0
fi
if [ "$1" = inspect ]; then
case "$*" in
*aaaaaaaaaaaa*) echo /home/someone/workspace/alpha ;;
*bbbbbbbbbbbb*) echo /home/someone/workspace/beta ;;
esac
exit 0
fi
exit 0
EOF
chmod +x "$STUB_DIR/docker"
cat > "$STUB_DIR/dejima-attach" <<'EOF'
#!/bin/bash
[ "$1" = --list ] || exit 1
printf 'run1\tlabel-b\tbeta\t3m\tattachable\tbbbbbbbbbbbb\n'
printf 'run2\tlabel-a\talpha\t9m\tattachable\taaaaaaaaaaaa\n'
EOF
chmod +x "$STUB_DIR/dejima-attach"
PATH="$STUB_DIR:$PATH"
}
@test "several running sandboxes are listed by project, not by run id alone" {
# The name is a run id, which is exactly what someone with three sessions
# open does not know. What they do know is which project each is working
# on. Taken from the container's own working directory, so it is there
# whether or not remote access is on.
stub_two_containers
run "$REPO_ROOT/bin/dejima-allow" crates.io
[ "$status" -ne 0 ]
[[ "$output" == *"Multiple running dejima containers"* ]] || { echo "$output"; return 1; }
grep -qE 'dejima-run-aaaa +alpha' <<< "$output" || { echo "$output"; return 1; }
grep -qE 'dejima-run-bbbb +beta' <<< "$output" || { echo "$output"; return 1; }
rm -rf "$STUB_DIR"
}
@test "the listing carries the number the dejima-attach picker gives each one" {
# The picker numbers its rows 1..N in the order --list prints them, so a
# position there is the number an operator would type. Deliberately not
# this tool's own ordering: two lists that number the same sessions
# differently is worse than one list without numbers.
stub_two_containers
run "$REPO_ROOT/bin/dejima-allow" crates.io
# beta is first in the attach listing, so beta is 1 - not alpha, which
# docker happens to report first.
grep -qE 'beta .*\(dejima-attach 1\)' <<< "$output" || { echo "$output"; return 1; }
grep -qE 'alpha .*\(dejima-attach 2\)' <<< "$output" || { echo "$output"; return 1; }
rm -rf "$STUB_DIR"
}
@test "the listing drops the picker number rather than failing without one" {
# dejima-attach refuses outright when remote access is off. That is a
# column missing, not an error: the project name is the part that makes
# this usable and it does not depend on the feature.
stub_two_containers
cat > "$STUB_DIR/dejima-attach" <<'EOF'
#!/bin/bash
echo "Remote access is not configured." >&2
exit 1
EOF
chmod +x "$STUB_DIR/dejima-attach"
run "$REPO_ROOT/bin/dejima-allow" crates.io
[ "$status" -ne 0 ]
grep -qE 'dejima-run-aaaa +alpha' <<< "$output" || { echo "$output"; return 1; }
[[ "$output" != *"dejima-attach 1"* ]] || { echo "invented a number: $output"; return 1; }
[[ "$output" != *"Remote access is not configured"* ]] \
|| { echo "leaked the attach refusal: $output"; return 1; }
rm -rf "$STUB_DIR"
}
@test "dejima-allow --project persists without needing a container" {
run "$REPO_ROOT/bin/dejima-allow" --project "$PROJ" crates.io
[ "$status" -eq 0 ]
+279 -7
View File
@@ -5,6 +5,7 @@
setup() {
REPO_ROOT="$BATS_TEST_DIRNAME/.."
LIB="$REPO_ROOT/bin/dejima-provider-lib.sh"
LIB_PROJECT="$REPO_ROOT/bin/dejima-project-lib.sh"
CONFIG_DIR="$(mktemp -d)"
export DEJIMA_CONFIG_DIR="$CONFIG_DIR"
}
@@ -482,9 +483,14 @@ assert_remedy_forges_exist() {
}
@test "the hook substitutes only the two runtime placeholders" {
# env -u, because these run inside a dejima sandbox as often as not, and
# there the briefing-delivery variables are set - the hook then emits only
# the session id and never reads the file under test. That made this one
# fail and the next one pass for the wrong reason, its assertion being
# satisfied by the session id the stub carries anyway. See .dejima.md.
b="$(mktemp)"
printf 'session=@@SESSION_ID@@ container=@@CONTAINER_ID@@\n' > "$b"
run env DEJIMA=1 DEJIMA_BRIEFING_FILE="$b" bash -c \
run env -u DEJIMA_BRIEFING_VIA_ARGS -u DEJIMA_BRIEFING_RENDERED DEJIMA=1 DEJIMA_BRIEFING_FILE="$b" bash -c \
'printf "{\"session_id\":\"abc123\",\"cwd\":\"$PWD\"}" | bash "$1"' _ \
"$REPO_ROOT/hooks/sandbox-context.sh"
[ "$status" -eq 0 ]
@@ -507,7 +513,7 @@ assert_remedy_forges_exist() {
printf 'resume-cmd: dejima --resume @@SESSION_ID@@\ncontainer=@@CONTAINER_ID@@\n' > "$b"
# Session id crafted to test each special case in the replacement context.
# Use printf %s so the value is never interpreted by the shell.
run env DEJIMA=1 DEJIMA_BRIEFING_FILE="$b" bash -c \
run env -u DEJIMA_BRIEFING_VIA_ARGS -u DEJIMA_BRIEFING_RENDERED DEJIMA=1 DEJIMA_BRIEFING_FILE="$b" bash -c \
'printf "{\"session_id\":%s,\"cwd\":\"%s\"}" "$(printf "%s" "$1" | jq -Rs .)" "$PWD" | bash "$2"' _ \
'pr-42&rm -rf /tmp\test/path' "$REPO_ROOT/hooks/sandbox-context.sh"
[ "$status" -eq 0 ]
@@ -522,7 +528,7 @@ assert_remedy_forges_exist() {
}
@test "a missing briefing file degrades rather than failing the session" {
run env DEJIMA=1 DEJIMA_BRIEFING_FILE=/nonexistent/briefing bash -c \
run env -u DEJIMA_BRIEFING_VIA_ARGS -u DEJIMA_BRIEFING_RENDERED DEJIMA=1 DEJIMA_BRIEFING_FILE=/nonexistent/briefing bash -c \
'printf "{\"session_id\":\"x\",\"cwd\":\"$PWD\"}" | bash "$1"' _ \
"$REPO_ROOT/hooks/sandbox-context.sh"
[ "$status" -eq 0 ]
@@ -617,7 +623,7 @@ assert_remedy_forges_exist() {
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$(lib manifest_get workflow branch-push push)" = "on" ]
[ "$(lib manifest_get workflow branch-push blocked_branches)" = "main master" ]
[ "$(lib manifest_get workflow branch-push block_merge)" = "off" ]
[ "$(lib manifest_get workflow branch-push block_merge)" = "on" ]
[ "$(lib manifest_get workflow branch-push requires_worktree)" = "on" ]
[ "$(lib manifest_get workflow branch-push autonomy)" = "push-branch" ]
# Requires no capability, so it is usable against a forge that offers
@@ -765,11 +771,51 @@ assert_remedy_forges_exist() {
grep -q 'export DEJIMA_GITHUB_PUSH' "$REPO_ROOT/bin/dejima"
}
@test "no shipped workflow tells the agent not to merge without blocking it" {
# Every shipped briefing says some version of "do not merge". Two of them
# said it with block_merge off, which made the sentence an assertion
# nothing held: the forge's CLI is installed by the FORGE, so `<pr> merge`
# is on PATH whenever a forge is selected, whatever the workflow tells the
# agent to do. The manifest that was off carried a reason - "nothing here
# ever reaches a forge's PR interface" - and the reason was false.
#
# Asserted as an invariant over every workflow rather than as three pinned
# values, because a fourth workflow gets this wrong the same way. A
# workflow that genuinely means to permit merging has to say so in its
# briefing and change this test on purpose.
local wf name
for wf in "$REPO_ROOT"/workflows/*/; do
[ -d "$wf" ] || continue
name="$(basename "$wf")"
[ "$(lib manifest_get workflow "$name" block_merge)" = "on" ] \
|| { echo "$name leaves merging unguarded"; return 1; }
grep -qi 'merg' "$wf/briefing.md" \
|| { echo "$name's briefing says nothing about merging"; return 1; }
done
}
@test "a workflow blocking merges reaches the guard with the forge's command" {
# block_merge is only half of it: what actually refuses the command is
# DEJIMA_MERGE_BLOCK, resolved from the FORGE's merge_block and passed to
# docker/forge-guard.sh. A workflow with the flag on and a forge that
# names no command blocks nothing, which is correct - there is nothing to
# block - but the pairing is what has to hold.
local wf
for wf in branch-push local-only worktree-pr; do
[ "$(lib manifest_get workflow "$wf" block_merge)" = "on" ] || continue
[ -n "$(lib manifest_get forge gitea merge_block)" ] \
|| { echo "gitea names no merge command for $wf to block"; return 1; }
done
grep -q 'DEJIMA_MERGE_BLOCK="$(manifest_get forge "$DEJIMA_FORGE_NAME" merge_block)"' \
"$REPO_ROOT/bin/dejima" \
|| { echo "the launcher no longer resolves the forge's merge command"; return 1; }
}
@test "local-only pushes nothing and blocks no branches" {
run lib manifest_validate workflow local-only
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$(lib manifest_get workflow local-only push)" = "off" ]
[ "$(lib manifest_get workflow local-only block_merge)" = "off" ]
[ "$(lib manifest_get workflow local-only block_merge)" = "on" ]
[ "$(lib manifest_get workflow local-only requires_worktree)" = "off" ]
[ "$(lib manifest_get workflow local-only autonomy)" = "commit" ]
# blocked_branches is empty BECAUSE push is off - there is no push to
@@ -784,6 +830,144 @@ assert_remedy_forges_exist() {
[ -z "$output" ]
}
# --- optional tools ----------------------------------------------------------
@test "every shipped tool is pinned by digest, not fetched from a moving ref" {
# The bar docs/tools.md sets, asserted rather than described. A tool that
# installs from whatever a branch points at today is one nothing has
# reviewed, and it would build differently on different days with no
# change to this repository.
local tool spec
for tool in $(lib provider_list tool); do
run lib manifest_validate tool "$tool"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
spec="$(lib manifest_get tool "$tool" install)"
[[ "$spec" == tarball:* ]] \
|| { echo "$tool does not install from a pinned artifact: $spec"; return 1; }
# valid_tarball_spec already requires a 64-hex digest and an https
# URL; this is the part it cannot check - that the URL names a
# version rather than a branch.
[[ "$spec" != *"/refs/heads/"* && "$spec" != *"/master/"* && "$spec" != *"/main/"* ]] \
|| { echo "$tool installs from a moving ref: $spec"; return 1; }
done
}
@test "a tool cannot declare anything that changes what dejima permits" {
# The manifest has no key for a hook, a mount, a domain or a guard. A tool
# that needed one would be a change to the boundary rather than a program
# on PATH, and adding the key is the moment to argue about it - not the
# moment a manifest quietly starts carrying one.
local key
for key in guards hooks mounts api_domains fixed_hosts install_hook \
briefing no_confirm_args settings_args; do
run lib manifest_get tool rtk "$key"
[ "$status" -ne 0 ] \
|| { echo "the tool schema accepts '$key', which it must not"; return 1; }
done
}
@test "rtk declares the archive shape its release actually has" {
# Inspected: `tar tzf rtk-x86_64-unknown-linux-musl.tar.gz` lists one
# entry, `rtk`, with no enclosing directory. Declared `prefix`, the layer
# would strip that single component, extract nothing, and build cleanly -
# a tool the session is told it has and does not.
#
# Pinned here because the emitter test below exercises both shapes with an
# explicit setting and so cannot notice this manifest changing.
[ "$(lib manifest_get tool rtk install_layout)" = "binary" ] \
|| { echo "rtk's release is a bare binary and the manifest no longer says so"; return 1; }
}
@test "a tool may only set environment variables that are its own" {
# image_env becomes a Dockerfile ENV, which every process in the container
# inherits - not just the tool that declared it. rtk shipped with
# NO_COLOR=1, reasoning that its output lands in a model's context where an
# escape sequence is noise. The reasoning was fine and the scope was wrong:
# a launch with the tool enabled lost colour in the harness, in git, in ls,
# in everything.
#
# A harness may set a bare name there, because a harness IS the container's
# main process. A tool is one program among many.
local d; d="$(mktemp -d "$BATS_TEST_TMPDIR/toolenv.XXXXXX")"
mkdir -p "$d/tools/probe"
local sha; sha="$(printf 'a%.0s' {1..64})"
local bad
for bad in 'NO_COLOR=1' 'DO_NOT_TRACK=1' 'PATH=/x' 'PROBEISH_X=1'; do
printf 'description = x\ninstall = tarball:p@%s:https://e.example/p.tgz\ncommand = p\nimage_env = %s\n' \
"$sha" "$bad" > "$d/tools/probe/tool.conf"
run env DEJIMA_PROVIDER_ROOT="$d" \
bash -c 'source "$1"; tool_validate probe' _ "$LIB"
[ "$status" -ne 0 ] || { echo "accepted image_env $bad"; return 1; }
[[ "$output" == *"not its own"* ]] || { echo "$output"; return 1; }
done
# Namespaced to the tool, which is what a tool's own switch looks like.
printf 'description = x\ninstall = tarball:p@%s:https://e.example/p.tgz\ncommand = p\nimage_env = PROBE_TELEMETRY=0 PROBE_QUIET=1\n' \
"$sha" > "$d/tools/probe/tool.conf"
run env DEJIMA_PROVIDER_ROOT="$d" bash -c 'source "$1"; tool_validate probe' _ "$LIB"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
}
@test "no shipped tool takes the colour out of the rest of the container" {
# The specific regression, pinned by name as well as by the rule above:
# these are the bare names most likely to look like a tool's own business
# and reach everything.
local tool pair
for tool in $(lib provider_list tool); do
run lib tool_validate "$tool"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
for pair in $(lib manifest_get tool "$tool" image_env); do
case "${pair%%=*}" in
NO_COLOR|TERM|CLICOLOR|CLICOLOR_FORCE|FORCE_COLOR)
echo "$tool sets ${pair%%=*} for the whole container"; return 1 ;;
esac
done
done
}
@test "rtk is declared with its telemetry switched off in the image" {@test "rtk is declared with its telemetry switched off in the image" {
# Two independent offs, because either one can be wrong: the tool's own
# switch here, and the firewall, which does not allowlist the endpoint.
[[ "$(lib manifest_get tool rtk image_env)" == *"RTK_TELEMETRY_DISABLED=1"* ]] \
|| { echo "rtk's telemetry switch is no longer set"; return 1; }
# And the endpoint stays out of what core allowlists.
run grep -rn 'telemetry.rtk-ai' "$REPO_ROOT/docker" "$REPO_ROOT/bin"
[ "$status" -ne 0 ] || { echo "the telemetry endpoint reached the allowlist:"; echo "$output"; return 1; }
}
@test "a tool's archive shape decides how it is unpacked" {
# A bare-binary archive stripped of a component extracts NOTHING, and does
# it silently: the layer builds, and the tool is not in it.
local spec
spec="tarball:x@$(printf 'a%.0s' {1..64}):https://e.example/x.tgz"
run env DEJIMA_TARBALL_STRIP=binary bash -c \
'source "$1"; generate_spec_dockerfile base "$2"' _ "$LIB_PROJECT" "$spec"
[[ "$output" == *"tar -C /opt/dejima/x/bin -xaf"* ]] \
|| { echo "binary layout still strips a component: $output"; return 1; }
run env DEJIMA_TARBALL_STRIP=prefix bash -c \
'source "$1"; generate_spec_dockerfile base "$2"' _ "$LIB_PROJECT" "$spec"
[[ "$output" == *"--strip-components=1"* ]] \
|| { echo "prefix layout stopped stripping: $output"; return 1; }
# Both verify before they unpack. A tar extracted first and checked after
# has already written whatever it contained.
[[ "$output" == *"sha256sum -c -"*"tar -C"* ]] \
|| { echo "the digest is no longer checked before unpacking"; return 1; }
}
@test "the briefing names the tools a launch enabled, and says nothing when there are none" {
run env DEJIMA_TOOLS=rtk bash -c \
'source "$1"; briefing_compose none local-only claude-code' _ "$LIB"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"Extra tools this session has"* ]] || { echo "$output"; return 1; }
[[ "$output" == *"rtk git status"* ]] \
|| { echo "the tool's own instructions did not reach the briefing"; return 1; }
run env DEJIMA_TOOLS= bash -c \
'source "$1"; briefing_compose none local-only claude-code' _ "$LIB"
[[ "$output" != *"Extra tools this session has"* ]] \
|| { echo "a heading with nothing under it"; return 1; }
}
@test "the github forge pushes with its own token, not the read-only one" {
run lib manifest_validate forge github
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
@@ -794,8 +978,96 @@ assert_remedy_forges_exist() {
# would promote a deliberately read-only PAT to a pushing credential.
[ "$(lib manifest_get forge github requires)" = "push_token_file" ]
[[ "$(lib manifest_get forge github env_export)" == *"GH_TOKEN=@push_token_file"* ]]
# Ships no CLI: gh is already in the image.
[ -z "$(lib manifest_get forge github install)" ]
# gh comes from a package rather than from this repo, but it is still
# declared: the entrypoint links the merge shadow only over a command that
# is behind /usr/local/lib/dejima/forge, and refuses to start otherwise.
# This forge shipping nothing of its own is what "system:" says.
# system:gh AND the one CLI this forge ships. install-forge-bins.sh fails
# the build in both directions - a declared file that is missing, and a
# file under bin/ that no manifest declares - so this is the declaration
# an operator reads to know what lands in the image.
[ "$(lib manifest_get forge github install)" = "system:gh bin/github-pr" ]
}
@test "each forge declares which of its keys belong to a person" {
# The credential paths, which a project may not declare and an identity
# may set. One declaration drives both rules, so they cannot disagree
# about which key is which.
[ "$(lib manifest_get forge gitea identity_keys)" = "token_file" ]
[ "$(lib manifest_get forge github identity_keys)" = "push_token_file" ]
[ -z "$(lib manifest_get forge none identity_keys)" ]
# And every one of them is a key that forge actually has, or the identity
# would offer a setting nothing reads.
local forge key spec found
for forge in $(lib provider_list forge); do
for key in $(lib manifest_get forge "$forge" identity_keys); do
found=0
for spec in $(lib manifest_get forge "$forge" keys); do
[ "${spec%%:*}" = "$key" ] && found=1
done
[ "$found" -eq 1 ] \
|| { echo "$forge names identity key '$key' and has no such key"; return 1; }
done
done
}
@test "no two forges claim the same identity key name" {
# An identity's keys are unqualified, so one name resolving to two forges'
# types would make the validation depend on which forge was read first.
# Refused here rather than guessed at in _identity_key_specs.
local forge key seen=""
for forge in $(lib provider_list forge); do
for key in $(lib manifest_get forge "$forge" identity_keys); do
case " $seen " in
*" $key "*) echo "two forges declare identity key '$key'"; return 1 ;;
esac
seen="$seen $key"
done
done
}
@test "the identity key set is core's two plus whatever the forges declare" {
# ssh_key and the git name are core's, because every forge needs the same
# answer to "who is pushing". The credentials are not, and are typed by
# the forge that owns them rather than by a second copy here.
local specs
specs="$(lib _identity_key_specs)"
grep -qP '^ssh_key\t\?path$' <<< "$specs" || { echo "$specs"; return 1; }
grep -qP '^user_name\t\?text$' <<< "$specs" || { echo "$specs"; return 1; }
grep -qP '^user_email\t\?text$' <<< "$specs" || { echo "$specs"; return 1; }
grep -qP '^token_file\t\?path$' <<< "$specs" \
|| { echo "token_file did not inherit gitea's type: $specs"; return 1; }
}
@test "the github forge declares how its CLI can be aimed at another repo" {
# gh takes --repo on nearly every subcommand and reads GH_REPO, so the
# ordinary path can act on a repository the session has nothing to do
# with (#187). The guard is generic; the manifest is what makes it fire.
[ "$(lib manifest_get forge github repo_flags)" = "--repo -R" ]
[ "$(lib manifest_get forge github repo_env)" = "GH_REPO" ]
# 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)" ]
}
@test "a repo flag has to look like a flag" {
# It is compared against argv. A bare word would be indistinguishable from
# a subcommand or a value, and would refuse commands at random.
local d bad; d="$(mktemp -d "$BATS_TEST_TMPDIR/flagtype.XXXXXX")"
mkdir -p "$d/forges/probe"
for bad in "repo" "--" "-" "--repo=x"; do
printf 'repo_flags = %s\n' "$bad" > "$d/forges/probe/forge.conf"
run env DEJIMA_PROVIDER_ROOT="$d" \
bash -c 'source "$1"; manifest_validate forge probe' _ "$LIB"
[ "$status" -ne 0 ] || { echo "accepted repo_flags = $bad"; return 1; }
done
# Space-separated, like every other manifest list.
printf 'repo_flags = --repo -R\n' > "$d/forges/probe/forge.conf"
run env DEJIMA_PROVIDER_ROOT="$d" \
bash -c 'source "$1"; manifest_validate forge probe' _ "$LIB"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
}
@test "the briefing does not call GitHub read-only when the forge can push to it" {
+157
View File
@@ -0,0 +1,157 @@
#!/bin/bash
# The pre-release gate: everything that can be checked by running it, plus the
# list of what cannot, derived from what the release actually changes.
#
# Run before opening a development -> main pull request. It is deliberately not
# a CI job: half of what it produces is a list of things a person has to go and
# do, and a green tick nobody had to earn is worse than no tick.
#
# Usage: test/release-pass.sh [base] [head] (defaults: main, HEAD)
set -uo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
base="${1:-main}"
head="${2:-HEAD}"
# Guarded, because everything below is relative to it: a cd that failed
# silently would have this reporting on some other directory's suites and
# diff, which for a release gate is the worst way to be wrong.
cd "$root" || { echo "cannot enter $root" >&2; exit 1; }
case "${1:-}" in
-h|--help)
echo "Usage: test/release-pass.sh [base] [head]"
echo
echo "Runs the gates that can be run here, then prints the manual checks"
echo "the diff between base and head calls for. See docs/release.md."
exit 0 ;;
esac
status=0
note() { printf '\n== %s\n' "$1"; }
fail() { printf ' NOT OK %s\n' "$1"; status=1; }
pass() { printf ' ok %s\n' "$1"; }
note "Confined suites"
if bash test/run-confined.sh; then
pass "every confined suite"
else
fail "confined suites"
fi
note "shellcheck"
# Captured rather than re-run, and the status kept in a variable rather than
# read off $? at a distance: 127 is "not installed", which is a different thing
# from "found faults" and must not be reported as either passing or failing the
# code.
shellcheck_out="$(bash test/shellcheck.sh 2>&1)"
shellcheck_rc=$?
if [ "$shellcheck_rc" = 0 ]; then
pass "shellcheck"
elif [ "$shellcheck_rc" = 127 ]; then
printf '%s\n' "$shellcheck_out"
fail "shellcheck is not installed here - the gate did NOT run"
else
printf '%s\n' "$shellcheck_out"
fail "shellcheck found faults"
fi
note "Privileged suite"
if docker info >/dev/null 2>&1; then
if bash -c 'bats test/runtime.bats'; then
pass "test/runtime.bats"
else
fail "test/runtime.bats"
fi
else
fail "no Docker here - test/runtime.bats did NOT run"
echo " It is the only thing that exercises mounts, the firewall and"
echo " the entrypoint end to end. Run it on the host, or check that"
echo " the privileged CI job RAN rather than queued: it is pinned to"
echo " one self-hosted runner and queues silently when that is down."
fi
# --- what a person has to do, chosen by what the release touches -------------
changed="$(git diff --name-only "$base...$head" 2>/dev/null)" || changed=""
if [ -z "$changed" ]; then
note "No diff between $base and $head - nothing to release."
exit "$status"
fi
touches() { grep -qE "$1" <<< "$changed"; }
# Not a checklist to complete: an inventory of what nothing has checked. The
# gate for a release here is the security review plus the maintainer running
# the build - see docs/release.md. Printed anyway, because a release whose
# unverified paths are written down is one somebody can debug later.
note "Nothing has verified these, and this release touches them"
printed=0
manual() { printf ' - %s\n' "$1"; printed=1; }
touches '^(bin/dejima$|docker/entrypoint|docker/Dockerfile|docker/docker-compose)' &&
manual "Launch a real session. Nothing else exercises the mounts, the
credential proxy and the exit audit together."
touches '^(forges/|bin/dejima-provider-lib|docker/forge-guard)' &&
manual "Open a pull request through EVERY configured forge, and check the
body that arrived. forge = github has never been run end to end."
touches '^(docker/git-guard|hooks/git-push-guard|hooks/merge-guard|docker/forge-guard)' &&
manual "Try the thing the guard refuses, by the route an agent would take."
touches '^(bin/dejima-tty-suspend|bin/dejima$)' &&
manual "Ctrl-Z a real session and fg it back: the terminal handling is
tested against a stand-in, not against a real container run."
touches '^web/' &&
manual "Load the page on a real phone. Nothing here can see a soft keyboard."
touches '^(harnesses/|bin/harness/|docker/harness-wrapper)' &&
manual "Launch each affected harness against a real model."
touches '^(bin/dejima-attach|bin/dejima-ttyd|systemd/)' &&
manual "Attach over the web front door and detach again."
touches '^\.dejima/allowed-domains$' &&
manual "A domain here is read from main by the CI hardener and widens every
future CI job on that runner. Say in the PR what it is for."
[ "$printed" = 1 ] || echo " (none - this release touches nothing with a manual check)"
note "Required before this merges"
echo " [ ] Security review of the whole $base...$head diff, against this"
echo " project's own invariants - see docs/release.md. This is the gate."
echo " [ ] Documentation review: every changed behaviour has its doc changed,"
echo " and no doc describes something that no longer exists."
echo " [ ] The maintainer runs the build: normal usage for a while, and a"
echo " launch on the other configured harness to confirm it starts."
# A config key added in a release makes the PREVIOUS main refuse to start once
# an operator has adopted it, because an unknown key fails validation. That
# turns the obvious rollback into a second outage, so it is worth knowing
# before rather than after.
note "Config keys this release adds (a rollback refuses a config using them)"
added="$(comm -13 \
<(git show "$base:bin/dejima-config-lib.sh" 2>/dev/null | grep -oE "^[a-z-]+\\.[a-z_]+" | sort -u) \
<(grep -oE "^[a-z-]+\\.[a-z_]+" bin/dejima-config-lib.sh | sort -u))"
# Providers too, and this is the sharper half: a [harness.<name>] section for
# a harness the older dejima does not ship is an unknown SECTION, which fails
# validation exactly as an unknown key does. So adopting a new provider is
# itself the thing that makes the rollback refuse.
# The singular is spelled out rather than derived: "harnesses" does not lose
# an s to become "harness", and a manifest is named for the singular.
new_providers=""
for pair in harnesses:harness forges:forge workflows:workflow; do
axis="${pair%%:*}"
one="${pair#*:}"
[ -d "$axis" ] || continue
while IFS= read -r name; do
[ -n "$name" ] || continue
git cat-file -e "$base:$axis/$name/$one.conf" 2>/dev/null ||
new_providers="$new_providers $one.$name"
done < <(find "$axis" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort)
done
if [ -n "$added" ] || [ -n "$new_providers" ]; then
[ -z "$added" ] || printf ' %s\n' $added
[ -z "$new_providers" ] || printf ' [%s] as a config section\n' $new_providers
else
echo " (none - a rollback accepts any config this release accepts)"
fi
note "Then"
echo " Say in the release PR what was run and what was not. A privileged job"
echo " that queued is not a job that passed."
exit "$status"
+76 -4
View File
@@ -1694,11 +1694,16 @@ EOF
}
@test "an argument that is not --once is refused rather than watched forever" {
# Anything unrecognized - a typo, --help - used to fall into the watch
# loop, which for an interactive invocation looks like a hang.
# Anything unrecognized - a typo - used to fall into the watch loop, which
# for an interactive invocation looks like a hang.
#
# --help was on this list until #64, which asked every tool to explain
# itself. It is now a recognized argument that prints and exits 0, so it
# belongs to the --help test rather than here. The property this one is
# about is unchanged: an argument the tool does not know is refused.
conf_with_ntfy
stub_curl
for bad in --help --onces -x once; do
for bad in --onces -x once; do
run timeout 10 "$REPO_ROOT/bin/dejima-notify-watch" "$bad"
[ "$status" -eq 2 ]
[[ "$output" == *"usage:"* ]]
@@ -1937,6 +1942,8 @@ expect_ttyd_argv() {
printf '%s\n' --index "$(dirname "$(readlink -f "$REPO_ROOT/bin")")/web/index.html"
[ -z "${2:-}" ] || printf '%s\n' -t "fontSize=$2"
[ -z "${3:-}" ] || printf '%s\n' -t "tmuxPrefix=$3"
[ -z "${4:-}" ] || printf '%s\n' -t "focusOnTap=$4"
[ -z "${5:-}" ] || printf '%s\n' -t "buttonColors=$5"
printf '%s\n' "$(readlink -f "$REPO_ROOT/bin")/dejima-attach"
}
# Sentinel on both sides: both are command substitutions, which strip trailing
@@ -2226,6 +2233,70 @@ PY
assert_ttyd_argv 12345 20 C-Space
}
@test "ttyd_button_colors reaches ttyd, and unset sends nothing so the page decides" {
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
# Unset is not "off": the page colours the toolbar unless told not to, and
# a host-wide "off" written here would be a different thing from silence.
write_remote_config 'ttyd_port = 12345'
stub_ttyd
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -eq 0 ]
run grep -c 'buttonColors' "$STUB_DIR/ttyd-argv"
[ "$output" = "0" ]
local value
for value in on off; do
: > "$STUB_DIR/ttyd-argv"
write_remote_config 'ttyd_port = 12345' "ttyd_button_colors = $value"
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -eq 0 ]
assert_ttyd_argv 12345 '' '' '' "$value"
done
# Alongside every other -t knob, since they share one splice.
: > "$STUB_DIR/ttyd-argv"
write_remote_config 'ttyd_port = 12345' 'ttyd_font_size = 20' \
'ttyd_tmux_prefix = C-a' 'ttyd_focus_on_tap = on' 'ttyd_button_colors = off'
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -eq 0 ]
assert_ttyd_argv 12345 20 C-a on off
}
@test "ttyd_focus_on_tap reaches ttyd, and unset sends nothing so the page decides" {
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
# Unset is not "off". The page picks from the pointer type, which is a
# property of the client and not of the host, so a host-wide default here
# would be wrong for whichever half of the clients it did not match.
write_remote_config 'ttyd_port = 12345'
stub_ttyd
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -eq 0 ]
run grep -c 'focusOnTap' "$STUB_DIR/ttyd-argv"
[ "$output" = "0" ]
# Both settings, spelled the way the config's bool type produces them -
# "off" has to survive the trip, and an is-it-empty test would drop it.
local value
for value in on off; do
: > "$STUB_DIR/ttyd-argv"
write_remote_config 'ttyd_port = 12345' "ttyd_focus_on_tap = $value"
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -eq 0 ]
assert_ttyd_argv 12345 '' '' "$value"
done
# Alongside the other -t knobs, since they share one splice and the wrong
# order would still leave each of them present.
: > "$STUB_DIR/ttyd-argv"
write_remote_config 'ttyd_port = 12345' 'ttyd_font_size = 20' \
'ttyd_tmux_prefix = C-a' 'ttyd_focus_on_tap = on'
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -eq 0 ]
assert_ttyd_argv 12345 20 C-a on
}
@test "dejima-ttyd refuses a ttyd_tmux_prefix it cannot parse rather than launching with one" {
# The page refuses an unparseable prefix too, and says so - but it says so
# on the phone, hours later, to whoever happens to press PREFIX. Refusing
@@ -2846,7 +2917,8 @@ remote_doc() {
# config schema (_config_table in bin/dejima-config-lib.sh). Pinned here
# rather than derived so this still catches the doc losing one of them.
local keys key
keys='enabled ntfy_url base_url ttyd_port ttyd_font_size ttyd_tmux_prefix'
keys='enabled ntfy_url base_url ttyd_port ttyd_font_size ttyd_tmux_prefix
ttyd_focus_on_tap ttyd_button_colors'
for key in $keys; do has -F "\`$key\`"; done
# The port default, taken from the library so the two cannot drift.
+125 -1
View File
@@ -37,6 +37,18 @@ bats_require_minimum_version 1.5.0
# The `dejima-test-` prefix is this suite's own namespace. Nothing else may use
# it, which is what makes the reclaim in setup_file safe.
# The cap_add list from the compose file, one per line. A minimal reader for
# one known block rather than a YAML parser: the alternative is this suite
# hardcoding a second copy of the capability floor and the two disagreeing
# silently, which is exactly the failure the floor exists to prevent.
compose_cap_add() {
awk '
/^ cap_add:/ { in_block = 1; next }
in_block && /^ - / { sub(/^ - /, ""); print; next }
in_block && !/^ / { in_block = 0 }
' "$BATS_TEST_DIRNAME/../docker/docker-compose.yml"
}
IMAGE=dejima:latest
# What a session actually runs: the base image plus the selected harness's own
@@ -330,8 +342,18 @@ setup_file() {
# container started without these gets none of them. Read from the
# manifest rather than written here, so this fixture cannot drift from
# what a real launch supplies.
# The capability posture from docker-compose.yml rather than a copy of it.
# This container is started with `docker run`, so a hand-written --cap-add
# list here would drift from the compose file the moment either changed -
# and the whole point of the floor is that the container a session actually
# gets is the one under test.
local -a caps=()
while IFS= read -r cap; do caps+=(--cap-add="$cap"); done < <(compose_cap_add)
[ "${#caps[@]}" -gt 0 ] || { echo "no cap_add found in docker-compose.yml" >&2; return 1; }
docker run -d --name "$container" \
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--cap-drop=ALL "${caps[@]}" \
--security-opt=no-new-privileges:true \
--network "$network" \
-e SANDBOX_TEST_MODE=1 \
-e GH_TOKEN="${GH_TOKEN:-}" \
@@ -525,6 +547,25 @@ dexec_node() {
[[ "$output" == *"policy DROP"* ]]
}
@test "the IPv6 block is a filtered family, not an absent one" {
# The DROP policy above is the good path. This asserts the container is
# actually ON that path rather than on the fallback, because the two are
# indistinguishable from the outside: a container with no IPv6 at all also
# reaches nothing over IPv6.
#
# If ip6tables ever stops working as root here, the test above fails and
# this one says why - and on a host that takes the fallback branch,
# init-firewall.sh now either turns the family off or refuses to start,
# rather than logging a conclusion it had not checked.
run dexec sh -c 'ip6tables -L >/dev/null 2>&1 && echo usable'
[[ "$output" == *usable* ]] \
|| { echo "ip6tables is not usable as root; the fallback branch is live"; return 1; }
# And the fallback's own condition is readable, so a failure here can be
# diagnosed without a second run: what does the kernel say is up?
run dexec sh -c 'cat /proc/net/if_inet6 2>/dev/null || echo "(no /proc/net/if_inet6)"'
[ "$status" -eq 0 ]
}
@test "a non-allowlisted domain is refused by the DNS proxy, not just left to fail later" {
run dexec dig definitely-not-on-the-allowlist.example.org
[[ "$output" == *"status: REFUSED"* ]]
@@ -1034,6 +1075,69 @@ dexec_node() {
run ! dexec_node sudo -n true
}
@test "the container runs with no path back up to more privilege" {
# no-new-privileges in docker-compose.yml. gosu drops privilege rather
# than gaining it, so this does not interfere with the session starting -
# what it forbids is a setuid binary or a file capability raising
# privileges on exec, which is the shape any escalation from the session
# user would have to take.
#
# Asserted on PID 1 and on the session user, because the flag is a property
# of the process tree and both halves of it matter.
run dexec cat /proc/1/status
[ "$status" -eq 0 ]
[[ "$output" == *$'NoNewPrivs:\t1'* ]] \
|| { echo "PID 1 can still gain privileges on exec"; return 1; }
run dexec_node sh -c 'grep NoNewPrivs /proc/self/status'
[[ "$output" == *"1"* ]] || { echo "the session user's shell can: $output"; return 1; }
}
@test "the capability bounding set is the declared floor and nothing more" {
# cap_drop: ALL plus an explicit list, rather than Docker's default set
# plus two. The entrypoint uses six capabilities during its root phase and
# the default carries about fourteen; this asserts the difference is real
# in the running container rather than only present in the compose file.
#
# CapBnd, not CapEff: the effective set is zero for the session user
# already (the test below), and the bounding set is what says the rest was
# never available to regain.
run dexec cat /proc/1/status
[ "$status" -eq 0 ]
local bnd; bnd=$(echo "$output" | awk '/^CapBnd:/ {print $2}')
[ -n "$bnd" ] || { echo "no CapBnd in /proc/1/status"; return 1; }
# Decoded by name so a failure says which capability, not which bit.
local have; have="$(dexec capsh --decode="$bnd" 2>/dev/null)" || have=""
[ -n "$have" ] || skip "capsh not available in the image to decode $bnd"
local cap
for cap in $(compose_cap_add); do
[[ "$have" == *"cap_$(echo "$cap" | tr '[:upper:]' '[:lower:]')"* ]] \
|| { echo "declared $cap is missing from the bounding set: $have"; return 1; }
done
# Not KILL: `dejima-allow --container` signals dnsmasq, which runs as its
# own user, so root here needs it. That absence is what failed the first
# privileged run of this change.
for cap in mknod setfcap setpcap sys_chroot audit_write net_bind_service sys_admin fsetid; do
[[ "$have" != *"cap_$cap"* ]] \
|| { echo "cap_$cap is in the bounding set and nothing declares it: $have"; return 1; }
done
}
@test "root can still signal dnsmasq, which is what adding a domain mid-session needs" {
# Named for the thing rather than left to be caught by the ipset tests,
# which is how it WAS caught: the first version of the capability floor
# dropped KILL, and `dejima-allow --container` failed with "Operation not
# permitted" because dnsmasq runs as its own user (`user=dnsmasq`) and root
# cannot signal another user's process without CAP_KILL.
#
# Adding a domain to a running session is a documented feature that every
# session briefing tells the agent to ask for, so this asserts the
# capability by exercising what needs it.
run dexec sh -c 'kill -HUP "$(cat /var/run/dnsmasq-sandbox.pid)"'
[ "$status" -eq 0 ] \
|| { echo "root cannot signal dnsmasq - is KILL in the floor? $output"; return 1; }
}
@test "the real running node process has NET_ADMIN/NET_RAW stripped, not just the bounding set" {
run dexec cat /proc/1/status
[ "$status" -eq 0 ]
@@ -1086,6 +1190,26 @@ EOF'
[[ "$output" == *"PLAYWRIGHT_LAUNCH_OK"* ]]
}
@test "a global package imports as ESM too, from a directory of its own" {
# The test above proves the browser works, in the module system that
# already resolved. NODE_PATH covers require() and nothing else: ESM walks
# up from the importing file, so the modern form failed everywhere while
# this suite stayed green. What an agent got was ERR_MODULE_NOT_FOUND,
# which reads as an image without a browser.
#
# Written somewhere with no node_modules of its own, because anywhere
# inside a project with one would resolve for the wrong reason.
dexec_node bash -c 'mkdir -p /tmp/esm-probe && cat > /tmp/esm-probe/probe.mjs <<'"'"'EOF'"'"'
import { chromium } from "playwright";
const browser = await chromium.launch({});
await browser.close();
console.log("ESM_IMPORT_OK");
EOF'
run dexec_node node /tmp/esm-probe/probe.mjs
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"ESM_IMPORT_OK"* ]] || { echo "$output"; return 1; }
}
@test "the firewall applies to Playwright's browser traffic, not just curl/git" {
# The whole point of pre-baking the browser at build time is that a
# session never needs new network access for it - confirm the egress
+23 -1
View File
@@ -3,7 +3,7 @@
# and by test/static.bats, so the two cannot disagree about what is covered.
#
# The list is discovered, not enumerated: a script added under bin/, hooks/,
# docker/ or a forge's bin/ is covered the day it lands, which is the only
# docker/, test/ or a forge's bin/ is covered the day it lands, which is the only
# version of this that stays true. Discovery is by shebang rather than by
# extension, because most of this repo's scripts have no extension.
#
@@ -19,6 +19,12 @@ list_scripts() {
ls bin/dejima bin/dejima-* bin/harness/*.sh 2>/dev/null || true
ls hooks/*.sh docker/*.sh install.sh 2>/dev/null || true
ls forges/*/bin/* 2>/dev/null || true
# test/, for the reason .dejima.md gives for being careful what goes
# there: CI checks the commit out on a host and runs it, so a helper
# in a test file is code executing outside the sandbox. That makes
# these the last scripts that should go unchecked, not the first -
# and leaving them out hid a real fault in the release gate itself.
ls test/*.sh 2>/dev/null || true
} | sort -u | while read -r f; do
[ -f "$f" ] || continue
# Harness plugins are sourced, not executed, so they carry no shebang
@@ -40,6 +46,22 @@ mapfile -t scripts < <(list_scripts)
[ "${#scripts[@]}" -gt 0 ] || { echo "found no shell scripts to check" >&2; exit 1; }
cd "$root"
# Said plainly rather than left to `exec` to fail with "not found", which reads
# as a broken script rather than a missing tool - and, on a nonzero exit nobody
# reads closely, as a check that ran.
if ! command -v shellcheck >/dev/null 2>&1; then
echo "shellcheck is not installed, so nothing was checked." >&2
echo >&2
echo "It is declared in .dejima/dependencies. In a sandbox that predates" >&2
echo "that declaration, approve it and relaunch so the image is rebuilt:" >&2
echo >&2
echo " dejima-project approve" >&2
echo >&2
echo "Report the suite as not run rather than as passing." >&2
exit 127
fi
# -S warning: info and style are largely taste, and a check whose output gets
# skimmed is not a check. Severities and the disabled classes are in
# .shellcheckrc, beside the reasoning for each.
+1239 -30
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
`rtk` is available, and reading less of a command's output is usually free:
it runs the real command and prints a compressed form of what came back.
Prefix a command with it where the full output is not the point.
rtk git status rtk git diff rtk git log -n 20
rtk ls . rtk find "*.py" . rtk grep "pattern" .
rtk cargo test rtk pytest rtk go test
It runs the real binary through PATH, so every guard that applies to a
command applies to it wrapped in rtk too. `rtk proxy <cmd>` runs something
unfiltered when you need the whole output, and `rtk read <file>` is a
structure-first view of a file rather than the whole body.
Use judgement rather than wrapping everything: a test run you are about to
debug line by line, or a diff you are about to apply, is output you want in
full.
+43
View File
@@ -0,0 +1,43 @@
# rtk - a CLI proxy that compresses command output before an agent reads it.
# https://github.com/rtk-ai/rtk, Apache-2.0, a single static Rust binary.
#
# The first optional tool, and the shape every later one has to follow: dejima
# does not install what a project asks for, it installs what it has hardened.
# See docs/tools.md for what that means and what was checked here.
#
# What it is NOT: rtk makes no model calls of its own. It is a deterministic
# output filter - `rtk git status` runs git and prints less of the result - so
# nothing here sends code anywhere.
description = compresses command output before it reaches the model
# The published release, pinned by digest. Deliberately not the project's
# `curl | sh` installer, which fetches whatever master points at today: an
# image build that produces a different binary each time is one nothing has
# reviewed. The digest was taken from the artifact below and checked against a
# second download.
#
# x86_64 only, which is issue #97's limitation and not this tool's: the
# tarball spec names one architecture and an arm64 host cannot use this entry.
install = tarball:rtk@c4c036fbf181fc55ef329786c8c17e0d427972b053b825944d968a6aafef1ba4:https://github.com/rtk-ai/rtk/releases/download/v0.45.0/rtk-x86_64-unknown-linux-musl.tar.gz
# The archive is one bare executable with no enclosing directory, where a Go
# or nvim release has everything under one. Stripping a component would strip
# the binary itself and extract nothing.
install_layout = binary
# rtk carries telemetry the README does not mention: an https ping compiled
# into the release binary, gated on a consent flag that defaults to unset.
# Disabled here as well, and the firewall does not allowlist the endpoint, so
# it is off three ways. Verified in the shipped artifact's strings, not only
# in the source - see docs/tools.md.
#
# This carried NO_COLOR=1 as well, reasoning that rtk's output lands in a
# model's context where an escape sequence is noise. The reasoning was fine
# and the mechanism was wrong: a tool's image_env becomes a Dockerfile ENV,
# which every process in the container inherits, so it took the colour out of
# the harness, git, ls and everything else. A tool may only set variables that
# are its own, and core refuses one that is not.
image_env = RTK_TELEMETRY_DISABLED=1
command = rtk
instructions = instructions.md
+2
View File
@@ -29,6 +29,7 @@ const CLIENT_SOURCES = [
'src/mouse.js',
'src/gestures.js',
'src/viewport.js',
'src/keyboard.js',
'src/toolbar.js',
'src/main.js'
];
@@ -43,6 +44,7 @@ const MANIFEST_SOURCES = [
'package-lock.json',
'src/config.js',
'src/gestures.js',
'src/keyboard.js',
'src/keys.js',
'src/main.js',
'src/modes.js',
+372 -21
View File
@@ -10,7 +10,7 @@
<meta name="color-scheme" content="dark">
<meta name="mobile-web-app-capable" content="yes">
<title>dejima</title>
<!-- Generated by web/build.mjs - do not edit. Sources: 73ce49c22acbea7ada626f21ffdc2a04ff7e002f28ff45a97457c677f57d4b3e -->
<!-- Generated by web/build.mjs - do not edit. Sources: f8a5205278ef77485e319cdefff6cbeeb027f201ab27266b02703a9eb570c608 -->
<style>
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
@@ -344,30 +344,96 @@ body.touch #terminal {
-webkit-touch-callout: none;
}
/* gruvbox dark, the same values bin/dejima-attach uses for the session
picker, each tagged with its upstream color.table name so a wrong hex is
catchable by reading. The two sit either side of one attach: picker, then
terminal. They should not look like different programs.
https://github.com/gruvbox-community/gruvbox-contrib/blob/master/color.table */
:root {
--gb-dark0-hard: #1d2021;
--gb-dark0: #282828;
--gb-dark1: #3c3836;
--gb-dark2: #504945;
--gb-light1: #ebdbb2;
--gb-orange: #fe8019; /* bright_orange, the wordmark */
--gb-yellow: #fabd2f; /* bright_yellow */
--gb-green: #b8bb26; /* bright_green */
--gb-blue: #83a598; /* bright_blue */
/* What the toolbar is made of, in tiers rather than in colours, so that
turning colour off is one block of overrides at the bottom of this file
and not an edit to every rule.
The tiers are an attention hierarchy, and the order is not arbitrary: KBD
is how you type at all on a phone, so it is the one button that must be
findable without looking for it. Enter is the key a session asks for most.
The window and arrow keys are movement - reached for often, but never the
thing you are hunting for - so they are marked out as a group without
being filled. Everything else is furniture. */
--key-fg: var(--gb-light1);
--key-bg: var(--gb-dark1);
--key-border: var(--gb-dark2);
--key-active-bg: var(--gb-dark2);
--key-bar-bg: var(--gb-dark0-hard);
--key-bar-border: var(--gb-dark2);
--key-primary-fg: var(--gb-dark0-hard);
--key-primary-bg: var(--gb-orange);
--key-primary-border: var(--gb-orange);
--key-secondary-fg: var(--gb-dark0-hard);
--key-secondary-bg: var(--gb-green);
--key-secondary-border: var(--gb-green);
/* Outlined, not coloured-in: bright_blue as text on dark1 measures 4.31:1,
which is under the 4.5:1 an unenlarged label needs, and the label is the
part that has to survive a phone in sunlight. The border carries the tier
and the text stays at 8.45:1. */
--key-nav-fg: var(--gb-light1);
--key-nav-border: var(--gb-blue);
--key-armed-fg: var(--gb-dark0-hard);
--key-armed-bg: var(--gb-blue);
--key-locked-fg: var(--gb-dark0-hard);
--key-locked-bg: var(--gb-yellow);
}
#toolbar {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px 4px calc(4px + env(safe-area-inset-bottom));
background: #14161a;
border-top: 1px solid #2a2f37;
background: var(--key-bar-bg);
border-top: 1px solid var(--key-bar-border);
touch-action: manipulation;
}
/* Flex rather than a grid with a column count in it. The count was 7, which
was the number of buttons at the time - adding one wrapped the row and broke
both. Equal-basis flex children divide the row however many there are, so a
button added later needs no change here. */
#toolbar .row {
display: grid;
grid-template-columns: repeat(7, 1fr);
display: flex;
gap: 4px;
}
#toolbar .row > button {
flex: 1 1 0;
min-width: 0;
}
#toolbar button {
min-height: 40px;
padding: 0 2px;
font: 600 13px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: #d7dce3;
background: #232830;
border: 1px solid #343b45;
/* Clamped rather than fixed: eight buttons on a narrow phone leave about
forty pixels each, and the longest label is six characters. The lower
bound is where a monospace PREFIX still fits; the upper is the size this
has always been, so a wide screen is unchanged. */
font: 600 clamp(10px, 2.8vw, 13px)/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: var(--key-fg);
background: var(--key-bg);
border: 1px solid var(--key-border);
border-radius: 6px;
-webkit-user-select: none;
user-select: none;
@@ -375,21 +441,79 @@ body.touch #terminal {
}
#toolbar button:active {
background: #39414d;
background: var(--key-active-bg);
}
/* The hierarchy. Filled-warm beats filled-cool beats outlined beats plain,
which is a ramp that survives being looked at quickly and in sunlight -
the two conditions a phone toolbar is actually used in.
These sit ABOVE the latch rules on purpose. A latched CTRL and a tiered
button match at the same specificity, so what decides is source order, and
the state a button is IN has to beat the job it does. */
#toolbar button[data-action="kbd"] {
color: var(--key-primary-fg);
background: var(--key-primary-bg);
border-color: var(--key-primary-border);
}
#toolbar button[data-action="enter"] {
color: var(--key-secondary-fg);
background: var(--key-secondary-bg);
border-color: var(--key-secondary-border);
}
#toolbar button[data-action="prev-window"],
#toolbar button[data-action="next-window"],
#toolbar button[data-action="left"],
#toolbar button[data-action="down"],
#toolbar button[data-action="up"],
#toolbar button[data-action="right"] {
color: var(--key-nav-fg);
border-color: var(--key-nav-border);
}
/* Armed for one key, versus latched on. Two states on one button, so the
distinction has to be visible without a legend. */
#toolbar button.is-armed {
color: #0b0d10;
background: #7fb3ff;
border-color: #7fb3ff;
color: var(--key-armed-fg);
background: var(--key-armed-bg);
border-color: var(--key-armed-bg);
}
#toolbar button.is-locked {
color: #0b0d10;
background: #ffcc66;
border-color: #ffcc66;
color: var(--key-locked-fg);
background: var(--key-locked-bg);
border-color: var(--key-locked-bg);
}
/* Every colour the toolbar had before it had a palette. One block, because
the alternative is an override per rule and a way for the two to drift -
an operator who turns colour off is asking for what was here, not for a
second scheme to maintain. */
body.mono-keys {
--key-fg: #d7dce3;
--key-bg: #232830;
--key-border: #343b45;
--key-active-bg: #39414d;
--key-bar-bg: #14161a;
--key-bar-border: #2a2f37;
--key-primary-fg: #d7dce3;
--key-primary-bg: #232830;
--key-primary-border: #343b45;
--key-secondary-fg: #d7dce3;
--key-secondary-bg: #232830;
--key-secondary-border: #343b45;
--key-nav-fg: #d7dce3;
--key-nav-border: #343b45;
--key-armed-fg: #0b0d10;
--key-armed-bg: #7fb3ff;
--key-locked-fg: #0b0d10;
--key-locked-bg: #ffcc66;
}
#toast {
@@ -420,11 +544,13 @@ body.touch #terminal {
<button type="button" data-action="down" aria-label="Down">&#8595;</button>
<button type="button" data-action="up" aria-label="Up">&#8593;</button>
<button type="button" data-action="right" aria-label="Right">&#8594;</button>
<button type="button" data-action="enter" aria-label="Enter">&#9166;</button>
</div>
<div class="row">
<button type="button" data-action="prefix">PREFIX</button>
<button type="button" data-action="prev-window" aria-label="Previous window">&#9664;W</button>
<button type="button" data-action="next-window" aria-label="Next window">W&#9654;</button>
<button type="button" data-action="sessions" aria-label="Back to the session picker">SESS</button>
<button type="button" data-action="zoom">ZOOM</button>
<button type="button" data-action="drag">DRAG</button>
<button type="button" data-action="paste">PASTE</button>
@@ -730,7 +856,7 @@ body.touch #terminal {
// an xterm.js option and is handed to the terminal untouched, which is how
// the existing TTYD_FONT_SIZE knob keeps working with no code here knowing
// about it. isWindows is ttyd's own, always present, and not an xterm option.
var PAGE_KEYS = ['tmuxPrefix'];
var PAGE_KEYS = ['tmuxPrefix', 'focusOnTap', 'buttonColors'];
var IGNORED_KEYS = ['isWindows'];
// "C-b" -> "\x02", the byte tmux is actually listening for. Deliberately
@@ -770,12 +896,71 @@ body.touch #terminal {
return !!coarsePointer;
}
// Whether tapping the terminal should raise the soft keyboard.
//
// Off by default where the pointer is coarse, which is the whole reason this
// exists: on a phone, tapping is how you scroll and select, and every one of
// those taps used to summon the keyboard and cost a second tap to dismiss.
// The KBD button is the deliberate way to ask for it. On a fine pointer there
// is no soft keyboard to raise and focus-on-click is simply what a terminal
// does, so nothing changes there.
//
// ?focus=1 / ?focus=0 override per client, and focusOnTap in ttyd's
// preferences overrides for every client, the same shape as the toolbar
// switch above.
function focusOnTapWanted(search, coarsePointer, preference) {
var params = new URLSearchParams(search || '');
var value = params.get('focus');
if (value === '1') return true;
if (value === '0') return false;
// "on"/"off" first, because that is dejima's own bool spelling and what
// ttyd_focus_on_tap actually sends. The others are accepted because a
// preference can also arrive from anything that writes ttyd -t directly.
if (preference === 'on' || preference === true || preference === 'true' ||
preference === '1') return true;
if (preference === 'off' || preference === false || preference === 'false' ||
preference === '0') return false;
return !coarsePointer;
}
// Whether the toolbar is colour-coded by what each button is for.
//
// On unless the operator says otherwise: the hierarchy is the point of it,
// and a toolbar where every key looks the same is one where the key you
// need most is found by counting positions. "off" restores exactly the grey
// it had before, for a terminal colour scheme this clashes with or an
// operator who would rather it were quiet.
function buttonColorsWanted(preference) {
if (preference === 'off' || preference === false || preference === 'false' ||
preference === '0') return false;
return true;
}
// The address of the session picker: this page with the run-id dropped.
//
// ttyd passes ?arg=<run-id> to dejima-attach as argv (--url-arg), so a page
// loaded without it runs dejima-attach with no argument, which is the picker.
// Every other parameter is kept, because they configure this page rather than
// the command - losing ?toolbar=1 on the way back would be a surprise.
//
// Returns a search string, "" when nothing is left, so a caller can build a
// URL without knowing whether to write the "?".
function pickerSearch(search) {
var params = new URLSearchParams(search || '');
params.delete('arg');
var rest = params.toString();
return rest ? '?' + rest : '';
}
D.config = {
DEFAULT_TMUX_PREFIX: DEFAULT_TMUX_PREFIX,
PAGE_KEYS: PAGE_KEYS,
parsePrefix: parsePrefix,
splitPreferences: splitPreferences,
toolbarWanted: toolbarWanted
toolbarWanted: toolbarWanted,
focusOnTapWanted: focusOnTapWanted,
buttonColorsWanted: buttonColorsWanted,
pickerSearch: pickerSearch
};
})((globalThis.DEJIMA = globalThis.DEJIMA || {}));
@@ -1280,6 +1465,114 @@ body.touch #terminal {
D.viewport = { createViewport: createViewport };
})((globalThis.DEJIMA = globalThis.DEJIMA || {}));
// Whether a tap on the terminal may raise the soft keyboard.
//
// xterm.js focuses its textarea whenever the terminal is clicked, which on a
// desktop is exactly right and on a phone is not: tapping is also how you
// scroll and select, so every one of those taps summons the keyboard and costs
// a second tap to put it away. The keyboard is asked for explicitly instead,
// with the toolbar's KBD button.
//
// Done by letting focus land and dropping it, rather than by intercepting the
// tap. Focus arrives from several paths inside xterm.js - pointer, selection,
// programmatic - and catching each would mean knowing about each. Selection and
// scrolling are untouched, and the toolbar's own keys never go through the
// textarea, so they keep working with the keyboard down.
(function (D) {
'use strict';
// How much of the screen something has to cover before it is a keyboard
// rather than an address bar sliding into view.
var KEYBOARD_MIN_PX = 120;
// opts: {term, element, visualViewport, layoutHeight(), defer(fn)}
function createKeyboardPolicy(opts) {
// Both start permissive, so a page that never calls setRaiseOnTap behaves
// exactly as a terminal always has.
var raiseOnTap = true;
var allowed = true;
var keyboardWasUp = false;
var defer = opts.defer || function (fn) { setTimeout(fn, 0); };
var layoutHeight = opts.layoutHeight || function () { return window.innerHeight; };
function textarea() { return opts.term.textarea || null; }
// "The keyboard should stay down": taps do not raise it, and nobody has
// asked for it since it last went away.
function locked() { return !raiseOnTap && !allowed; }
function dropFocus() {
var el = textarea();
if (el && locked()) el.blur();
}
// Focus alone is not enough to hang this on. Tapping a textarea that is
// ALREADY the focused element raises the keyboard again and fires no focus
// event, and there are two ordinary ways to arrive at a focused textarea
// with the keyboard down: the terminal is focused when the connection
// opens, and a keyboard dismissed by a system gesture leaves focus where it
// was. Both were exactly the reported bug. So the tap is watched too, and
// the blur deferred to let xterm's own focus call happen first.
function onPointerDown() {
if (locked()) defer(dropFocus);
}
// Dismissing the keyboard means the next tap should not bring it back, so
// a blur re-locks. This is also how a dropped focus feeds back in.
function onBlur() { allowed = false; }
// A keyboard dismissed with the system's own gesture does not blur the
// element it belongs to, so KBD would otherwise unlock things for good.
// The visual viewport is the only thing that sees a keyboard at all: it is
// covered by one rather than resized by it.
function onViewportResize() {
var vv = opts.visualViewport;
if (!vv) return;
var up = (layoutHeight() - vv.height) > KEYBOARD_MIN_PX;
if (keyboardWasUp && !up && !raiseOnTap) {
allowed = false;
var el = textarea();
if (el) el.blur();
}
keyboardWasUp = up;
}
// Listeners are attached once, and the policy they consult is a variable.
// Re-attaching per policy change would stack duplicates, since preferences
// arrive again on every reconnect.
function attach() {
var el = textarea();
if (el) {
el.addEventListener('focus', dropFocus);
el.addEventListener('blur', onBlur);
}
if (opts.element) opts.element.addEventListener('pointerdown', onPointerDown);
if (opts.visualViewport) opts.visualViewport.addEventListener('resize', onViewportResize);
}
// Called at startup with the pointer-type default and again when ttyd's
// preferences arrive. Dropping focus here is what handles the terminal
// being focused already, which it is by the time preferences land.
function setRaiseOnTap(wanted) {
raiseOnTap = !!wanted;
allowed = raiseOnTap;
if (!raiseOnTap) dropFocus();
}
return {
attach: attach,
setRaiseOnTap: setRaiseOnTap,
raisesOnTap: function () { return raiseOnTap; },
// The one deliberate request for the keyboard, so the one thing that
// unlocks it.
allow: function () { allowed = true; }
};
}
D.keyboard = { createKeyboardPolicy: createKeyboardPolicy, KEYBOARD_MIN_PX: KEYBOARD_MIN_PX };
})((globalThis.DEJIMA = globalThis.DEJIMA || {}));
// The toolbar: keys a phone keyboard does not have, and the two tmux windows
// buttons that replace a status-bar entry a few pixels wide.
//
@@ -1287,6 +1580,8 @@ body.touch #terminal {
// whole reason tapping a button neither moves focus nor dismisses the soft
// keyboard - the default action of a pointerdown on a button is to focus it,
// which blurs the terminal's textarea and takes the keyboard down with it.
// Requires config.js, for the picker address the SESS button navigates to.
(function (D) {
'use strict';
@@ -1389,7 +1684,24 @@ body.touch #terminal {
refreshLatches();
},
paste: paste,
// Carriage return, not a newline. A terminal's Enter key is CR; sending
// LF here would submit nothing in a shell and insert a line in an editor.
enter: function () { opts.sendText('\r'); },
// Back to dejima-attach with no run-id, which is the picker. A full
// navigation rather than anything cleverer: the run-id reaches the
// command through ttyd's --url-arg on the WebSocket URL, so the only way
// to run a different one is a new connection.
//
// Safe for the session being left. dejima-attach gives each client its
// own tmux session grouped onto the real one and sets destroy-unattached
// on THAT, so detaching reaps the view and leaves the sandbox pane alone.
sessions: function () {
if (opts.navigate) opts.navigate(D.config.pickerSearch(opts.search()));
},
kbd: function () {
// The one deliberate request for the keyboard, so it is the one place
// that unlocks it - see focusOnTapWanted.
if (opts.allowKeyboard) opts.allowKeyboard();
opts.term.focus();
if (opts.term.textarea) opts.term.textarea.focus();
}
@@ -1485,6 +1797,30 @@ body.touch #terminal {
var viewport = D.viewport.createViewport({ app: app, onFit: fitNow });
// Whether this is a touch device, which decides both the toolbar below and
// the keyboard policy that follows. Probed here rather than beside its first
// use because applyPreferences needs it, and that runs on a message from
// ttyd - later in wall-clock time, but easier to reason about declared
// before the function that reads it.
var coarsePointer = !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches);
var keyboard = D.keyboard.createKeyboardPolicy({
term: term,
element: terminalHost,
visualViewport: window.visualViewport,
layoutHeight: function () { return window.innerHeight; }
});
keyboard.attach();
// Decided at startup from the pointer type, and again below when ttyd's
// preferences arrive. Both are needed: the connection opens before the
// preferences do, and what happens on open depends on this.
function tapFocusPolicy(preference) {
keyboard.setRaiseOnTap(D.config.focusOnTapWanted(
window.location.search, coarsePointer, preference));
}
tapFocusPolicy(undefined);
function applyPreferences(prefs) {
var split = D.config.splitPreferences(prefs);
Object.keys(split.terminal).forEach(function (key) {
@@ -1505,6 +1841,17 @@ body.touch #terminal {
}
prefixByte = parsed;
// ttyd sends preferences after the connection opens, so a focusOnTap set
// server-side always arrives later than the startup decision and has to be
// able to change it.
tapFocusPolicy(split.page.focusOnTap);
// A class rather than per-button styling: what the page decides is
// whether there is a palette at all, and the stylesheet owns which button
// gets which part of it.
document.body.classList.toggle(
'mono-keys', !D.config.buttonColorsWanted(split.page.buttonColors));
// A font size arriving in the preferences changes the cell size, so the
// column count computed before it was applied is now wrong.
viewport.refresh();
@@ -1519,7 +1866,9 @@ body.touch #terminal {
onStatus: function (status) {
if (status === 'open') {
clearNotice();
term.focus();
// Focusing here is what put the keyboard up on a phone before a single
// tap: it happens on every connect, including reconnects.
if (keyboard.raisesOnTap()) term.focus();
} else if (status === 'closed') {
notify('Disconnected. Reconnecting...', true);
}
@@ -1571,7 +1920,6 @@ body.touch #terminal {
// Touch affordances only where there is a touch. A desktop browser gets a
// plain terminal, which is what lets this ship as the default frontend.
var coarsePointer = !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches);
if (D.config.toolbarWanted(window.location.search, coarsePointer)) {
document.body.classList.add('touch');
toolbar = D.toolbar.createToolbar({
@@ -1584,7 +1932,10 @@ body.touch #terminal {
// ASCII, which UTF-8 leaves alone.
sendText: function (text) { client.sendInput(text); },
prefix: function () { return prefixByte; },
notify: notify
notify: notify,
search: function () { return window.location.search; },
navigate: function (search) { window.location.search = search; },
allowKeyboard: keyboard.allow
});
toolbar.attach();
+6 -5
View File
@@ -1,14 +1,15 @@
f6f6611d0b3eacc39d10add9c4bbc56d4b3a70a47f022351a1c6eab68cd66e71 package-lock.json
aa2a54392a0ab6bceae342ac0b12494d401b11574db12c013d0c164ebd10e018 src/config.js
613d28096b1bed66f347590663f5f0136fb7f61a4578066fa2ed9e788ff9567a src/config.js
f690fd9d35975bc977e3d323595a2a4aa1fca6ed50f213602f08adec3f5b2c92 src/gestures.js
e75dc9d5e311a3d9bf9dc373b651ad506afbd7a7cc7ad54aac6fdad94276b0a4 src/keyboard.js
2a070f63702b95b0c62963ec6b9ec4fa2b0b89b7553a8a889d68cdf3506f9ce3 src/keys.js
248b4b6e72eed31eda11d466e1483b33ed2c6796895c221f1d0aecfea9243eac src/main.js
e5888ba8413fc2b442b44cade378f7393fd231dd5f79d44c58d1bc217ed7c807 src/main.js
e3a550e2249b536c9bc3d90ee2735a9a2d8e6f772a6a631f724d289b96b1f557 src/modes.js
8446be09a1eca041a5a2e2068fdace32596713aef1d1fb74b522ea4eacc50ec3 src/mouse.js
cb5e33c0fa117176d9073bd9019f78b65bfe5535833bf1765ee542c700d67f1f src/osc52.js
6dddb5ed652fd5189916225115083fc38ae0380a45f82f59f0c388591aa74ccc src/page.css
94229c981f6287162d6ef774f71ef41d941494595f76f16bb742811ca32a767f src/page.css
e02823ce9d5d57615a0a8c0c7b91611932202758048b7eadabe4f1054aaf1a9f src/protocol.js
aac82e88c6a65617818c6589091cbb8c3bab50e82e98aabf8b583cbb3dfda7e7 src/sgr.js
2369fbfdcd1d162c372f3dc361dce09a758db9671cbde64cb0debe57c12468a2 src/template.html
a87dc2980dc647c04464dff0ea859ee381f00c3caa752d3bd0ef1ba3218d97ee src/toolbar.js
dab361cf748e6d530099f79ccd3f407fcc493f81dd4afeda227376a8c9e9b81f src/template.html
3c8e5520b694693126097d9fe17f89ff23ba15adb8532521c647907edba20215 src/toolbar.js
864405fe11e5456bdb98ea8c841cb04346d5a38aabec897101defa9612d89443 src/viewport.js
+61 -2
View File
@@ -12,7 +12,7 @@
// an xterm.js option and is handed to the terminal untouched, which is how
// the existing TTYD_FONT_SIZE knob keeps working with no code here knowing
// about it. isWindows is ttyd's own, always present, and not an xterm option.
var PAGE_KEYS = ['tmuxPrefix'];
var PAGE_KEYS = ['tmuxPrefix', 'focusOnTap', 'buttonColors'];
var IGNORED_KEYS = ['isWindows'];
// "C-b" -> "\x02", the byte tmux is actually listening for. Deliberately
@@ -52,11 +52,70 @@
return !!coarsePointer;
}
// Whether tapping the terminal should raise the soft keyboard.
//
// Off by default where the pointer is coarse, which is the whole reason this
// exists: on a phone, tapping is how you scroll and select, and every one of
// those taps used to summon the keyboard and cost a second tap to dismiss.
// The KBD button is the deliberate way to ask for it. On a fine pointer there
// is no soft keyboard to raise and focus-on-click is simply what a terminal
// does, so nothing changes there.
//
// ?focus=1 / ?focus=0 override per client, and focusOnTap in ttyd's
// preferences overrides for every client, the same shape as the toolbar
// switch above.
function focusOnTapWanted(search, coarsePointer, preference) {
var params = new URLSearchParams(search || '');
var value = params.get('focus');
if (value === '1') return true;
if (value === '0') return false;
// "on"/"off" first, because that is dejima's own bool spelling and what
// ttyd_focus_on_tap actually sends. The others are accepted because a
// preference can also arrive from anything that writes ttyd -t directly.
if (preference === 'on' || preference === true || preference === 'true' ||
preference === '1') return true;
if (preference === 'off' || preference === false || preference === 'false' ||
preference === '0') return false;
return !coarsePointer;
}
// Whether the toolbar is colour-coded by what each button is for.
//
// On unless the operator says otherwise: the hierarchy is the point of it,
// and a toolbar where every key looks the same is one where the key you
// need most is found by counting positions. "off" restores exactly the grey
// it had before, for a terminal colour scheme this clashes with or an
// operator who would rather it were quiet.
function buttonColorsWanted(preference) {
if (preference === 'off' || preference === false || preference === 'false' ||
preference === '0') return false;
return true;
}
// The address of the session picker: this page with the run-id dropped.
//
// ttyd passes ?arg=<run-id> to dejima-attach as argv (--url-arg), so a page
// loaded without it runs dejima-attach with no argument, which is the picker.
// Every other parameter is kept, because they configure this page rather than
// the command - losing ?toolbar=1 on the way back would be a surprise.
//
// Returns a search string, "" when nothing is left, so a caller can build a
// URL without knowing whether to write the "?".
function pickerSearch(search) {
var params = new URLSearchParams(search || '');
params.delete('arg');
var rest = params.toString();
return rest ? '?' + rest : '';
}
D.config = {
DEFAULT_TMUX_PREFIX: DEFAULT_TMUX_PREFIX,
PAGE_KEYS: PAGE_KEYS,
parsePrefix: parsePrefix,
splitPreferences: splitPreferences,
toolbarWanted: toolbarWanted
toolbarWanted: toolbarWanted,
focusOnTapWanted: focusOnTapWanted,
buttonColorsWanted: buttonColorsWanted,
pickerSearch: pickerSearch
};
})((globalThis.DEJIMA = globalThis.DEJIMA || {}));
+107
View File
@@ -0,0 +1,107 @@
// Whether a tap on the terminal may raise the soft keyboard.
//
// xterm.js focuses its textarea whenever the terminal is clicked, which on a
// desktop is exactly right and on a phone is not: tapping is also how you
// scroll and select, so every one of those taps summons the keyboard and costs
// a second tap to put it away. The keyboard is asked for explicitly instead,
// with the toolbar's KBD button.
//
// Done by letting focus land and dropping it, rather than by intercepting the
// tap. Focus arrives from several paths inside xterm.js - pointer, selection,
// programmatic - and catching each would mean knowing about each. Selection and
// scrolling are untouched, and the toolbar's own keys never go through the
// textarea, so they keep working with the keyboard down.
(function (D) {
'use strict';
// How much of the screen something has to cover before it is a keyboard
// rather than an address bar sliding into view.
var KEYBOARD_MIN_PX = 120;
// opts: {term, element, visualViewport, layoutHeight(), defer(fn)}
function createKeyboardPolicy(opts) {
// Both start permissive, so a page that never calls setRaiseOnTap behaves
// exactly as a terminal always has.
var raiseOnTap = true;
var allowed = true;
var keyboardWasUp = false;
var defer = opts.defer || function (fn) { setTimeout(fn, 0); };
var layoutHeight = opts.layoutHeight || function () { return window.innerHeight; };
function textarea() { return opts.term.textarea || null; }
// "The keyboard should stay down": taps do not raise it, and nobody has
// asked for it since it last went away.
function locked() { return !raiseOnTap && !allowed; }
function dropFocus() {
var el = textarea();
if (el && locked()) el.blur();
}
// Focus alone is not enough to hang this on. Tapping a textarea that is
// ALREADY the focused element raises the keyboard again and fires no focus
// event, and there are two ordinary ways to arrive at a focused textarea
// with the keyboard down: the terminal is focused when the connection
// opens, and a keyboard dismissed by a system gesture leaves focus where it
// was. Both were exactly the reported bug. So the tap is watched too, and
// the blur deferred to let xterm's own focus call happen first.
function onPointerDown() {
if (locked()) defer(dropFocus);
}
// Dismissing the keyboard means the next tap should not bring it back, so
// a blur re-locks. This is also how a dropped focus feeds back in.
function onBlur() { allowed = false; }
// A keyboard dismissed with the system's own gesture does not blur the
// element it belongs to, so KBD would otherwise unlock things for good.
// The visual viewport is the only thing that sees a keyboard at all: it is
// covered by one rather than resized by it.
function onViewportResize() {
var vv = opts.visualViewport;
if (!vv) return;
var up = (layoutHeight() - vv.height) > KEYBOARD_MIN_PX;
if (keyboardWasUp && !up && !raiseOnTap) {
allowed = false;
var el = textarea();
if (el) el.blur();
}
keyboardWasUp = up;
}
// Listeners are attached once, and the policy they consult is a variable.
// Re-attaching per policy change would stack duplicates, since preferences
// arrive again on every reconnect.
function attach() {
var el = textarea();
if (el) {
el.addEventListener('focus', dropFocus);
el.addEventListener('blur', onBlur);
}
if (opts.element) opts.element.addEventListener('pointerdown', onPointerDown);
if (opts.visualViewport) opts.visualViewport.addEventListener('resize', onViewportResize);
}
// Called at startup with the pointer-type default and again when ttyd's
// preferences arrive. Dropping focus here is what handles the terminal
// being focused already, which it is by the time preferences land.
function setRaiseOnTap(wanted) {
raiseOnTap = !!wanted;
allowed = raiseOnTap;
if (!raiseOnTap) dropFocus();
}
return {
attach: attach,
setRaiseOnTap: setRaiseOnTap,
raisesOnTap: function () { return raiseOnTap; },
// The one deliberate request for the keyboard, so the one thing that
// unlocks it.
allow: function () { allowed = true; }
};
}
D.keyboard = { createKeyboardPolicy: createKeyboardPolicy, KEYBOARD_MIN_PX: KEYBOARD_MIN_PX };
})((globalThis.DEJIMA = globalThis.DEJIMA || {}));
+42 -3
View File
@@ -61,6 +61,30 @@
var viewport = D.viewport.createViewport({ app: app, onFit: fitNow });
// Whether this is a touch device, which decides both the toolbar below and
// the keyboard policy that follows. Probed here rather than beside its first
// use because applyPreferences needs it, and that runs on a message from
// ttyd - later in wall-clock time, but easier to reason about declared
// before the function that reads it.
var coarsePointer = !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches);
var keyboard = D.keyboard.createKeyboardPolicy({
term: term,
element: terminalHost,
visualViewport: window.visualViewport,
layoutHeight: function () { return window.innerHeight; }
});
keyboard.attach();
// Decided at startup from the pointer type, and again below when ttyd's
// preferences arrive. Both are needed: the connection opens before the
// preferences do, and what happens on open depends on this.
function tapFocusPolicy(preference) {
keyboard.setRaiseOnTap(D.config.focusOnTapWanted(
window.location.search, coarsePointer, preference));
}
tapFocusPolicy(undefined);
function applyPreferences(prefs) {
var split = D.config.splitPreferences(prefs);
Object.keys(split.terminal).forEach(function (key) {
@@ -81,6 +105,17 @@
}
prefixByte = parsed;
// ttyd sends preferences after the connection opens, so a focusOnTap set
// server-side always arrives later than the startup decision and has to be
// able to change it.
tapFocusPolicy(split.page.focusOnTap);
// A class rather than per-button styling: what the page decides is
// whether there is a palette at all, and the stylesheet owns which button
// gets which part of it.
document.body.classList.toggle(
'mono-keys', !D.config.buttonColorsWanted(split.page.buttonColors));
// A font size arriving in the preferences changes the cell size, so the
// column count computed before it was applied is now wrong.
viewport.refresh();
@@ -95,7 +130,9 @@
onStatus: function (status) {
if (status === 'open') {
clearNotice();
term.focus();
// Focusing here is what put the keyboard up on a phone before a single
// tap: it happens on every connect, including reconnects.
if (keyboard.raisesOnTap()) term.focus();
} else if (status === 'closed') {
notify('Disconnected. Reconnecting...', true);
}
@@ -147,7 +184,6 @@
// Touch affordances only where there is a touch. A desktop browser gets a
// plain terminal, which is what lets this ship as the default frontend.
var coarsePointer = !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches);
if (D.config.toolbarWanted(window.location.search, coarsePointer)) {
document.body.classList.add('touch');
toolbar = D.toolbar.createToolbar({
@@ -160,7 +196,10 @@
// ASCII, which UTF-8 leaves alone.
sendText: function (text) { client.sendInput(text); },
prefix: function () { return prefixByte; },
notify: notify
notify: notify,
search: function () { return window.location.search; },
navigate: function (search) { window.location.search = search; },
allowKeyboard: keyboard.allow
});
toolbar.attach();
+139 -15
View File
@@ -42,30 +42,96 @@ body.touch #terminal {
-webkit-touch-callout: none;
}
/* gruvbox dark, the same values bin/dejima-attach uses for the session
picker, each tagged with its upstream color.table name so a wrong hex is
catchable by reading. The two sit either side of one attach: picker, then
terminal. They should not look like different programs.
https://github.com/gruvbox-community/gruvbox-contrib/blob/master/color.table */
:root {
--gb-dark0-hard: #1d2021;
--gb-dark0: #282828;
--gb-dark1: #3c3836;
--gb-dark2: #504945;
--gb-light1: #ebdbb2;
--gb-orange: #fe8019; /* bright_orange, the wordmark */
--gb-yellow: #fabd2f; /* bright_yellow */
--gb-green: #b8bb26; /* bright_green */
--gb-blue: #83a598; /* bright_blue */
/* What the toolbar is made of, in tiers rather than in colours, so that
turning colour off is one block of overrides at the bottom of this file
and not an edit to every rule.
The tiers are an attention hierarchy, and the order is not arbitrary: KBD
is how you type at all on a phone, so it is the one button that must be
findable without looking for it. Enter is the key a session asks for most.
The window and arrow keys are movement - reached for often, but never the
thing you are hunting for - so they are marked out as a group without
being filled. Everything else is furniture. */
--key-fg: var(--gb-light1);
--key-bg: var(--gb-dark1);
--key-border: var(--gb-dark2);
--key-active-bg: var(--gb-dark2);
--key-bar-bg: var(--gb-dark0-hard);
--key-bar-border: var(--gb-dark2);
--key-primary-fg: var(--gb-dark0-hard);
--key-primary-bg: var(--gb-orange);
--key-primary-border: var(--gb-orange);
--key-secondary-fg: var(--gb-dark0-hard);
--key-secondary-bg: var(--gb-green);
--key-secondary-border: var(--gb-green);
/* Outlined, not coloured-in: bright_blue as text on dark1 measures 4.31:1,
which is under the 4.5:1 an unenlarged label needs, and the label is the
part that has to survive a phone in sunlight. The border carries the tier
and the text stays at 8.45:1. */
--key-nav-fg: var(--gb-light1);
--key-nav-border: var(--gb-blue);
--key-armed-fg: var(--gb-dark0-hard);
--key-armed-bg: var(--gb-blue);
--key-locked-fg: var(--gb-dark0-hard);
--key-locked-bg: var(--gb-yellow);
}
#toolbar {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px 4px calc(4px + env(safe-area-inset-bottom));
background: #14161a;
border-top: 1px solid #2a2f37;
background: var(--key-bar-bg);
border-top: 1px solid var(--key-bar-border);
touch-action: manipulation;
}
/* Flex rather than a grid with a column count in it. The count was 7, which
was the number of buttons at the time - adding one wrapped the row and broke
both. Equal-basis flex children divide the row however many there are, so a
button added later needs no change here. */
#toolbar .row {
display: grid;
grid-template-columns: repeat(7, 1fr);
display: flex;
gap: 4px;
}
#toolbar .row > button {
flex: 1 1 0;
min-width: 0;
}
#toolbar button {
min-height: 40px;
padding: 0 2px;
font: 600 13px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: #d7dce3;
background: #232830;
border: 1px solid #343b45;
/* Clamped rather than fixed: eight buttons on a narrow phone leave about
forty pixels each, and the longest label is six characters. The lower
bound is where a monospace PREFIX still fits; the upper is the size this
has always been, so a wide screen is unchanged. */
font: 600 clamp(10px, 2.8vw, 13px)/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: var(--key-fg);
background: var(--key-bg);
border: 1px solid var(--key-border);
border-radius: 6px;
-webkit-user-select: none;
user-select: none;
@@ -73,21 +139,79 @@ body.touch #terminal {
}
#toolbar button:active {
background: #39414d;
background: var(--key-active-bg);
}
/* The hierarchy. Filled-warm beats filled-cool beats outlined beats plain,
which is a ramp that survives being looked at quickly and in sunlight -
the two conditions a phone toolbar is actually used in.
These sit ABOVE the latch rules on purpose. A latched CTRL and a tiered
button match at the same specificity, so what decides is source order, and
the state a button is IN has to beat the job it does. */
#toolbar button[data-action="kbd"] {
color: var(--key-primary-fg);
background: var(--key-primary-bg);
border-color: var(--key-primary-border);
}
#toolbar button[data-action="enter"] {
color: var(--key-secondary-fg);
background: var(--key-secondary-bg);
border-color: var(--key-secondary-border);
}
#toolbar button[data-action="prev-window"],
#toolbar button[data-action="next-window"],
#toolbar button[data-action="left"],
#toolbar button[data-action="down"],
#toolbar button[data-action="up"],
#toolbar button[data-action="right"] {
color: var(--key-nav-fg);
border-color: var(--key-nav-border);
}
/* Armed for one key, versus latched on. Two states on one button, so the
distinction has to be visible without a legend. */
#toolbar button.is-armed {
color: #0b0d10;
background: #7fb3ff;
border-color: #7fb3ff;
color: var(--key-armed-fg);
background: var(--key-armed-bg);
border-color: var(--key-armed-bg);
}
#toolbar button.is-locked {
color: #0b0d10;
background: #ffcc66;
border-color: #ffcc66;
color: var(--key-locked-fg);
background: var(--key-locked-bg);
border-color: var(--key-locked-bg);
}
/* Every colour the toolbar had before it had a palette. One block, because
the alternative is an override per rule and a way for the two to drift -
an operator who turns colour off is asking for what was here, not for a
second scheme to maintain. */
body.mono-keys {
--key-fg: #d7dce3;
--key-bg: #232830;
--key-border: #343b45;
--key-active-bg: #39414d;
--key-bar-bg: #14161a;
--key-bar-border: #2a2f37;
--key-primary-fg: #d7dce3;
--key-primary-bg: #232830;
--key-primary-border: #343b45;
--key-secondary-fg: #d7dce3;
--key-secondary-bg: #232830;
--key-secondary-border: #343b45;
--key-nav-fg: #d7dce3;
--key-nav-border: #343b45;
--key-armed-fg: #0b0d10;
--key-armed-bg: #7fb3ff;
--key-locked-fg: #0b0d10;
--key-locked-bg: #ffcc66;
}
#toast {
+2
View File
@@ -30,11 +30,13 @@
<button type="button" data-action="down" aria-label="Down">&#8595;</button>
<button type="button" data-action="up" aria-label="Up">&#8593;</button>
<button type="button" data-action="right" aria-label="Right">&#8594;</button>
<button type="button" data-action="enter" aria-label="Enter">&#9166;</button>
</div>
<div class="row">
<button type="button" data-action="prefix">PREFIX</button>
<button type="button" data-action="prev-window" aria-label="Previous window">&#9664;W</button>
<button type="button" data-action="next-window" aria-label="Next window">W&#9654;</button>
<button type="button" data-action="sessions" aria-label="Back to the session picker">SESS</button>
<button type="button" data-action="zoom">ZOOM</button>
<button type="button" data-action="drag">DRAG</button>
<button type="button" data-action="paste">PASTE</button>
+19
View File
@@ -5,6 +5,8 @@
// whole reason tapping a button neither moves focus nor dismisses the soft
// keyboard - the default action of a pointerdown on a button is to focus it,
// which blurs the terminal's textarea and takes the keyboard down with it.
// Requires config.js, for the picker address the SESS button navigates to.
(function (D) {
'use strict';
@@ -107,7 +109,24 @@
refreshLatches();
},
paste: paste,
// Carriage return, not a newline. A terminal's Enter key is CR; sending
// LF here would submit nothing in a shell and insert a line in an editor.
enter: function () { opts.sendText('\r'); },
// Back to dejima-attach with no run-id, which is the picker. A full
// navigation rather than anything cleverer: the run-id reaches the
// command through ttyd's --url-arg on the WebSocket URL, so the only way
// to run a different one is a new connection.
//
// Safe for the session being left. dejima-attach gives each client its
// own tmux session grouped onto the real one and sets destroy-unattached
// on THAT, so detaching reaps the view and leaves the sandbox pane alone.
sessions: function () {
if (opts.navigate) opts.navigate(D.config.pickerSearch(opts.search()));
},
kbd: function () {
// The one deliberate request for the keyboard, so it is the one place
// that unlocks it - see focusOnTapWanted.
if (opts.allowKeyboard) opts.allowKeyboard();
opts.term.focus();
if (opts.term.textarea) opts.term.textarea.focus();
}
+94 -2
View File
@@ -3,8 +3,8 @@ const { test } = require('node:test');
const assert = require('node:assert');
require('../src/keys.js'); // config.js parses the tmux prefix through it
require('../src/config.js');
const { DEFAULT_TMUX_PREFIX, parsePrefix, splitPreferences, toolbarWanted } =
globalThis.DEJIMA.config;
const { DEFAULT_TMUX_PREFIX, parsePrefix, splitPreferences, toolbarWanted,
focusOnTapWanted, pickerSearch, buttonColorsWanted } = globalThis.DEJIMA.config;
test('a tmux prefix is parsed into the byte tmux would receive', () => {
assert.equal(parsePrefix('C-b'), '\x02');
@@ -53,3 +53,95 @@ test('the toolbar follows the pointer type unless the query string overrides it'
assert.equal(toolbarWanted('?arg=x&toolbar=1', false), true);
assert.equal(toolbarWanted('?toolbar=maybe', false), false, 'an unparseable value is not an override');
});
// Tapping the terminal used to raise the soft keyboard, which on a phone is
// where scrolling and selecting also happen - so every one of those taps cost
// a second tap to put the keyboard away again. The KBD button is the explicit
// way to ask for it.
test('a tap does not raise the keyboard on a touch device', () => {
assert.equal(focusOnTapWanted('', true, undefined), false);
});
test('a tap still focuses on a desktop, where there is no keyboard to raise', () => {
assert.equal(focusOnTapWanted('', false, undefined), true);
});
test('a client can override either way with ?focus=', () => {
assert.equal(focusOnTapWanted('?focus=1', true, undefined), true,
'a phone that wants the old behaviour can have it');
assert.equal(focusOnTapWanted('?focus=0', false, undefined), false);
});
test('the server preference is read in dejima\'s own bool spelling', () => {
// ttyd_focus_on_tap is a dejima bool, so what reaches the page is "on" or
// "off" - not "true"/"false". Accepting only the latter would have made the
// documented config key do nothing.
assert.equal(focusOnTapWanted('', true, 'on'), true);
assert.equal(focusOnTapWanted('', false, 'off'), false);
});
test('the server preference decides when the client says nothing', () => {
assert.equal(focusOnTapWanted('', true, 'true'), true);
assert.equal(focusOnTapWanted('', false, 'false'), false);
// ttyd hands -t values through as strings; booleans arrive from tests and
// from anything that parses the JSON itself.
assert.equal(focusOnTapWanted('', true, true), true);
assert.equal(focusOnTapWanted('', false, false), false);
});
test('the query string beats the server preference', () => {
assert.equal(focusOnTapWanted('?focus=0', false, 'true'), false);
assert.equal(focusOnTapWanted('?focus=1', true, 'false'), true);
});
// The session picker is this page with the run-id dropped: ttyd passes ?arg=
// to dejima-attach as argv, and dejima-attach with no argument is the picker.
test('the picker address drops the run-id', () => {
assert.equal(pickerSearch('?arg=abc123'), '');
});
test('the picker address keeps every other parameter', () => {
// ?toolbar=1 configures this page, not the command. Losing it on the way
// back to the picker would silently change the UI.
const r = pickerSearch('?arg=abc123&toolbar=1&focus=0');
const p = new URLSearchParams(r);
assert.equal(p.get('arg'), null, 'the run-id is what we are dropping');
assert.equal(p.get('toolbar'), '1');
assert.equal(p.get('focus'), '0');
});
test('the picker address is already the picker when there is no run-id', () => {
assert.equal(pickerSearch(''), '');
assert.equal(pickerSearch('?toolbar=1'), '?toolbar=1');
});
// The toolbar's colours are on unless the operator turns them off: the
// hierarchy is the point of them, and a toolbar where every key looks alike is
// one where the key you need most is found by counting positions.
test('the toolbar is coloured when nothing says otherwise', () => {
assert.equal(buttonColorsWanted(undefined), true);
assert.equal(buttonColorsWanted(''), true);
assert.equal(buttonColorsWanted('on'), true);
});
test('off turns the toolbar grey, in dejima\'s own bool spelling', () => {
// ttyd_button_colors is a dejima bool, so what reaches the page is "off" -
// not "false". Accepting only the latter would ship a documented key that
// does nothing, which is exactly what focusOnTap nearly did.
assert.equal(buttonColorsWanted('off'), false);
assert.equal(buttonColorsWanted(false), false);
assert.equal(buttonColorsWanted('false'), false);
assert.equal(buttonColorsWanted('0'), false);
});
test('buttonColors is a page option, not one xterm is handed', () => {
// Every page key that reached term.options would be an unknown option to
// xterm.js, which is the failure splitPreferences exists to prevent.
const split = splitPreferences({ buttonColors: 'off', fontSize: 14 });
assert.equal(split.page.buttonColors, 'off');
assert.equal(split.terminal.buttonColors, undefined);
assert.equal(split.terminal.fontSize, 14);
});
+163
View File
@@ -0,0 +1,163 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert');
require('../src/keyboard.js');
const { createKeyboardPolicy, KEYBOARD_MIN_PX } = globalThis.DEJIMA.keyboard;
// An element that records focus the way the DOM does, including the part that
// caused the bug: focusing something already focused fires no focus event.
function makeElement() {
const listeners = {};
return {
focused: false,
listeners: listeners,
addEventListener: (type, fn) => { (listeners[type] = listeners[type] || []).push(fn); },
fire: function (type) { (listeners[type] || []).forEach((fn) => fn()); },
focus: function () {
if (this.focused) return;
this.focused = true;
this.fire('focus');
},
blur: function () {
if (!this.focused) return;
this.focused = false;
this.fire('blur');
}
};
}
// term, terminal element and visual viewport, plus a defer() that runs on
// demand so a test can see the state between the tap and the blur.
function makeWorld(layoutHeight) {
const textarea = makeElement();
const element = makeElement();
const visualViewport = makeElement();
visualViewport.height = layoutHeight || 800;
const deferred = [];
const policy = createKeyboardPolicy({
term: { textarea: textarea },
element: element,
visualViewport: visualViewport,
layoutHeight: () => layoutHeight || 800,
defer: (fn) => deferred.push(fn)
});
policy.attach();
return {
policy, textarea, element, visualViewport,
tap: function () {
element.fire('pointerdown');
// xterm focuses the textarea from its own handler, synchronously, before
// anything deferred runs.
textarea.focus();
while (deferred.length) deferred.shift()();
},
keyboardTo: function (up) {
visualViewport.height = up ? (layoutHeight || 800) - KEYBOARD_MIN_PX - 1 : (layoutHeight || 800);
visualViewport.fire('resize');
}
};
}
test('with no policy set, a tap focuses the terminal as it always has', () => {
// The desktop path, and the one that ships if setRaiseOnTap is never called.
const w = makeWorld();
w.tap();
assert.equal(w.textarea.focused, true);
});
test('a tap does not leave the terminal focused when taps must not raise the keyboard', () => {
const w = makeWorld();
w.policy.setRaiseOnTap(false);
w.tap();
assert.equal(w.textarea.focused, false);
});
test('a tap on an ALREADY focused terminal still drops the focus', () => {
// The reported bug. Focusing a focused element fires no focus event, so a
// focus handler alone never runs - and the terminal is focused as a matter
// of course, because the page focuses it when the connection opens.
const w = makeWorld();
w.textarea.focus();
w.policy.setRaiseOnTap(false);
assert.equal(w.textarea.focused, false, 'the standing focus is dropped when the policy lands');
w.textarea.focused = true; // as if the system had put it back without an event
w.tap();
assert.equal(w.textarea.focused, false);
});
test('KBD is what raises the keyboard, and it survives a tap afterwards', () => {
const w = makeWorld();
w.policy.setRaiseOnTap(false);
w.policy.allow();
w.textarea.focus();
assert.equal(w.textarea.focused, true, 'the deliberate request is honoured');
// Having asked for the keyboard, tapping to move the cursor must not take it
// away again.
w.tap();
assert.equal(w.textarea.focused, true);
});
test('dismissing the keyboard re-locks it, so the next tap does not bring it back', () => {
const w = makeWorld();
w.policy.setRaiseOnTap(false);
w.policy.allow();
w.textarea.focus();
w.textarea.blur();
w.tap();
assert.equal(w.textarea.focused, false);
});
test('a keyboard closed by a system gesture re-locks, though it fires no blur', () => {
// Android's back gesture closes the keyboard and leaves the element focused.
// Without the viewport watching, one KBD would unlock taps permanently.
const w = makeWorld();
w.policy.setRaiseOnTap(false);
w.policy.allow();
w.textarea.focus();
w.keyboardTo(true);
w.keyboardTo(false);
assert.equal(w.textarea.focused, false, 'focus is dropped when the keyboard goes');
w.textarea.focused = true;
w.tap();
assert.equal(w.textarea.focused, false);
});
test('the viewport is left alone where taps are meant to raise the keyboard', () => {
// A desktop window shrinking, or a phone with focus_on_tap = on. Nothing
// here should be stealing focus from a terminal that is meant to have it.
const w = makeWorld();
w.policy.setRaiseOnTap(true);
w.textarea.focus();
w.keyboardTo(true);
w.keyboardTo(false);
assert.equal(w.textarea.focused, true);
});
test('setting the policy again does not stack up listeners', () => {
// Preferences arrive on every reconnect, so this runs more than once.
const w = makeWorld();
w.policy.setRaiseOnTap(false);
w.policy.setRaiseOnTap(false);
w.policy.setRaiseOnTap(true);
assert.equal(w.textarea.listeners.focus.length, 1);
assert.equal(w.textarea.listeners.blur.length, 1);
assert.equal(w.element.listeners.pointerdown.length, 1);
});
test('raisesOnTap reports the policy, so the page knows whether to focus on connect', () => {
const w = makeWorld();
assert.equal(w.policy.raisesOnTap(), true);
w.policy.setRaiseOnTap(false);
assert.equal(w.policy.raisesOnTap(), false);
});
test('a terminal with no textarea yet is not a crash', () => {
// term.textarea is null until the terminal is opened.
const policy = createKeyboardPolicy({ term: {}, element: makeElement() });
policy.attach();
policy.setRaiseOnTap(false);
policy.allow();
});
+106
View File
@@ -0,0 +1,106 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
// Every label the toolbar draws has to be readable on a phone held outdoors,
// which is the only place this page is ever used. WCAG AA asks 4.5:1 for text
// this size, and one of these pairings shipped at 4.31:1 in the first draft -
// gruvbox's bright_blue as a label on dark1 - which is a difference nobody
// catches by looking at a screenshot on a desk.
//
// Read out of the stylesheet rather than restated here, so a colour changed
// there is a colour checked here.
const CSS = fs.readFileSync(path.join(__dirname, '..', 'src', 'page.css'), 'utf8');
function declarationsIn(block) {
const out = {};
const re = /^\s*(--[a-z0-9-]+):\s*([^;]+);/gm;
let m;
while ((m = re.exec(block)) !== null) out[m[1]] = m[2].trim();
return out;
}
function blockFor(selector) {
const start = CSS.indexOf(selector + ' {');
assert.notEqual(start, -1, 'no ' + selector + ' block in page.css');
const end = CSS.indexOf('\n}', start);
return CSS.slice(start, end);
}
// var(--x) chains down to a literal hex.
function resolve(vars, value, depth) {
depth = depth || 0;
assert.ok(depth < 10, 'variable cycle resolving ' + value);
const m = /^var\((--[a-z0-9-]+)\)$/.exec(value);
if (!m) return value;
assert.ok(vars[m[1]], 'undefined variable ' + m[1]);
return resolve(vars, vars[m[1]], depth + 1);
}
function luminance(hex) {
const h = hex.replace('#', '');
const parts = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255);
const lin = parts.map((c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)));
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
}
function contrast(fg, bg) {
const a = luminance(fg);
const b = luminance(bg);
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
}
// Each tier's label against the surface it is drawn on. The nav tier is
// outlined rather than filled, so its label sits on the ordinary key
// background - which is precisely why its colour matters.
const PAIRINGS = [
['plain keys', '--key-fg', '--key-bg'],
['the nav tier', '--key-nav-fg', '--key-bg'],
['KBD', '--key-primary-fg', '--key-primary-bg'],
['Enter', '--key-secondary-fg', '--key-secondary-bg'],
['an armed latch', '--key-armed-fg', '--key-armed-bg'],
['a locked latch', '--key-locked-fg', '--key-locked-bg']
];
function checkBlock(selector) {
const vars = declarationsIn(blockFor(selector));
PAIRINGS.forEach(([what, fgVar, bgVar]) => {
assert.ok(vars[fgVar], selector + ' does not set ' + fgVar);
assert.ok(vars[bgVar], selector + ' does not set ' + bgVar);
const fg = resolve(vars, vars[fgVar]);
const bg = resolve(vars, vars[bgVar]);
const ratio = contrast(fg, bg);
assert.ok(
ratio >= 4.5,
what + ' in ' + selector + ' is ' + fg + ' on ' + bg + ' at ' +
ratio.toFixed(2) + ':1, under the 4.5:1 a label this size needs'
);
});
}
test('every toolbar label meets AA against what it is drawn on', () => {
checkBlock(':root');
});
test('turning the colours off does not turn legibility off with them', () => {
// The grey scheme is a supported mode, not a fallback nobody looks at.
checkBlock('body.mono-keys');
});
test('the tiers are actually distinct, or the hierarchy is decoration', () => {
const vars = declarationsIn(blockFor(':root'));
const bg = (v) => resolve(vars, vars[v]);
const primary = bg('--key-primary-bg');
const secondary = bg('--key-secondary-bg');
const plain = bg('--key-bg');
assert.notEqual(primary, secondary, 'KBD and Enter are filled the same colour');
assert.notEqual(primary, plain, 'KBD is filled the same as an ordinary key');
assert.notEqual(secondary, plain, 'Enter is filled the same as an ordinary key');
assert.notEqual(
resolve(vars, vars['--key-nav-border']), resolve(vars, vars['--key-border']),
'the nav tier is outlined the same as an ordinary key'
);
});
+83 -1
View File
@@ -1,8 +1,47 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert');
require('../src/keys.js'); // config.js parses the tmux prefix through it
require('../src/config.js'); // toolbar.js asks it for the picker address
require('../src/toolbar.js');
const { pasteSequence } = globalThis.DEJIMA.toolbar;
const { pasteSequence, createToolbar } = globalThis.DEJIMA.toolbar;
// A toolbar wired to spies, with an element stub standing in for the DOM the
// real one attaches to. fire() reaches the action table the way a tap does,
// through the pointerdown listener, so a button wired to the wrong name fails
// here rather than only on a phone.
function makeToolbar(overrides) {
const listeners = {};
const root = {
hidden: true,
addEventListener: (type, fn) => { listeners[type] = fn; },
// refreshLatches paints the CTRL and DRAG buttons; nothing here asserts on
// that, but the toolbar calls it on attach, so it has to find something.
querySelector: () => ({ classList: { toggle: () => {} } })
};
const opts = Object.assign({
root: root,
term: { focus: () => {}, textarea: { focus: () => {} } },
modes: { bracketedPaste: () => false },
latch: { state: () => 'off', armed: () => false, tap: () => {}, consume: () => {}, reset: () => {} },
sendText: () => {},
prefix: () => '\x02',
notify: () => {},
search: () => '',
navigate: () => {},
allowKeyboard: () => {}
}, overrides || {});
const toolbar = createToolbar(opts);
toolbar.attach();
return {
fire: function (action) {
listeners.pointerdown({
target: { closest: () => ({ dataset: { action: action } }) },
preventDefault: () => {}
});
}
};
}
// The clipboard is session-writable through OSC 52, so what PASTE sends is
// content that may not be the operator's. Without bracketed paste a trailing
@@ -41,3 +80,46 @@ test('stripping does not turn a multi-line paste into a silent one', () => {
// single-line and has nothing to warn about.
assert.equal(pasteSequence('single\n', false).warn, false);
});
// The two buttons added for a phone: Enter, because it is the key a harness
// wants most and no soft keyboard row has it where a thumb is; and SESS, to
// get back to the picker without closing the tab.
test('ENTER sends a carriage return, not a newline', () => {
// A terminal's Enter is CR. LF would submit nothing at a shell prompt and
// insert a line in an editor, which is the kind of difference that reads as
// "the button is broken".
const sent = [];
const t = makeToolbar({ sendText: (s) => sent.push(s) });
t.fire('enter');
assert.deepEqual(sent, ['\r']);
});
test('SESS navigates to the picker, dropping only the run-id', () => {
const went = [];
const t = makeToolbar({
search: () => '?arg=run-7&toolbar=1',
navigate: (s) => went.push(s)
});
t.fire('sessions');
assert.equal(went.length, 1);
const p = new URLSearchParams(went[0]);
assert.equal(p.get('arg'), null, 'the run-id is what sends it back to the picker');
assert.equal(p.get('toolbar'), '1', 'page options survive the trip');
});
test('KBD is what unlocks the keyboard, and it does so before focusing', () => {
// The order matters: the focus handler that drops an unwanted focus reads
// the flag, so setting it afterwards would blur the focus KBD just asked for.
const order = [];
const t = makeToolbar({
allowKeyboard: () => order.push('allow'),
term: {
focus: () => order.push('focus'),
textarea: { focus: () => order.push('textarea') }
}
});
t.fire('kbd');
assert.equal(order[0], 'allow', 'the keyboard is unlocked first');
assert.ok(order.includes('focus'));
});
+4 -1
View File
@@ -10,7 +10,10 @@ Git workflow - do this even though nothing stops you from doing otherwise:
branch-finishing advice from a skill cannot know that, and does not
override it.
- Stop at the push. Do not open a pull request, and do not merge anything -
neither is part of this workflow. Report the branch name and stop.
neither is part of this workflow. A `merge-guard.sh` hook and a shadow over
the forge's own command both refuse a merge here, but treat that as the
workflow rather than as a filter to route around. Report the branch name
and stop.
- Never push directly to {{blocked_branches}}, on any remote. A
`git-push-guard.sh` hook enforces that as a backstop, but treat it as the
workflow to follow rather than a filter to route around.
+8 -3
View File
@@ -1,9 +1,14 @@
# block_merge is off despite this being a pushing workflow: nothing here ever
# reaches a forge's PR interface, so there is no merge command to block.
requires =
push = on
blocked_branches = main master
block_merge = off
# This said "off" and gave a reason: nothing here ever reaches a forge's PR
# interface, so there is no merge command to block. The premise is false. What
# the briefing tells the agent to do and what the image puts on its PATH are
# different things - select forge = gitea and `gitea-pr merge` is present
# whatever the workflow says, because the CLI is installed by the forge, not
# by the workflow. This workflow's own briefing says "do not merge anything",
# and until now nothing held it to that.
block_merge = on
requires_worktree = on
autonomy = push-branch
briefing = briefing.md
+1 -1
View File
@@ -3,7 +3,7 @@ Git workflow - do this even though nothing stops you from doing otherwise:
anywhere: there is no remote destination configured for it, and
`git-push-guard.sh` blocks pushes as a backstop.
- Do not open pull requests and do not merge. Neither is part of this
workflow.
workflow, and a merge is refused here in any case.
- Report what you committed and stop. If the work needs to reach a remote,
say so plainly rather than attempting it - that is the operator's call and
needs a different workflow.
+6 -1
View File
@@ -3,7 +3,12 @@
requires =
push = off
blocked_branches =
block_merge = off
# On for the same reason branch-push's is: this workflow may be selected
# alongside a forge, its briefing says "do not open pull requests and do not
# merge", and the forge's CLI is on PATH regardless of what the workflow tells
# the agent to do. A briefing that states a rule while the manifest leaves the
# guard off is asserting something nothing holds.
block_merge = on
requires_worktree = off
autonomy = commit
briefing = briefing.md