Compare commits

...
Author SHA1 Message Date
branderson 011675ab8b Merge pull request 'Let the harness declare what it runs on the host' (#141) from harness-host-exec into harness-guard-registration
tests / runner-env (confined) (pull_request_target) Successful in 33s
tests / confined (proxy) (pull_request_target) Successful in 1m7s
tests / confined (static) (pull_request_target) Successful in 3m14s
tests / confined (providers) (pull_request_target) Successful in 3m39s
tests / privileged (pull_request_target) Successful in 4m12s
tests / runner-env (privileged) (pull_request_target) Successful in 36s
tests / confined (remote) (pull_request_target) Successful in 4m46s
tests / confined (project) (pull_request_target) Successful in 6m14s
tests / confined (audit) (pull_request_target) Successful in 7m56s
tests / confined (config) (pull_request_target) Successful in 8m47s
Reviewed-on: #141
Reviewed-by: branderson <brad@branderson.io>
2026-08-17 17:58:54 -07:00
branderson 7581ac0351 Let the harness declare what it runs on the host
tests / runner-env (confined) (pull_request_target) Successful in 37s
tests / confined (proxy) (pull_request_target) Successful in 1m9s
tests / confined (static) (pull_request_target) Successful in 3m23s
tests / confined (providers) (pull_request_target) Successful in 3m50s
tests / privileged (pull_request_target) Successful in 4m20s
tests / confined (remote) (pull_request_target) Successful in 4m52s
tests / runner-env (privileged) (pull_request_target) Successful in 35s
tests / confined (project) (pull_request_target) Successful in 6m23s
tests / confined (audit) (pull_request_target) Successful in 8m4s
tests / confined (config) (pull_request_target) Successful in 9m1s
The audit watched exactly one path, named statusline_path, reported it
with a label written into dejima-audit, and keyed it under a
`statusline:` id. All three assumed Claude Code.

The path, the id and the label are now the plugin's, and the mechanism -
snapshot, hash, stage, CRITICAL severity, the ack path - stays core's. A
harness declares its surfaces through emit_host_exec with fixed literals;
a harness with none is watched for none, rather than for the previous
harness's path.

Declared by the plugin rather than the manifest, and the reason is a
trust boundary rather than taste. `ack` recovers an id by stripping a
fixed label from the front of a finding, anchored deliberately: an
unanchored match lets chosen text derive an id in the wrong namespace,
and approving it baselines an id that never matches while the real
finding counts as acknowledged (#45). Those labels are a parser's
grammar. Provider directories are not in tcb_snapshot's list, so in the
self-mount case a session can edit a manifest without raising a
CRITICAL; a plugin is under bin/, which the audit does watch.

That choice also removes the migration this looked like it needed. The
namespace and leaf come from the harness, so the id stays
`statusline:statusline.sh` byte for byte and every approval an operator
already holds keeps matching. A test records one and asserts the
baseline line, so a future rename has to break it out loud.

The ack arm becomes an exact prefix strip over the declared labels
rather than a literal case arm - `"$body"` with the label removed
differs from `"$body"` only when the label really is at position 0 -
which also stops a label's own characters being read as globs.
2026-08-17 16:14:07 -07:00
branderson 02bda50b4d Let the harness install its own guards
tests / runner-env (confined) (pull_request_target) Successful in 1m4s
tests / confined (proxy) (pull_request_target) Successful in 1m7s
tests / confined (static) (pull_request_target) Successful in 3m22s
tests / confined (providers) (pull_request_target) Successful in 3m47s
tests / privileged (pull_request_target) Successful in 4m21s
tests / confined (remote) (pull_request_target) Successful in 4m53s
tests / runner-env (privileged) (pull_request_target) Successful in 35s
tests / confined (project) (pull_request_target) Successful in 6m24s
tests / confined (audit) (pull_request_target) Successful in 7m44s
tests / confined (config) (pull_request_target) Successful in 9m0s
install.sh knew ~/.claude/hooks and settings.json's hook schema: it
symlinked five files into one harness's directory and wrote four
registrations in that harness's JSON shape, for any operator who ran it.
An operator who had chosen another harness got their Claude Code config
edited anyway.

That work is now harness_register_guards in the plugin, and each guard
is gated on the manifest declaring it - a harness declaring only
bash-deny gets only that, and registering something it had not declared
would contradict the launch message from #138 that says it is missing.

Moved to after the starter config is written rather than before. That
config is where [core] harness comes from, and install.sh seeds it, so
on a fresh install the selection exists by the time registration reads
it. With no harness set - an operator upgrading from before it was
required - nothing is registered and the message says which key to set.

harness_plugin_load resolves the plugin from the library's own location
instead of the caller's SCRIPT_DIR, which bin/dejima sets to bin/ and
install.sh sets to the repo root.

deny-dangerous-bash.sh keeps the property that makes it different: it
has no DEJIMA check, so it applies to the operator's own host sessions,
and it is still only claimed when nothing else already provides it. The
comment now says why rather than leaving the next reader to notice the
missing check.
2026-08-17 14:12:00 -07:00
branderson 7174d1147f Merge pull request 'Declare which guards a harness can host, and say when it cannot' (#138) from harness-guards into harness-plugins
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / confined (proxy) (pull_request_target) Successful in 1m10s
tests / confined (static) (pull_request_target) Successful in 3m11s
tests / confined (providers) (pull_request_target) Successful in 3m45s
tests / privileged (pull_request_target) Successful in 4m21s
tests / confined (remote) (pull_request_target) Successful in 4m43s
tests / runner-env (privileged) (pull_request_target) Successful in 35s
tests / confined (project) (pull_request_target) Successful in 6m17s
tests / confined (audit) (pull_request_target) Successful in 7m39s
tests / confined (config) (pull_request_target) Successful in 8m25s
Reviewed-on: #138
Reviewed-by: branderson <brad@branderson.io>
2026-08-17 11:44:44 -07:00
branderson 7274236a7c Declare which guards a harness can host, and say when it cannot
tests / runner-env (confined) (pull_request_target) Successful in 38s
tests / confined (proxy) (pull_request_target) Successful in 1m10s
tests / confined (static) (pull_request_target) Successful in 3m8s
tests / confined (providers) (pull_request_target) Successful in 3m42s
tests / privileged (pull_request_target) Successful in 4m18s
tests / confined (remote) (pull_request_target) Successful in 4m41s
tests / runner-env (privileged) (pull_request_target) Successful in 35s
tests / confined (project) (pull_request_target) Successful in 6m11s
tests / confined (audit) (pull_request_target) Successful in 7m38s
tests / confined (config) (pull_request_target) Successful in 8m24s
The capability bar allows a harness to be supported at a smaller guard
set. Nothing made that visible: dejima applied four guard rails and
assumed every harness could host them, so a harness that could not
would have shipped three protections short with no one told.

`guards` in the manifest lists which of bash-deny, push-guard,
merge-guard and notify the harness hosts, the same declared-capability
shape the forge axis already uses for `capabilities`. A name outside the
known set is refused rather than treated as an unknown guard: a typo
would otherwise turn a guard off in silence, which is the failure the
declaration exists to prevent.

What a harness does not declare is named once at launch, with what each
guard costs. Nothing is printed when it hosts them all, so the message
stays a signal rather than a line to scroll past.

The notify registration moves into the plugin. How a harness is told to
run something when the agent wants attention is its own business -
Claude Code merges hook entries across settings layers, another may take
a config key - so the writing is the plugin's, while whether it happens
is core's and is gated on the declaration rather than on the plugin
happening to define the hook. A harness that forgot to declare it but
implemented it would otherwise get a guard the launch message had just
said it does not have.

The providers suite caught DEJIMA_KNOWN_GUARDS being defined in
dejima-harness-lib.sh while the validation that reads it lives in
dejima-provider-lib.sh: that suite sources only the latter, so it
validated against an empty set and rejected every guard there is. A
constant the validation depends on now lives where the validation does.
2026-08-17 00:50:22 -07:00
branderson 48bcd32c0e Merge pull request 'Take the harness's command and its flags from the manifest' (#137) from harness-wrapper into harness-plugins
tests / runner-env (confined) (pull_request_target) Successful in 37s
tests / confined (proxy) (pull_request_target) Successful in 1m9s
tests / confined (static) (pull_request_target) Successful in 3m15s
tests / confined (providers) (pull_request_target) Successful in 3m46s
tests / privileged (pull_request_target) Successful in 4m29s
tests / confined (remote) (pull_request_target) Successful in 4m35s
tests / runner-env (privileged) (pull_request_target) Successful in 34s
tests / confined (project) (pull_request_target) Successful in 6m7s
tests / confined (config) (pull_request_target) Successful in 7m45s
tests / confined (audit) (pull_request_target) Successful in 7m47s
Reviewed-on: #137
Reviewed-by: branderson <brad@branderson.io>
2026-08-16 23:39:55 -07:00
branderson 7695325c2e Take the harness's command and its flags from the manifest
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / confined (proxy) (pull_request_target) Successful in 1m10s
tests / confined (static) (pull_request_target) Successful in 3m11s
tests / confined (providers) (pull_request_target) Successful in 3m44s
tests / confined (remote) (pull_request_target) Successful in 4m38s
tests / privileged (pull_request_target) Successful in 4m48s
tests / runner-env (privileged) (pull_request_target) Successful in 36s
tests / confined (project) (pull_request_target) Successful in 6m6s
tests / confined (config) (pull_request_target) Successful in 7m50s
tests / confined (audit) (pull_request_target) Successful in 7m50s
The wrapper hardcoded --dangerously-skip-permissions, was COPYed to
/usr/local/bin/claude at build time, and exec'd a path under
npm-global. Core's own schema defaulted [core] command to the literal
`claude`, while the harness manifest's `command` key was read by
nothing at all. Between them, unattended operation was a property of
the image rather than of the selected harness.

Four manifest keys carry it now: `command` (which [core] command falls
back to), `command_path`, `no_confirm_args` and `settings_args`.
docker/harness-wrapper.sh names no harness and is installed under a
neutral name; entrypoint.sh links it into place as the harness's own
command, because the image is shared across harnesses and the selection
is per launch. The link is refused for anything but a bare name - it
becomes a path under /usr/local/bin, and the value arrives as
environment.

A harness that declares no no_confirm_args is refused at launch. R1 of
the capability bar is not a formality: a harness that stops to ask,
running against a container built on the premise that it does not,
presents as a hang rather than as a misconfiguration.

settings_args names /etc/dejima-settings.json in full rather than
templating it. The providers suite caught the first attempt, which used
{{settings_file}} - that syntax belongs to the briefing composer's
substitution, and a second mechanism that merely looks like it is worth
neither the confusion nor the whitelist entry. The path is dejima's own
fixed contract, so the manifest can say it.
2026-08-16 22:27:16 -07:00
branderson 0707cfc1dd Add the harness plugin tier, and move Claude Code's state mounts into it
tests / runner-env (confined) (pull_request_target) Successful in 34s
tests / confined (proxy) (pull_request_target) Successful in 1m5s
tests / confined (static) (pull_request_target) Successful in 2m45s
tests / confined (providers) (pull_request_target) Successful in 3m25s
tests / confined (remote) (pull_request_target) Successful in 4m0s
tests / privileged (pull_request_target) Successful in 4m8s
tests / runner-env (privileged) (pull_request_target) Successful in 34s
tests / confined (project) (pull_request_target) Successful in 5m16s
tests / confined (config) (pull_request_target) Successful in 6m46s
tests / confined (audit) (pull_request_target) Successful in 7m19s
Three tiers, and which one a thing belongs in is the design: core holds
the mechanism and every enforcement point, the manifest holds what can
be stated as a value, and a plugin holds the harness-specific remainder
as code. A feature goes in the manifest unless it demonstrably cannot -
`projects_dir` and `file_history_dir` are both paths, so both are
manifest keys, while the rule that a name in the first selects an entry
in the second is Claude Code's own and is the plugin.

Plugins live under bin/ rather than beside their manifest because
tcb_snapshot covers install.sh, bin/, hooks/, docker/ and test/ and not
the provider directories. Host-side plugin code under harnesses/ would
be code that runs on the host and that a session could edit in dejima's
own checkout without raising a CRITICAL. Under bin/ it is covered, and
"manifests are data, never executed" stays true word for word.

Sourced rather than invoked. They are trusted code, so the risk worth
designing against is three plugins implementing the security model three
ways, not one careless plugin - and sourcing lets a plugin use core's
primitives instead of re-deriving them. An invoked protocol would put a
new text parser on the launch path, which is where this repository's
bugs have been.

What replaces isolation is that a plugin computes and core enforces. A
plugin declares through emit_mount and emit_env, which validate at the
point of declaration: emit_mount runs the guarded-store refusal there,
so a harness state directory overlapping the approval, audit or epitaph
store is refused without each plugin remembering to check. A plugin
never touches extra_mounts, a plugin defines only hooks core calls, and
every harness has both a manifest and a plugin - all three held by
static tests rather than by convention.

The projects/ and file-history/ mounts move into
bin/harness/claude-code.sh unchanged in behaviour. The existing test
that drives a real launch and asserts on the mount arguments passes
untouched, which is what says the extraction was faithful; the two
static tests that grepped bin/dejima for the code now grep the plugin
for the same property.
2026-08-16 21:24:16 -07:00
branderson d49b9eaa08 Merge pull request 'State what a harness must be able to do to be supported' (#134) from harness-capability-bar into harness-decoupling
tests / runner-env (confined) (pull_request_target) Successful in 37s
tests / confined (proxy) (pull_request_target) Successful in 1m9s
tests / confined (static) (pull_request_target) Successful in 2m42s
tests / confined (providers) (pull_request_target) Successful in 3m24s
tests / confined (remote) (pull_request_target) Successful in 3m53s
tests / privileged (pull_request_target) Successful in 4m28s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (project) (pull_request_target) Successful in 5m5s
tests / confined (config) (pull_request_target) Successful in 6m32s
tests / confined (audit) (pull_request_target) Successful in 7m25s
Reviewed-on: #134
Reviewed-by: branderson <brad@branderson.io>
2026-08-16 20:35:51 -07:00
branderson 821e15283a State what a harness must be able to do to be supported
tests / runner-env (confined) (pull_request_target) Successful in 34s
tests / confined (proxy) (pull_request_target) Successful in 1m6s
tests / confined (static) (pull_request_target) Successful in 2m47s
tests / confined (providers) (pull_request_target) Successful in 3m28s
tests / confined (remote) (pull_request_target) Successful in 3m59s
tests / privileged (pull_request_target) Successful in 4m19s
tests / runner-env (privileged) (pull_request_target) Successful in 34s
tests / confined (project) (pull_request_target) Successful in 5m7s
tests / confined (config) (pull_request_target) Successful in 6m32s
tests / confined (audit) (pull_request_target) Successful in 7m27s
Support is per harness and deliberate, at the depth Claude Code has: the
same mounts, the same guards, the same audit surfaces. That is only
possible if the harness can host what those protections need, and
nothing said what that meant - so the first harness that could not host
one would have been argued about rather than measured.

Five requirements a harness cannot be supported without: unattended
execution, tool-call interception, host-controlled context at session
start, state dejima can locate or discard, and a credential it can
withhold. Three more that may be declared absent, with what is lost
named and said at launch, so a harness can be supported honestly at a
smaller guard set instead of shipped with protections quietly missing.

aider is the worked example of a refusal. It clears four of the five and
fails tool-call interception: it has no hook or event system, so the
push guard, the merge guard and the dangerous-command denylist have
nowhere to live and no equivalent to rebuild from. The container would
still bound it, but "supported" would mean something different for that
one harness than for every other.

The precondition section this replaces said the same thing about
unattended execution alone, which turned out to be one requirement of
five rather than the whole bar.
2026-08-16 20:10:19 -07:00
branderson eedf855bc4 Read a harness's config through the selected harness
tests / runner-env (confined) (pull_request_target) Successful in 58s
tests / confined (proxy) (pull_request_target) Successful in 1m27s
tests / confined (static) (pull_request_target) Successful in 3m5s
tests / confined (providers) (pull_request_target) Successful in 3m57s
tests / confined (remote) (pull_request_target) Successful in 4m17s
tests / confined (project) (pull_request_target) Successful in 5m22s
tests / confined (config) (pull_request_target) Successful in 7m3s
tests / confined (audit) (pull_request_target) Successful in 7m55s
tests / privileged (pull_request_target) Successful in 10m57s
tests / runner-env (privileged) (pull_request_target) Successful in 33s
`keys` is declared per harness, so reading one through a literal
[harness.<name>] section makes it a declaration only one harness can
ever be read through: a second harness gets selected, gets validated,
and is then handed the first one's paths with nothing erroring
anywhere. Three call sites did that - two in bin/dejima-audit, one in
bin/dejima-epitaph. `harness_config_get` resolves whichever [core]
harness names, and a static test holds the rule.

The two remaining mentions in bin/dejima are message text in the
legacy-environment-variable detector, whose own test requires each entry
to name a real schema key. They point a reader at a config setting; they
read nothing.

The audit suite found something the refactor would otherwise have
shipped quietly. Its fixture set [harness.claude-code] keys but never
[core] harness, so with the paths resolved properly the statusline
surface stopped being watched and four tests failed. That is a real
state, not a fixture artefact: dejima refuses to start without a
harness, but the audit can be driven by hand against a config dejima
would reject, and it would have reported a clean run over a surface it
never examined. It now says so on snapshot and compare. The fixture
gains the [core] harness every launchable config has, matching what
config.bats already does for the same reason.
2026-08-16 18:08:03 -07:00
branderson 4df8ca1dfa Inventory what couples dejima to Claude Code
tests / confined (audit) (pull_request_target) Successful in 7m16s
tests / confined (project) (pull_request_target) Successful in 5m10s
tests / confined (providers) (pull_request_target) Successful in 3m30s
tests / confined (proxy) (pull_request_target) Successful in 1m7s
tests / confined (remote) (pull_request_target) Successful in 3m57s
tests / confined (static) (pull_request_target) Successful in 2m44s
tests / privileged (pull_request_target) Successful in 4m18s
tests / runner-env (confined) (pull_request_target) Successful in 31s
tests / runner-env (privileged) (pull_request_target) Successful in 31s
tests / confined (config) (pull_request_target) Failing after 6m39s
A harness manifest declares four things. Everything else dejima does for
Claude Code is written against Claude Code, and nothing said where or how
much - so the first question of a second harness, "what has to be built",
had no answer short of reading the tree.

docs/development.md gains "Adding a new harness": the precondition, what
the manifest already covers, and a file-by-file inventory of what does
not - the image and the wrapper that forces the permission bypass, the
credential proxy's shape, the egress entries, the hook protocol and how
the briefing rides on it, the enumerated ~/.claude mounts, the project
slug scheme reproduced in three places, the transcript and compaction
formats, and the audit surfaces.

Two findings are worth more than the list. Five call sites in bin/ read
`harness.claude-code` by literal name rather than the selected harness,
so `keys` is declared per harness and consumed as though there were only
one; that is the first thing to fix, because until it is a second
harness silently inherits Claude Code's paths. And `statusLine.command`
runs on the host, so the question "what does this harness execute
outside the container" belongs in the audit from a harness's first
release rather than after someone finds it.

A static test holds the call-site count to what the audit lists, so
fixing one forces the row to go with it.
2026-08-13 15:37:45 -07:00
branderson 0b8d1e5817 Merge pull request 'Say what dejima is doing after the session ends' (#126) from verbose-exit into development
Reviewed-on: #126
Reviewed-by: branderson <brad@branderson.io>
2026-08-13 08:45:33 -07:00
branderson 0ac60b7a59 Merge pull request 'Create an epitaph directory only when something is kept in it' (#125) from epitaph-empty-dirs into development
Reviewed-on: #125
Reviewed-by: branderson <brad@branderson.io>
2026-08-13 08:43:24 -07:00
branderson 34b0b1bec2 Say what dejima is doing after the session ends
tests / confined (proxy) (pull_request_target) Successful in 1m3s
tests / confined (providers) (pull_request_target) Successful in 3m8s
tests / runner-env (confined) (pull_request_target) Successful in 31s
tests / confined (config) (pull_request_target) Successful in 5m50s
tests / confined (static) (pull_request_target) Successful in 2m29s
tests / confined (project) (pull_request_target) Successful in 4m40s
tests / confined (audit) (pull_request_target) Successful in 6m40s
tests / confined (remote) (pull_request_target) Successful in 3m35s
tests / privileged (pull_request_target) Successful in 3m58s
tests / runner-env (privileged) (pull_request_target) Successful in 30s
Between the container exiting and the shell prompt returning, dejima
audits every host surface the session could have written to and reads
back the transcripts it left. On a real project that is seconds of an
entirely silent terminal, which reads as a hung one - and the operator's
answer to a hung terminal is Ctrl-C, during the audit.

Each of the two tasks now names itself as it starts. Only once a
snapshot exists, which is the same condition `dejima-audit compare` uses
to decide it has anything to do: an exit before the session started is
instant, and its last line is the error that caused it, which an
announcement would push off the screen.

Epitaph moves ahead of the audit while its output is being considered.
It prints a line whenever it keeps a summary, and that line was landing
under the audit verdict - the one thing in the trap that is deliberately
last, because the operator reads bottom-up from their prompt.
2026-08-12 19:14:05 -07:00
branderson 38045f87c8 Create an epitaph directory only when something is kept in it
tests / runner-env (confined) (pull_request_target) Successful in 34s
tests / confined (proxy) (pull_request_target) Successful in 1m10s
tests / confined (static) (pull_request_target) Successful in 2m28s
tests / confined (providers) (pull_request_target) Successful in 3m12s
tests / confined (remote) (pull_request_target) Successful in 3m35s
tests / privileged (pull_request_target) Successful in 4m8s
tests / runner-env (privileged) (pull_request_target) Successful in 30s
tests / confined (project) (pull_request_target) Successful in 4m40s
tests / confined (config) (pull_request_target) Successful in 5m52s
tests / confined (audit) (pull_request_target) Successful in 6m41s
A workspace's slugs include one per live worktree, and worktrees have
transcripts. What they mostly do not have is a compaction summary in any
of them, so a directory made per slug up front left the store reading as
one entry per worktree with nothing in any of them.

The directory is now made by the first write into it. An `rmdir` per
slug clears the empty ones earlier runs left behind; it cannot touch a
slug that kept anything.

Also corrects dejima-slug-lib.sh's header, which still named
bin/dejima-audit as the second caller. The cross-project finding that
used workspace_slugs was removed in #115; bin/dejima-epitaph is what
shares the definition now.
2026-08-12 19:10:30 -07:00
branderson d7d808425c Merge remote-tracking branch 'origin/main' into development
# Conflicts:
#	docs/development.md
2026-08-12 18:57:56 -07:00
branderson 5b8f6e71be Merge pull request 'Add epitaph: keep what a session leaves when its context dies' (#123) from compaction-summaries into development
Reviewed-on: #123
Reviewed-by: branderson <brad@branderson.io>
2026-08-12 17:19:45 -07:00
branderson ef8cc12d9f Add epitaph: keep what a session leaves when its context dies
tests / runner-env (confined) (pull_request_target) Successful in 32s
tests / confined (proxy) (pull_request_target) Successful in 1m8s
tests / confined (static) (pull_request_target) Successful in 2m45s
tests / confined (providers) (pull_request_target) Successful in 3m30s
tests / confined (remote) (pull_request_target) Successful in 3m59s
tests / privileged (pull_request_target) Successful in 4m18s
tests / runner-env (privileged) (pull_request_target) Successful in 31s
tests / confined (project) (pull_request_target) Successful in 5m8s
tests / confined (config) (pull_request_target) Successful in 6m28s
tests / confined (audit) (pull_request_target) Successful in 6m58s
Claude Code writes a summary each time a session is compacted. It is the only
durable account of what a long session worked out, and until now it went with
the container.

bin/dejima-epitaph runs host-side from the exit trap, reads the workspace's
own transcripts, and writes each summary to [epitaph] dir with its trigger
and token counts. Nothing in the container takes part, and the store is never
mounted - so a session can neither read what earlier sessions concluded nor
edit what this one is about to record. That is the same arrangement as the
audit store, for the reason #104 established.

Nothing loads an epitaph back into a later session, deliberately. A file a
session writes and a later session reads is the channel #104 closed; this
one goes to the operator, who can promote anything worth keeping into
.dejima.md, where it is reviewed like code.

The transcript it reads IS session-writable, so an epitaph records what was
there rather than vouching for it. The header says so.

[epitaph] loki, off by default, also pushes each one to [logging] loki_url -
host-side rather than through the container's log shipper, so the shipping
path keeps the same property.
2026-08-12 11:25:26 -07:00
branderson d5b9830631 Merge pull request 'Open branches against development, not main' (#124) from development-branch-process into development
Reviewed-on: #124
Reviewed-by: branderson <brad@branderson.io>
2026-08-12 01:43:17 -07:00
branderson bd0f5ddab6 Merge pull request 'Make the exit-time audit clearable in one sitting' (#115) from audit-usability into main
Reviewed-on: #115
Reviewed-by: branderson <brad@branderson.io>
2026-08-12 01:41:21 -07:00
branderson d4a45cc3f7 Open branches against development, not main
tests / runner-env (confined) (pull_request_target) Successful in 34s
tests / confined (proxy) (pull_request_target) Successful in 1m5s
tests / confined (static) (pull_request_target) Successful in 2m42s
tests / confined (providers) (pull_request_target) Successful in 3m31s
tests / confined (remote) (pull_request_target) Successful in 3m52s
tests / privileged (pull_request_target) Successful in 4m23s
tests / runner-env (privileged) (pull_request_target) Successful in 32s
tests / confined (project) (pull_request_target) Successful in 5m6s
tests / confined (config) (pull_request_target) Successful in 6m24s
tests / confined (audit) (pull_request_target) Successful in 7m8s
main stays releasable while work lands quickly. development merges to main
periodically; nothing else about the workflow changes.

Stated in the three places it gets looked for - .dejima.md for an agent
working here, CONTRIBUTING.md for a reader, docs/development.md for the
mechanics - with a test that all three say it, because a convention with one
stale copy is a convention someone follows wrongly.

The mistake worth naming is gitea-pr's base: it defaults to the repository's
default branch, so --base development has to be passed. It is recoverable, a
pull request's base can be changed afterwards, so the instruction is to read
what gitea-pr printed rather than to be careful.

Nothing enforces the target. git-push-guard blocks the branches a workflow
names, and that list is workflow-wide rather than per-project, so adding
development would impose it on every dejima user - most of whom have no such
branch. Said plainly in docs/development.md rather than left to be discovered.

CI needed no change: tests.yml triggers on pull_request_target with no branch
filter, so it already runs whatever the base is.
2026-08-12 00:34:32 -07:00
branderson 54962bb165 Keep the audit edge case out of the README
tests / runner-env (confined) (pull_request_target) Successful in 33s
tests / confined (proxy) (pull_request_target) Successful in 1m7s
tests / confined (static) (pull_request_target) Successful in 2m44s
tests / confined (providers) (pull_request_target) Successful in 3m33s
tests / confined (remote) (pull_request_target) Successful in 3m55s
tests / confined (config) (pull_request_target) Successful in 6m27s
tests / confined (audit) (pull_request_target) Successful in 7m20s
tests / privileged (pull_request_target) Successful in 4m24s
tests / runner-env (privileged) (pull_request_target) Successful in 34s
tests / confined (project) (pull_request_target) Successful in 5m9s
The deleted-surface behaviour is audit mechanics, not something a reader
needs on first contact with the project. It moves to docs/security.md beside
the rest of the approve/ignore/skip rules, which is where someone asking that
question already is.

The README keeps one line: show indexes, ack walks it.
2026-08-12 00:06:32 -07:00
branderson 1efcc42294 Make the exit-time audit clearable in one sitting
Five problems, one of which caused another.

A finding whose file had since been deleted could not be approved - there is
no content to pin a `safe` entry to - so `ack` refused. But refusing meant
crit_acked never reached crit_seen, the report kept -CRITICAL.txt, and
select_report prefers CRITICAL: one deleted file pinned the audit to a report
no decision could resolve and hid every other report behind it, permanently.
It is cleared now. Nothing is recorded, because nothing unmatchable should
reach the baseline, but the finding is done with - a surface that does not
exist does not run, and if it comes back it comes back as new.

`ack` walks every report in one run, asking after each whether to continue,
and says up front how many are waiting. It used to walk one and exit with no
indication there were more, so clearing a backlog took as many runs as there
were reports and read as the tool being stuck on the first.

The cross-project finding is gone. It reported a poisoning route a session
could once take, when the whole ~/.claude/projects tree was mounted; since
belongs to another session doing its own work - and with several running it
reported all of them, on every report. Detection that cannot observe what it
was built for, and fires constantly on what it was not, trains an operator to
skim.

TCB findings now split on whether git can see the change. tcb_snapshot only
populates when the workspace IS dejima's own repo, so an ordinary change is
already in the working tree about to be read in `git status` and in the pull
request - one CRITICAL per file duplicated a stronger control and made
developing dejima unusable. Those collapse to one INFO line. A change git
CANNOT see stays CRITICAL, because nothing else catches it: .gitignore,
.git/info/exclude, assume-unchanged and skip-worktree all hide live code that
runs on the next launch.

`show` indexes the reports - how many, which are CRITICAL, which `ack` will
open - instead of dumping one report's contents including findings already
acknowledged. `show --full` prints one.

Two bugs found while doing this, both mine, both fixed with the tests that
caught them: the `reviewed` marker recorded a filename before the report shed
its -CRITICAL suffix, so it never matched afterwards; and a cleared finding
was neither baseline-suppressed nor un-ackable, so it re-presented on every
later run - the same loop the clear was meant to end.
2026-08-12 00:06:16 -07:00
branderson 13e8c39d0f Merge pull request 'Require [core] harness rather than defaulting to claude-code' (#121) from require-harness into main
Reviewed-on: #121
Reviewed-by: branderson <brad@branderson.io>
2026-08-11 23:48:01 -07:00
branderson 1ba13df4df Merge pull request 'Tell agents here what the compression passes keep cutting' (#122) from dejima-md-brevity into main
Reviewed-on: #122
Reviewed-by: branderson <brad@branderson.io>
2026-08-11 23:21:43 -07:00
branderson 779a644e63 Tell agents here what the compression passes keep cutting
tests / runner-env (confined) (pull_request_target) Successful in 33s
tests / confined (proxy) (pull_request_target) Successful in 1m7s
tests / confined (static) (pull_request_target) Successful in 2m42s
tests / confined (providers) (pull_request_target) Successful in 3m32s
tests / confined (remote) (pull_request_target) Successful in 3m54s
tests / privileged (pull_request_target) Successful in 4m28s
tests / runner-env (privileged) (pull_request_target) Successful in 32s
tests / confined (project) (pull_request_target) Successful in 5m2s
tests / confined (config) (pull_request_target) Successful in 6m18s
tests / confined (audit) (pull_request_target) Successful in 7m13s
The prose in this repository has been through two compression passes, and
both cut the same things: how a bug was found, consequence chains followed
past the first step, restatements of the code below, meta-commentary, and
numbers that were wrong within a month. Writing that guidance down is
cheaper than running the pass again.

Framed as keep/cut rather than "be concise", which is not actionable, and
explicitly not an argument for thinner documentation - the README's note on
voice prefers thorough over polished, and this has to not contradict it. The
distinction is padding, not length: the sections named at the end are long
because the argument is the content, and they survive every pass.
2026-08-11 22:49:07 -07:00
branderson 799dd3b6eb Require [core] harness rather than defaulting to claude-code
tests / runner-env (confined) (pull_request_target) Successful in 32s
tests / confined (proxy) (pull_request_target) Successful in 1m4s
tests / confined (static) (pull_request_target) Successful in 2m38s
tests / confined (providers) (pull_request_target) Successful in 3m27s
tests / confined (remote) (pull_request_target) Successful in 3m49s
tests / privileged (pull_request_target) Successful in 4m19s
tests / runner-env (privileged) (pull_request_target) Successful in 32s
tests / confined (project) (pull_request_target) Successful in 5m0s
tests / confined (config) (pull_request_target) Successful in 6m17s
tests / confined (audit) (pull_request_target) Successful in 7m4s
The default was fine while claude-code was the only harness and stops being
fine the moment there is a second: an operator who never wrote the key made
no choice, and a release changing the default would move them silently on
upgrade.

dejima now refuses, naming the file, the line to add, and what is available.
install.sh writes it uncommented in the starter config, the same treatment
forge and workflow already get, for the reason already written there.

--print-config keeps working with it unset, which took one fix: the schema
enumeration resolved [harness.<name>] keys through the selected name and
printed "invalid harness name:" for an empty one. An unselected axis
contributes no keys.

Every fixture that launches now selects a harness, as a real config must.
config.bats' write_config seeds one only when the caller has not, matched as
a key assignment rather than as the word anywhere in the arguments - a
[mounts] path containing "harness" otherwise suppressed it.
2026-08-11 20:38:28 -07:00
branderson 26bc014b3f Merge pull request 'Make the GitHub reachability probe retry, and say why when it fails' (#119) from runtime-github-flake into main
Reviewed-on: #119
Reviewed-by: branderson <brad@branderson.io>
2026-08-11 18:36:50 -07:00
branderson 0eafdf1d78 Merge branch 'main' into runtime-github-flake
tests / runner-env (confined) (pull_request_target) Successful in 35s
tests / confined (proxy) (pull_request_target) Successful in 1m10s
tests / confined (static) (pull_request_target) Successful in 2m40s
tests / confined (providers) (pull_request_target) Successful in 3m25s
tests / confined (remote) (pull_request_target) Successful in 3m48s
tests / confined (config) (pull_request_target) Successful in 6m0s
tests / privileged (pull_request_target) Successful in 4m14s
tests / runner-env (privileged) (pull_request_target) Successful in 33s
tests / confined (project) (pull_request_target) Successful in 4m53s
tests / confined (audit) (pull_request_target) Successful in 6m55s
2026-08-11 18:28:10 -07:00
branderson 1f92e7a8aa Merge pull request 'Say who wrote the prose' (#120) from writing-note into main
Reviewed-on: #120
Reviewed-by: branderson <brad@branderson.io>
2026-08-11 02:54:14 -07:00
branderson c5c23682af Say who wrote the prose
tests / runner-env (confined) (pull_request_target) Successful in 1m39s
tests / confined (proxy) (pull_request_target) Successful in 2m11s
tests / confined (static) (pull_request_target) Successful in 3m47s
tests / confined (providers) (pull_request_target) Successful in 4m37s
tests / confined (remote) (pull_request_target) Successful in 4m57s
tests / privileged (pull_request_target) Successful in 5m28s
tests / runner-env (privileged) (pull_request_target) Successful in 1m40s
tests / confined (project) (pull_request_target) Successful in 6m8s
tests / confined (config) (pull_request_target) Successful in 7m21s
tests / confined (audit) (pull_request_target) Successful in 8m15s
The repository argues at length in a voice that is not its maintainer's, and
until now nothing said so. A reader's default assumption about a README is
that the person on the copyright line wrote it, and here that is not true.

The note is the maintainer's own words, as a quote. The first version of this
was written about the AI voice in the AI voice, which is a joke the reader
gets to have at the project's expense rather than with it.

Placed above the "Developed in dejima" badge, which now closes the opening:
the note says what the badge means for the words, so it reads before rather
than after it.

Names the contrast with ATTRIBUTION.md, which states the artwork is
human-made with no AI tools. The two claims are opposite and both true, so
stating one without the other invites the wrong inference about whichever is
missing.

Pinned by a test: this is the kind of line that goes quietly in a tidy-up,
and nothing else in the repository makes its absence visible.
2026-08-11 01:30:38 -07:00
branderson 3825d23074 Merge pull request 'Say why GitHub push is blocked, not just how' (#116) from github-push-why into main
Reviewed-on: #116
Reviewed-by: branderson <brad@branderson.io>
2026-08-10 22:37:55 -07:00
branderson 1140ef056d Stop describing the unsolicited pull request as possibly helpful
tests / confined (proxy) (pull_request_target) Successful in 2m44s
tests / runner-env (confined) (pull_request_target) Successful in 1m39s
tests / confined (static) (pull_request_target) Successful in 3m46s
tests / confined (providers) (pull_request_target) Successful in 4m37s
tests / confined (remote) (pull_request_target) Successful in 4m59s
tests / privileged (pull_request_target) Successful in 5m27s
tests / confined (project) (pull_request_target) Successful in 6m5s
tests / runner-env (privileged) (pull_request_target) Successful in 1m39s
tests / confined (config) (pull_request_target) Successful in 7m19s
tests / confined (audit) (pull_request_target) Successful in 8m12s
"Nothing about that is an attack, the change might even be good" framed an
agent pushing to a stranger's repository as a good deed with bad timing,
needing only the operator's sign-off. docs/responsible-use.md says the
opposite and says it at length: maintainers are already triaging a flood of
unsolicited AI-generated pull requests, and adding to it is a cost imposed on
someone who did not ask. Two documents in one repository should not disagree
about that.

It now says the block exists so the push cannot happen, not so it can be
approved first, and points at the fork-and-review workflow as what to do
instead. The "not an attack" clause stays, because the misreading this whole
section exists to correct is that the block is containment - but it no longer
slides from "not an attack" to "therefore harmless".

The test pins the corrected framing rather than the phrasing, so a future
edit that softens it back fails.
2026-08-10 22:15:35 -07:00
branderson 3b1a7098d9 Say why GitHub push is blocked, not just how
The section described the mechanism - GitHub allowlisted on 443, port 22
unreachable - and left the reason to be inferred. An agent working on
dejima-ci-hardener inferred the wrong one: that it is containment against a
hostile session. It is not, and reading it that way makes the default look
like something to switch off once the "real" protection is in place.

The principle, in the words dejima-ci-hardener settled on so the two do not
drift: a session that is not being used to push to a forge should not be able
to push to it. Your configured forge is the one you chose. GitHub is where
everything else lives, so that is where the rule bites by default.

The case is an eager agent rather than a compromised one - it finds a real
bug in an unrelated project and helpfully pushes a fix to somebody else's
repository. Nothing about that is an attack, the change might even be good,
and it is still a decision that was the operator's to make.

Closing the CI chain follows from the principle rather than being the reason
for it, and the docs now say so in that order.
2026-08-10 22:15:35 -07:00
branderson 8cfd763b79 Merge pull request 'Compress the longest comment blocks, keep what they were for' (#113) from comment-slim into main
Reviewed-on: #113
Reviewed-by: branderson <brad@branderson.io>
2026-08-10 22:13:00 -07:00
branderson a63c964df9 Merge pull request 'Tell the session the parts of the policy it can act on' (#118) from github-push-briefing into main
Reviewed-on: #118
Reviewed-by: branderson <brad@branderson.io>
2026-08-10 22:00:04 -07:00
branderson fa1d67018b Make the GitHub reachability probe retry, and say why when it fails
tests / confined (proxy) (pull_request_target) Successful in 2m14s
tests / runner-env (confined) (pull_request_target) Successful in 1m40s
tests / confined (static) (pull_request_target) Successful in 3m42s
tests / confined (providers) (pull_request_target) Successful in 4m15s
tests / confined (remote) (pull_request_target) Successful in 4m51s
tests / privileged (pull_request_target) Successful in 5m29s
tests / confined (project) (pull_request_target) Successful in 6m2s
tests / runner-env (privileged) (pull_request_target) Successful in 1m40s
tests / confined (config) (pull_request_target) Successful in 7m12s
tests / confined (audit) (pull_request_target) Successful in 8m9s
It failed once on CI and left nothing to diagnose: `[ "$status" -eq 0 ]` and
no curl exit code, no HTTP status, no way to separate DNS from the ipset from
the far end. Working out that it was transient took reading the rest of the
run rather than the failure itself.

The rest of that run is what settled it. Tests 12, 13, 14 and 32 passed, and
12 reaches GitHub on port 22 through the push grant - which cannot happen
unless github-domains holds real addresses. So the firewall was correct and
one five-second connect to a live third-party service was not, which is a
thing that will happen again.

Three attempts, and only because this is a positive assertion: more attempts
can only turn a pass into a pass. The unreachability assertions either side
are deliberately left alone - retrying one of those could hide a hole that
opens intermittently, which is the opposite trade.

On failure it now prints the exit code and the last output, and says how to
read them: 6 is DNS, 7 or 28 is the route or the firewall, and an HTTP code
means the connection worked and something else is wrong.
2026-08-10 21:54:12 -07:00
branderson bd21eb5db0 Tell the session the parts of the policy it can act on
tests / confined (proxy) (pull_request_target) Successful in 2m13s
tests / runner-env (confined) (pull_request_target) Successful in 1m38s
tests / confined (providers) (pull_request_target) Successful in 4m13s
tests / confined (config) (pull_request_target) Successful in 6m37s
tests / confined (project) (pull_request_target) Successful in 5m38s
tests / confined (static) (pull_request_target) Successful in 3m36s
tests / confined (audit) (pull_request_target) Successful in 7m38s
tests / confined (remote) (pull_request_target) Successful in 4m46s
tests / privileged (pull_request_target) Successful in 5m31s
tests / runner-env (privileged) (pull_request_target) Successful in 1m40s
docs/responsible-use.md is mostly addressed to the operator - which
repository to point the sandbox at, what else is in the directory, what to
merge. Three of its points are things the agent itself decides, and it only
knows them if it is told.

When GitHub push is enabled - by the flag, or by forge = github under a
pushing workflow - the briefing says the grant is for the repository the
session was pointed at and nothing else, and that an unsolicited pull request
elsewhere is a cost imposed on maintainers rather than a contribution. The
default exists for that reason, so lifting it hands over the ability without
the reason unless something supplies it.

Two more go to every session, since neither depends on a forge. A domain can
be an action rather than a source - a deploy hook or a production API acts
the moment it is called, with no diff to review afterwards - so an agent
should not ask for one to finish a task. And the project directory is mounted
whole, so it can hold a .env or a dump that is not code: say so, do not open
or send it.

Kept to eight lines between them. This text is in every user's context on
every launch, and the scope-selection advice already there covers the rest of
the allowlist point.

.dejima.md gets the two that bind an agent working ON dejima, where there is
no global context cost: .dejima/allowed-domains is read from origin/main by
dejima-ci-hardener and treated as reviewed, so a domain added there widens
every future CI job; and security here is the product rather than a later
pass.
2026-08-10 20:47:45 -07:00
branderson bc05990e33 Fix five README and ATTRIBUTION points, and compress three more doc sections
tests / runner-env (confined) (pull_request_target) Successful in 1m39s
tests / confined (proxy) (pull_request_target) Successful in 2m11s
tests / confined (static) (pull_request_target) Successful in 3m24s
tests / confined (providers) (pull_request_target) Successful in 3m52s
tests / confined (remote) (pull_request_target) Successful in 4m28s
tests / confined (project) (pull_request_target) Successful in 5m27s
tests / confined (config) (pull_request_target) Successful in 6m24s
tests / runner-env (privileged) (pull_request_target) Successful in 1m37s
tests / confined (audit) (pull_request_target) Successful in 7m27s
tests / privileged (pull_request_target) Successful in 5m36s
README:
- The Loki bullet read as though shipping were the default. It is off unless
  [logging] loki_url points at an instance of your own, and there is no
  endpoint of ours in it - which a reader who does not know Loki cannot be
  expected to assume.
- "So this one is work on the runner" -> "Preventing this requires hardening
  your CI runner".
- New bullet for the ~/.claude isolation. A session gets its own project's
  transcripts, memory and edit history and nothing else. That is a property
  users should be told about rather than a fix buried in security.md.
- "One maintainer, no support" moves below the feature list. It interrupted
  the run from what dejima is to what it ships, and reads better once the
  reader knows what is on offer.

ATTRIBUTION.md scopes the trademark claim to the software. dejima is a real
island in Nagasaki harbour, rebuilt and standing today, and a file that reads
as claiming the name of a real place is both wrong and rude. A mark attaches
to particular goods and services, never to a word in the abstract, so the
terms now say so and disclaim the historical and geographic senses outright.
The artwork restrictions stay absolute; the name restriction is about being
taken for this project, which is the only sense in which a name can be
claimed.

Also compressed: tarball dependencies, ci.md's runner-reachability section,
and providers.md's validation rules. Net -314 lines overall.
2026-08-10 20:29:00 -07:00
branderson e837634c1f docs: compress the audit, credential-proxy and notification sections
Same rule as the comment passes. The exit-time audit section was half of
security.md and had grown a second explanation of itself alongside the first:
the enumerated exec keys, the withholding rules and the multi-value git config
case all stay, since each names a decision someone would otherwise undo. The
retelling around them went.

The test-suite bullet no longer counts tests. "Over 300 bats tests across six
files" was wrong within a week of being written and drifts every time a suite
is added, which is the argument against numbers in prose that nothing checks.

Rebased onto main, taking the README tagline edit and #112's worktree
paragraph, which landed in a bullet this pass rewrote. Both are kept - the
worktree text is compressed to match the surrounding register rather than
dropped.
2026-08-10 20:28:33 -07:00
branderson 283527fc6d docs: compress the exit-time audit section 2026-08-10 20:28:33 -07:00
branderson efb92cb703 Compress another fourteen comment blocks
Second pass, same rule: the fact that stops a regression stays, the retelling
goes. Net -272 across 21 files now.

Mostly file headers this time, which had drifted into explaining the design
twice - once to justify it and once to describe it. dejima-ttyd's flag notes,
merge-guard's, gitea-actions', the config schema's, and the two Dockerfile
blocks all say the same things in half the lines.

The load-bearing detail is untouched: --check-origin's WebSocket
same-origin argument, reqOwner() being unfixable by widening a token, the
port-443 restriction being the read-only guarantee, and the ERR trap's reason
for existing all read the same.
2026-08-10 20:28:32 -07:00
branderson 77a298264c Compress the longest comment blocks, keep what they were for
Net -195 lines across 13 files. Every comment here still says the thing that
stops the next person reinstating the bug: the issue number, the failure mode,
the reason a check is written the way it is. What went is the narrative around
it - the retelling of how a bug was found, consequence chains spelled out to
their third step, restatements of what the code above already shows.

Nothing was cut because it was long. The blocks that survive at length are the
ones where the reasoning is the content: the O_NOFOLLOW open in
dejima-project-lib.sh, the id-derivation anchoring in dejima-audit, the
enumerated ~/.claude mounts.

Two operator-environment details are gone: a directory size and checkout count
from the machine this was written on, in bin/dejima and docs/security.md. They
were useful for judging severity in a handoff and have no business in a public
repository - the same distinction dejima-ci-hardener's addresses.bats exists
to keep.
2026-08-10 20:28:32 -07:00
branderson d31c707217 Merge pull request 'Say plainly that this is unreviewed early development' (#117) from maturity-disclaimer into main
Reviewed-on: #117
Reviewed-by: branderson <brad@branderson.io>
2026-08-10 20:17:19 -07:00
branderson 0e09060555 Say plainly that this is unreviewed early development
tests / confined (proxy) (pull_request_target) Successful in 2m10s
tests / runner-env (confined) (pull_request_target) Successful in 1m38s
tests / confined (static) (pull_request_target) Successful in 3m35s
tests / confined (providers) (pull_request_target) Successful in 4m2s
tests / confined (remote) (pull_request_target) Successful in 4m38s
tests / privileged (pull_request_target) Successful in 5m15s
tests / confined (project) (pull_request_target) Successful in 5m45s
tests / runner-env (privileged) (pull_request_target) Successful in 1m39s
tests / confined (config) (pull_request_target) Successful in 6m53s
tests / confined (audit) (pull_request_target) Successful in 7m56s
The no-support notice said nothing was promised, which reads as a statement
about responsiveness rather than about maturity. Someone could take it at
face value and still conclude the software itself is finished.

It is not. Nothing here has been through outside review, an external audit,
or adversarial testing by anyone but its author. The reasoning is one
person's and the tests assert what that person thought to check, which is a
weaker thing than it can look like from a repository that argues at length
for its own security properties.

In all three places a reader lands: the README opening, above the no-support
block; SECURITY.md, as its own section ahead of the support and warranty
terms, since that file is canonical for the organisation; and the top of
docs/security.md, which is a thousand lines of confident claims and the place
over-confidence is most likely to take hold.

Pinned by a test. This is the first line that gets quietly dropped as a
project starts to feel established, and dropping it should take a deliberate
edit to a test rather than an oversight.
2026-08-10 19:51:38 -07:00
branderson e7f5a9b541 Merge pull request 'Say that dejima is starting, and wear the badge' (#114) from launch-progress into main
Reviewed-on: #114
Reviewed-by: branderson <brad@branderson.io>
2026-08-10 18:28:26 -07:00
branderson 54b00d73a6 Use the wordmark's own green in the badge
tests / confined (proxy) (pull_request_target) Successful in 2m7s
tests / runner-env (confined) (pull_request_target) Successful in 1m37s
tests / confined (providers) (pull_request_target) Successful in 3m46s
tests / confined (project) (pull_request_target) Successful in 5m10s
tests / confined (config) (pull_request_target) Successful in 6m16s
tests / confined (audit) (pull_request_target) Successful in 7m24s
tests / runner-env (privileged) (pull_request_target) Successful in 1m38s
tests / confined (static) (pull_request_target) Successful in 3m27s
tests / confined (remote) (pull_request_target) Successful in 4m29s
tests / privileged (pull_request_target) Successful in 5m19s
#98971a was gruvbox's faded green, chosen from the palette rather than from
the mark. Sampling the wordmark gives #8ec07c for the island tile, so the two
now agree. Dark text on it is 7.0:1 rather than 4.8:1.
2026-08-10 14:52:43 -07:00
branderson ecb886f658 Move the badge to the end of the opening, and point it at dejima
tests / confined (remote) (pull_request_target) Failing after 1m18s
tests / runner-env (confined) (pull_request_target) Successful in 1m35s
tests / confined (proxy) (pull_request_target) Successful in 2m7s
tests / confined (static) (pull_request_target) Successful in 3m19s
tests / confined (providers) (pull_request_target) Successful in 3m44s
tests / privileged (pull_request_target) Successful in 5m3s
tests / confined (project) (pull_request_target) Successful in 5m9s
tests / runner-env (privileged) (pull_request_target) Successful in 1m34s
tests / confined (config) (pull_request_target) Successful in 6m3s
tests / confined (audit) (pull_request_target) Successful in 7m6s
It sat under the wordmark, where two marks stacked read as branding rather
than as a statement about the project. Last thing before "The name" puts it
after the reader knows what dejima is, which is where the badge means
something.

The link goes to the project, not the badge repository - what a reader wants
from it is dejima, and that is what the badge repo already tells everyone
else to link to.
2026-08-10 14:51:15 -07:00
branderson aa11bdd27b Say that dejima is starting, and wear the badge
tests / confined (proxy) (pull_request_target) Successful in 2m13s
tests / confined (static) (pull_request_target) Successful in 3m35s
tests / confined (providers) (pull_request_target) Successful in 4m6s
tests / confined (remote) (pull_request_target) Successful in 4m42s
tests / runner-env (privileged) (pull_request_target) Successful in 1m36s
tests / confined (project) (pull_request_target) Successful in 5m44s
tests / confined (config) (pull_request_target) Successful in 6m47s
tests / confined (audit) (pull_request_target) Successful in 7m48s
tests / runner-env (confined) (pull_request_target) Successful in 1m40s
tests / privileged (pull_request_target) Successful in 5m13s
Everything between the command and the container's first output is silent -
the audit snapshot, an image build that prints nothing when every layer is
cached, and Docker creating the container with a mount per allowed path. Long
enough to read as hung. Two lines to stderr now say what is happening, and
name the first-run case specifically, since building the image takes minutes
and is when someone is most likely to interrupt.

Placed after --print-config and --print-briefing exit, so machine-readable
output stays machine-readable on both streams.

The badge is dejima's own, as the first project to carry it. The SVG is
copied into assets/ rather than hotlinked or fetched from shields.io: a badge
in a README should not make every reader of it fetch from a third party, and
that goes double for a project whose argument is about not trusting reach you
did not choose.

test/static.bats' image check now covers markdown image syntax and svg, not
just <img> and png - the badge is referenced the markdown way, and would have
sat outside the check that exists for exactly this.
2026-08-10 14:41:34 -07:00
branderson 32841b4a2e Update README.md 2026-08-10 00:21:23 -07:00
branderson 760af9946f Merge pull request 'Tell the session a worktree does not isolate .git/config' (#112) from worktree-config-briefing into main
Reviewed-on: #112
Reviewed-by: branderson <brad@branderson.io>
2026-08-10 00:03:44 -07:00
branderson b7f641594b Tell the session a worktree does not isolate .git/config
tests / runner-env (confined) (pull_request_target) Successful in 1m39s
tests / confined (proxy) (pull_request_target) Successful in 2m8s
tests / confined (providers) (pull_request_target) Successful in 4m13s
tests / confined (remote) (pull_request_target) Successful in 4m51s
tests / confined (project) (pull_request_target) Successful in 5m59s
tests / confined (config) (pull_request_target) Successful in 7m2s
tests / confined (audit) (pull_request_target) Successful in 8m2s
tests / confined (static) (pull_request_target) Successful in 3m45s
tests / privileged (pull_request_target) Successful in 5m25s
tests / runner-env (privileged) (pull_request_target) Successful in 1m37s
A worktree isolates the working tree; `.git/config` is shared with the parent
checkout. So a bare `git config <key> <value>` run inside a worktree writes
the user's real repository config. That happened: an implementer set
`user.name` while reproducing a fixture by hand and misattributed three
commits, noticed only because a later subagent read an odd author off
`git log`.

The briefing tells a session to work in a worktree, and says nothing about
what a worktree does not cover, which reads as more isolation than is on
offer. It now says both, and gives the scoped form.

In `_briefing_core_worktree` rather than a workflow's own prose, so a
workflow can opt into worktrees but cannot ship them without this - same
reasoning as the containment paragraph already there.

A guard, not a boundary, and the doc says so. `.git/config` sits in the
read-write project mount and cannot be made read-only without breaking
`git push -u`, which writes `branch.<name>.merge` on every push. Detection is
what covers a session that does not care: the audit already watches that file
for `core.hooksPath`, `credential.helper` and the other exec keys the same
unscoped command reaches, and is how this was found.

Closes #32
2026-08-09 23:43:44 -07:00
branderson cd8ac7be0f Merge pull request 'Give each project its own nvim volumes' (#111) from nvim-volumes-per-project into main
Reviewed-on: #111
Reviewed-by: branderson <brad@branderson.io>
2026-08-09 23:41:42 -07:00
branderson 8438daa07e Give each project its own nvim volumes
tests / confined (proxy) (pull_request_target) Successful in 2m8s
tests / runner-env (confined) (pull_request_target) Successful in 1m37s
tests / confined (static) (pull_request_target) Successful in 3m38s
tests / confined (providers) (pull_request_target) Successful in 3m56s
tests / runner-env (privileged) (pull_request_target) Successful in 1m39s
tests / confined (audit) (pull_request_target) Successful in 8m10s
tests / confined (remote) (pull_request_target) Successful in 4m47s
tests / privileged (pull_request_target) Successful in 5m22s
tests / confined (project) (pull_request_target) Successful in 5m56s
tests / confined (config) (pull_request_target) Successful in 6m14s
The nvim data, state and cache directories are Docker volumes rather than
host paths, so nothing a session installs has ever reached the host's own
nvim. They do outlive the container, which is the point - a plugin install
should not happen on every launch - but they carried one fixed name each and
so were shared by every project on the machine.

That made them a persistence channel between sandboxes. A session writes a
Lua file under `lazy/`, or replaces a binary under `mason/bin/`, and the next
session to run nvim in the sandbox executes it, whatever project that session
is working on. `mason/bin` is the sharper half: those are invoked directly,
with no load path to arrange and no plugin to enable. It is bounded by the
container - a payload there runs in another sandbox, not on the host - but
cross-project persistence is what this repository closes everywhere else.

Scoped per project, the same way the derived image tag already is. The
service still refers to the static compose keys; only the volume Docker
creates varies, and the defaults reproduce the previous names so a bare
`docker compose` behaves as it did.

The `dejima_nvim-*` prefix is load-bearing rather than cosmetic: project_slug
maps a leading `/` to `-`, and a Docker volume name may not start with one.
Without it every launch would fail inside `compose run` rather than anywhere
that names the cause, so there is a test for it.
2026-08-09 23:27:38 -07:00
branderson 631b73ae5f Merge pull request 'Add --extra-readonly and --extra-read-write for one-off mounts' (#109) from extra-mount-flags into main
Reviewed-on: #109
Reviewed-by: branderson <brad@branderson.io>
2026-08-09 23:20:43 -07:00
branderson d0052161ef Merge pull request 'Mount ~/.claude/file-history one session at a time, not the whole tree' (#110) from file-history-scope into main
Reviewed-on: #110
Reviewed-by: branderson <brad@branderson.io>
2026-08-09 23:19:17 -07:00
branderson 8a26412716 Add --extra-readonly and --extra-read-write for one-off mounts
tests / confined (proxy) (pull_request_target) Successful in 2m10s
tests / runner-env (confined) (pull_request_target) Successful in 1m39s
tests / confined (static) (pull_request_target) Successful in 3m40s
tests / confined (providers) (pull_request_target) Successful in 3m59s
tests / confined (remote) (pull_request_target) Successful in 4m50s
tests / privileged (pull_request_target) Successful in 5m24s
tests / confined (project) (pull_request_target) Successful in 5m27s
tests / confined (config) (pull_request_target) Successful in 6m52s
tests / runner-env (privileged) (pull_request_target) Successful in 1m37s
tests / confined (audit) (pull_request_target) Successful in 8m4s
Reading another checkout while migrating code out of it, or writing to a
scratch directory for one session, meant editing the config file and then
remembering to edit it back. These do it per launch: --extra-readonly PATH
and --extra-read-write PATH, short forms --ro and --rw, both repeatable.

They append to [mounts] readonly and [mounts] read_write rather than carrying
their own mount code, which is the design rather than an implementation
detail. Everything that already applies to a configured mount then applies to
a flag with nothing duplicated and no second path to keep in step: the
existence check, the refusal when a path overlaps the approval store or the
audit stores, the refusal when one path appears in both lists, and the
read-write paths named in the session briefing and the audit report header.
A flag says where, not whether, and no flag waives a refusal.

Two differences from the config form, both because a command line is not a
file. A relative path is accepted and made absolute against the working
directory - by prefixing rather than by readlink -f, so a symlinked path is
mounted where the operator typed it rather than at its target. And a path
containing a colon is refused: the lists are colon-separated, so it cannot be
represented in one and would otherwise split into two mounts that each fail
the existence check.

The refusals name the flag rather than the config key when a flag supplied
the path, since "[mounts] readonly entry" sends someone to a file that does
not mention it. That record is kept at parse time: the lists are assembled
inside a command substitution, whose subshell would discard it and report
every flag as a config entry.
2026-08-09 23:01:20 -07:00
branderson 0a323ed230 Mount ~/.claude/file-history one session at a time, not the whole tree
tests / confined (proxy) (pull_request_target) Successful in 2m13s
tests / runner-env (confined) (pull_request_target) Successful in 1m37s
tests / confined (providers) (pull_request_target) Successful in 3m57s
tests / confined (remote) (pull_request_target) Successful in 4m52s
tests / confined (config) (pull_request_target) Successful in 6m7s
tests / privileged (pull_request_target) Successful in 9m21s
tests / confined (static) (pull_request_target) Successful in 3m40s
tests / confined (project) (pull_request_target) Successful in 5m33s
tests / confined (audit) (pull_request_target) Successful in 8m18s
tests / runner-env (privileged) (pull_request_target) Successful in 1m40s
The tree was bind-mounted read-write in full, and each
`<session>/<hash>@v1` in it is a verbatim copy of an edited file. So it is
other projects' source rather than a record about it, which makes the read
side worse than the transcripts #104 covered: on the host this was developed
on, 47M across ten checkouts, including a dotfiles repo. The write side puts
chosen content into a later restore in another project.

A launch now gets the sessions belonging to its own workspace and nothing
else - 11 directories here instead of 50.

Keyed by session id, so unlike projects/ there is no slug in these paths to
scope by. The transcripts supply it: projects/<slug>/<id>.jsonl and
file-history/<id> name the same session, so the slugs a workspace owns select
the session ids it owns. That routes through workspace_slugs_sorted, so the
sibling case is excluded here for free - a naive count of "dejima's" history
says 12, and one of those belongs to dejima-ci-hardener.

The session about to start has an id nothing can know in advance, so its own
directory is container-local and does not survive the run. That costs undo
history for that session if it is later resumed on the host. It is the one
respect in which this cannot be scoped as tightly as projects/, where the
workspace's own slug is known at launch, and it is why the image pre-creates
file-history/: without it Docker creates the parent root-owned while setting
up the mounts nested in it, and the session cannot record its own undo entry
at all.

The static test asserting that compose still mounts the tree, and the
Dockerfile mkdir test whose regex is anchored to the RUN line, both had to
move with it.
2026-08-09 22:50:05 -07:00
branderson db989b3277 Merge pull request 'Refuse a read-only mount that would expose the approval store' (#108) from approval-store-mount-guard into main
Reviewed-on: #108
Reviewed-by: branderson <brad@branderson.io>
2026-08-09 21:52:15 -07:00
branderson 3a6acf102a Refuse a read-only mount that would expose the approval store
tests / confined (proxy) (pull_request_target) Successful in 2m13s
tests / runner-env (confined) (pull_request_target) Successful in 1m35s
tests / confined (static) (pull_request_target) Successful in 3m32s
tests / confined (providers) (pull_request_target) Successful in 3m48s
tests / confined (remote) (pull_request_target) Successful in 4m37s
tests / confined (project) (pull_request_target) Successful in 5m16s
tests / runner-env (privileged) (pull_request_target) Successful in 1m38s
tests / confined (audit) (pull_request_target) Successful in 7m51s
tests / privileged (pull_request_target) Successful in 5m13s
tests / confined (config) (pull_request_target) Successful in 5m59s
Two mount paths reached `extra_mounts+=(-v ...)` with a path computed at run
time, and neither was checked: a `[mounts] readonly` entry naming the store,
and a ~/.claude symlink whose target directory sits inside it. Reproduced
directly against main - `readonly = <approval store>` produces
`-v <store>:<store>:ro` and the launch exits 0 with nothing said.

The static test guarding "the approval store is never mounted into the
container" greps bin/dejima for a literal $PROJECT_STORE or config_root, so
it could not see either one and passed throughout. That is what #15 named.

Read-only is not enough to make those mounts harmless. The approval store
discloses every project's approved domains and dependencies, and the audit
baselines say which findings were already waved through.

`[mounts] read_write` has been guarded since it was added. The guard is now a
function, and the two unguarded paths call it with the stores alone - not
read_write's wider list, which also covers $REPO_ROOT and the workspace,
because mounting those read-only is ordinary and expected.

The half of #15 that is already closed: the symlink is no longer
session-plantable. It required ~/.claude to be mounted read-write, and the
enumerated-mounts rework made the five scanned directories read-only.
Verified from inside a running container - every one of them refuses a
symlink with "Read-only file system", and ~/.claude itself is container-local.
So what is left is an operator's own symlink, and the invariant.

Closes #15
2026-08-09 21:41:58 -07:00
branderson 18cff7eece Merge pull request 'Move the devcontainer credit to License, and quieten the ssh-agent warning' (#107) from lineage-ssh-warning into main
Reviewed-on: #107
Reviewed-by: branderson <brad@branderson.io>
2026-08-09 21:12:32 -07:00
branderson da0920d13f Merge pull request 'Mount ~/.claude/projects one slug at a time, not the whole tree' (#105) from projects-slug-mount into main
Reviewed-on: #105
Reviewed-by: branderson <brad@branderson.io>
2026-08-09 21:11:57 -07:00
branderson e31c651a5e Move the devcontainer credit to License, and quieten the ssh-agent warning
tests / confined (proxy) (pull_request_target) Successful in 2m11s
tests / runner-env (confined) (pull_request_target) Successful in 1m38s
tests / confined (static) (pull_request_target) Successful in 3m40s
tests / confined (providers) (pull_request_target) Successful in 3m56s
tests / confined (remote) (pull_request_target) Successful in 4m50s
tests / privileged (pull_request_target) Successful in 5m15s
tests / confined (project) (pull_request_target) Successful in 5m27s
tests / runner-env (privileged) (pull_request_target) Successful in 1m39s
tests / confined (config) (pull_request_target) Successful in 5m56s
tests / confined (audit) (pull_request_target) Successful in 8m4s
The credit moves out of the opening and into the License section, naming the
part it applies to. docker/init-firewall.sh, docker/Dockerfile,
hooks/sandbox-context.sh and docs/security.md carry it at the point of use
and are unchanged.

bin/dejima warned that ssh-agent had no identities loaded even when
[core] ssh_key was set, and told the operator the push would fail "unless
[core] ssh_key is also set" while it was. With a key configured the premise
does not hold: the key is loaded into the container's own agent and pushes
work without the forwarded one carrying anything. The warning is now silent
in that case, and its last line points at the key rather than treating it as
an afterthought.

Two tests in config.bats, driving a real ssh-agent with nothing added to it,
since a handmade socket does not reproduce the `ssh-add -l` failure the
warning keys on. The first fails against the previous behaviour.
2026-08-09 20:03:46 -07:00
branderson 7079fca1cd Merge pull request 'Stop the remote tests from overwriting the real dejima-audit' (#106) from test-audit-stub into main
Reviewed-on: #106
Reviewed-by: branderson <brad@branderson.io>
2026-08-09 19:54:23 -07:00
branderson 4dfa6e3927 Stop the remote tests from overwriting the real dejima-audit
tests / runner-env (confined) (pull_request_target) Successful in 1m39s
tests / confined (proxy) (pull_request_target) Successful in 2m11s
tests / confined (static) (pull_request_target) Successful in 3m38s
tests / confined (providers) (pull_request_target) Successful in 3m58s
tests / confined (remote) (pull_request_target) Successful in 4m50s
tests / confined (project) (pull_request_target) Successful in 5m29s
tests / confined (config) (pull_request_target) Successful in 5m35s
tests / confined (audit) (pull_request_target) Successful in 8m5s
tests / privileged (pull_request_target) Successful in 9m19s
tests / runner-env (privileged) (pull_request_target) Successful in 1m38s
Two tests need an exit-time audit that fails, and they got one by writing a
two-line `exit 3` stub over `$REPO_ROOT/bin/dejima-audit` and restoring it
from a backup afterwards. Three things wrong with that:

A run killed partway never reaches the restore, so the checkout is left
holding the stub. Two runs of this file at once are worse: the second backs
up the first's stub as the "real" script and restores that, permanently. A
suite that passes while silently disabling the exit-time audit is the exact
failure this file exists to prevent.

And it reads like an attack. A file watcher that samples the tree during the
window sees a security tool replaced by `#!/bin/bash` / `exit 3`, with no
indication anywhere in the bytes that a test put it there. That is worth
avoiding on its own: a real compromise should not have somewhere innocent to
hide, and an alarm that fires on ordinary test runs is an alarm that stops
being read.

Tests now mutate a throwaway copy under $BATS_TEST_TMPDIR instead. bin/ is
copied rather than symlinked, which is load-bearing twice over: bin/dejima
resolves its own location with readlink -f, so a symlink would send
SCRIPT_DIR back to the repository; and `cat >` through a symlink truncates
the target, so linking would have written the real script anyway. The first
draft of this change did link, and the new teardown_file guard caught it
overwriting bin/dejima-audit on the first run - which is the argument for
having the guard.

The stub also says what it is now, so the alarming version of it explains
itself if anything ever does surface it.

Two guards, because the fixture is not the only way to get this wrong:
teardown_file hashes bin/dejima-audit around the whole file, and a static
test refuses any redirect or removal aimed at $REPO_ROOT or
$BATS_TEST_DIRNAME from any suite.
2026-08-09 19:37:26 -07:00
branderson b7880c1084 Mount ~/.claude/projects one slug at a time, not the whole tree
tests / runner-env (confined) (pull_request_target) Successful in 1m33s
tests / confined (proxy) (pull_request_target) Successful in 2m23s
tests / confined (static) (pull_request_target) Successful in 3m50s
tests / confined (providers) (pull_request_target) Successful in 4m9s
tests / confined (remote) (pull_request_target) Successful in 4m59s
tests / confined (project) (pull_request_target) Successful in 5m39s
tests / confined (config) (pull_request_target) Successful in 5m43s
tests / confined (audit) (pull_request_target) Successful in 8m14s
tests / privileged (pull_request_target) Successful in 12m32s
tests / runner-env (privileged) (pull_request_target) Successful in 1m25s
The tree holds every project's transcripts and agent memory, mounted
read-write. Memory is loaded into context at session start, so a write into
another project's memory/ and its MEMORY.md index is text a future session
there reads as established background rather than as input. No container
escape and no forge credential were needed for that; the mount was enough.

A launch now gets the workspace's own slug plus one per live git worktree
nested under it, and nothing else. On the host this repo was developed on
that is 17 directories instead of 71. Sibling projects are absent rather than
present-but-unwritable, which is the first-run state Claude Code already
handles - the risk that a read-only projects/ would hang it, the way a
blocked ~/.claude.json write did, does not arise, because projects/ itself
stays container-local and writable.

The set comes from real worktree paths, not from the slug string, and that is
load-bearing. A slug cannot tell a child from a sibling: /w/proj/wt1 and
/w/proj-wt1 produce the same one. The audit's existing "$slug"-* glob
therefore treated any project checked out beside this one whose directory
name merely extends it as part of it - on this host, a session in dejima had
dejima-ci-hardener's writes silently exempted from cross-project reporting.
Sharing that glob with the mount would have carried the hole straight into
it, so both now call one function in bin/dejima-slug-lib.sh. Hosts with
sibling-named projects will see CROSS-PROJECT findings they were not getting
before; those are real writes, not new false positives.

Detection is unaffected: dejima-audit runs host-side over the whole tree, so
narrowing the mount does not narrow what it can see. It now covers what the
mount cannot reach, which is concurrent host sessions.

The runtime assertion could not be run here - this sandbox has no Docker
daemon - so the privileged CI job is the first thing to execute it.

Closes #104
2026-08-09 13:53:22 -07:00
branderson d7900f1b03 Merge pull request 'Put the wordmark on the README, publish the org-wide policy files, and comb the docs' (#103) from branding into main
Reviewed-on: #103
Reviewed-by: branderson <brad@branderson.io>
2026-08-09 03:12:00 -07:00
branderson d234e473de Indent the wordmark and open up the gap under the rule
tests / confined (proxy) (pull_request_target) Successful in 1m56s
tests / runner-env (confined) (pull_request_target) Successful in 1m24s
tests / confined (providers) (pull_request_target) Successful in 3m29s
tests / confined (config) (pull_request_target) Successful in 4m51s
tests / confined (project) (pull_request_target) Successful in 4m51s
tests / confined (static) (pull_request_target) Successful in 3m22s
tests / confined (remote) (pull_request_target) Successful in 4m27s
tests / privileged (pull_request_target) Successful in 4m50s
tests / confined (audit) (pull_request_target) Successful in 7m19s
tests / runner-env (privileged) (pull_request_target) Successful in 1m24s
A leading &nbsp; on the same line as the tag puts the image inside a
paragraph and indents it by one space. hspace would be the obvious way to do
that and is the wrong one: it survives Gitea sanitizing and is then ignored by
every browser that dropped HTML4 presentation attributes, so it reads as
working in the source and does nothing on the page.
2026-08-09 01:43:34 -07:00
branderson 96c8906351 Space the wordmark above, rule it off below
tests / runner-env (confined) (pull_request_target) Successful in 1m24s
tests / confined (proxy) (pull_request_target) Successful in 1m59s
tests / confined (static) (pull_request_target) Successful in 3m23s
tests / confined (providers) (pull_request_target) Successful in 3m39s
tests / confined (remote) (pull_request_target) Successful in 4m23s
tests / privileged (pull_request_target) Successful in 4m49s
tests / confined (project) (pull_request_target) Successful in 4m52s
tests / confined (config) (pull_request_target) Successful in 4m58s
tests / runner-env (privileged) (pull_request_target) Successful in 1m23s
tests / confined (audit) (pull_request_target) Successful in 7m19s
The blank line moves to above the tag, and an <hr> separates the wordmark
from the tagline underneath it.
2026-08-09 01:41:49 -07:00
branderson 2c75ef0661 Use a character, not a break, for the wordmark spacing
tests / confined (proxy) (pull_request_target) Successful in 2m0s
tests / confined (providers) (pull_request_target) Successful in 3m49s
tests / runner-env (confined) (pull_request_target) Successful in 1m26s
tests / privileged (pull_request_target) Successful in 5m4s
tests / confined (project) (pull_request_target) Successful in 5m20s
tests / confined (config) (pull_request_target) Successful in 5m23s
tests / runner-env (privileged) (pull_request_target) Successful in 1m25s
tests / confined (static) (pull_request_target) Successful in 3m32s
tests / confined (audit) (pull_request_target) Successful in 7m50s
tests / confined (remote) (pull_request_target) Successful in 4m41s
The <br> did not render. A break at the end of a block ends a line that
nothing follows, and browsers give that no height, so the tagline stayed
where it was. &nbsp; survives Gitea sanitizing as a real U+00A0, which means
the paragraph holding it has content and occupies a line. Verified against
the instance markdown endpoint: the rendered output is <p>\xa0</p>, not a
collapsing space.
2026-08-09 01:34:25 -07:00
branderson 9a0c56f9a7 Give the wordmark room above the tagline
tests / confined (proxy) (pull_request_target) Successful in 1m58s
tests / runner-env (confined) (pull_request_target) Successful in 1m25s
tests / confined (static) (pull_request_target) Successful in 3m28s
tests / confined (providers) (pull_request_target) Successful in 3m45s
tests / confined (remote) (pull_request_target) Successful in 4m36s
tests / privileged (pull_request_target) Successful in 5m5s
tests / confined (project) (pull_request_target) Successful in 5m16s
tests / confined (config) (pull_request_target) Successful in 5m20s
tests / runner-env (privileged) (pull_request_target) Successful in 1m25s
tests / confined (audit) (pull_request_target) Successful in 7m50s
An image alone between two blank lines is not wrapped in a paragraph, so it
has no bottom margin and the tagline sits right under it. `style` is not the
fix: Gitea's sanitizer strips it from the tag and from any wrapper, verified
against the instance's own markdown endpoint, and `vspace` survives
sanitizing only to be ignored by every browser that dropped HTML4 presentation
attributes. A `<br>` renders on both Gitea and GitHub, so that is what this
uses, with the reasoning in the comment above the tag.
2026-08-09 01:29:37 -07:00
branderson ab8e26437c Comb the docs for the tics that mark them as agent-written
tests / confined (proxy) (pull_request_target) Successful in 2m2s
tests / confined (providers) (pull_request_target) Successful in 3m51s
tests / runner-env (confined) (pull_request_target) Successful in 1m26s
tests / confined (static) (pull_request_target) Successful in 3m31s
tests / confined (remote) (pull_request_target) Successful in 4m41s
tests / privileged (pull_request_target) Successful in 5m10s
tests / confined (project) (pull_request_target) Successful in 5m20s
tests / confined (config) (pull_request_target) Successful in 5m25s
tests / runner-env (privileged) (pull_request_target) Successful in 1m25s
tests / confined (audit) (pull_request_target) Successful in 7m56s
The prose leaned on a handful of constructions hard enough to be a tell,
mostly the "X, not Y" antithesis as a closing beat and the meta-commentary
that tells a reader how to read the sentence they just read. Where the
contrast carries information, and in a security doc it usually does, it
stays; where it was rhythm, it goes. "worth knowing" went from nine uses to
none, "which is the point" and "the whole point" from three to none, and the
filler senses of "actually", "simply" and "precisely" are gone while the
load-bearing ones (nominal versus real) are untouched.

No claim changes. The agent-voice section is deliberately untouched, since
its whole premise is that it was written by one.

Provider axes is now three bulleted lists rather than a run-on sentence
naming seven directories, and ATTRIBUTION.md credits gruvbox for the palette.
2026-08-09 00:44:43 -07:00
branderson 034913205a Put the wordmark on the README, and publish the org-wide policy files
The README points at one wordmark file unconditionally rather than switching
on `prefers-color-scheme`, which reports the browser's setting and never the
site's. A forge that renders dark whichever way the browser is set would then
show dark ink on a dark page, silently, to a reader whose browser prefers
light. Both files ship, so a mirror on a light-themed host changes one line
where someone can see whether it worked. The suffix names the mode a file is
for, not the colour of its ink, so `-darkmode` is the pale one.

ATTRIBUTION.md and SECURITY.md are canonical for the whole organisation and
live here because this is the repository the others link back to. Keeping one
copy is the point: the terms cannot drift into two divergent statements of
who holds the marks, and a reporter cannot find two different addresses to
send a vulnerability to. dejima-ci-hardener links to both by absolute URL, so
a rename here is a 404 in a repository whose tests cannot see this one, which
is why static.bats pins the two filenames.

The README's opening now says there is one maintainer and no support, where
somebody deciding whether to deploy this sees it before investing effort.
2026-08-08 23:41:45 -07:00
branderson 9cb6b7b432 Merge pull request 'docs: point at the dejima organization' (#102) from org-move into main
Reviewed-on: #102
Reviewed-by: branderson <brad@branderson.io>
2026-08-08 20:34:08 -07:00
branderson 32589c0603 Merge branch 'main' into org-move
tests / runner-env (confined) (pull_request_target) Successful in 1m42s
tests / confined (proxy) (pull_request_target) Successful in 2m5s
tests / confined (static) (pull_request_target) Successful in 3m42s
tests / confined (providers) (pull_request_target) Successful in 3m55s
tests / confined (remote) (pull_request_target) Successful in 4m50s
tests / confined (project) (pull_request_target) Successful in 5m29s
tests / confined (config) (pull_request_target) Successful in 5m33s
tests / confined (audit) (pull_request_target) Successful in 8m2s
tests / privileged (pull_request_target) Successful in 11m41s
tests / runner-env (privileged) (pull_request_target) Successful in 1m25s
2026-08-08 20:19:04 -07:00
branderson 3a72114770 Merge pull request 'ci: one vocabulary for the two runner tiers' (#101) from tier-vocabulary into main
Reviewed-on: #101
Reviewed-by: branderson <brad@branderson.io>
2026-08-08 20:17:54 -07:00
branderson e69d35a8a4 docs: point at the dejima organization
tests / runner-env (sandbox) (pull_request_target) Waiting to run
tests / runner-env (host-docker) (pull_request_target) Waiting to run
tests / sandbox (remote) (pull_request_target) Waiting to run
tests / sandbox (static) (pull_request_target) Waiting to run
tests / host-docker (pull_request_target) Waiting to run
tests / sandbox (audit) (pull_request_target) Has been cancelled
tests / sandbox (config) (pull_request_target) Has been cancelled
tests / sandbox (project) (pull_request_target) Has been cancelled
tests / sandbox (providers) (pull_request_target) Has been cancelled
tests / sandbox (proxy) (pull_request_target) Has been cancelled
Both repositories moved out of the personal namespace and into a `dejima`
organization, so every link into either one is now one path segment wrong:
four to dejima-ci-hardener and one to this repository's own issue #97.

Gitea redirects an old path, so none of these were broken. They were merely
lying about where the thing lives, which is the kind of stale that survives
indefinitely because nothing fails.

Only the organization segment changes. The host stays `gitbox.branderson.io`,
which is the name a reader resolves; the git remote uses the internal
`gitbox.bradina.localdomain` and is not a documentation concern.

Nothing in `bin/`, `test/` or the workflow hardcoded the old owner, so this is
documentation only. The forge CLIs read owner and repo out of the git remote
rather than from anything committed here, which is why moving a repository
does not require a code change to keep them working.
2026-08-08 19:17:44 -07:00
branderson 07e21f86b1 ci: one vocabulary for the two runner tiers
tests / sandbox (proxy) (pull_request_target) Successful in 2m2s
tests / runner-env (sandbox) (pull_request_target) Successful in 1m26s
tests / sandbox (static) (pull_request_target) Successful in 3m29s
tests / sandbox (providers) (pull_request_target) Successful in 3m59s
tests / sandbox (remote) (pull_request_target) Successful in 4m41s
tests / host-docker (pull_request_target) Successful in 4m51s
tests / runner-env (host-docker) (pull_request_target) Successful in 1m24s
tests / sandbox (project) (pull_request_target) Successful in 5m18s
tests / sandbox (config) (pull_request_target) Successful in 5m21s
tests / sandbox (audit) (pull_request_target) Successful in 8m1s
Two tiers were carrying three sets of names. dejima-ci-hardener called them
confined and privileged, the runner labels called them dejima-ci and
dejima-runtime, and the workflow and tests called them sandbox and host-docker.
Someone reading a red `runner-env (host-docker)` and then opening the runbook
to fix it had to map three ways before they could act.

Now: `dejima-confined` and `dejima-privileged` as labels, `confined` and
`privileged` as job names and DEJIMA_CI_TIER values. The hardener's runner
generator derives its default label from the role it was given
(`dejima-$ROLE`), so accepting that default produces exactly the strings this
file names, and neither side is typed from memory.

Deliberately not a substitution. `sandbox` in this repository overwhelmingly
means the dejima container, not a CI tier: git-push-guard.sh, providers.md,
security.md, responsible-use.md and most of .dejima.md all use it that way and
are untouched. Only the tier sense moved, and test/ci-runner.bats no longer
contains the word at all, which is right for a file that is entirely about
tiers.

`/tmp/dejima-ci` stays as it is. It is a path the privileged job bind-mounts,
not a label, and renaming it would need the runner's filesystem to change in
the same breath.

## This does not take effect until the runners are relabelled

`runs-on:` matches labels a registered runner actually carries, and under
pull_request_target the workflow is read from main. So from the merge until
both runners come up as dejima-confined and dejima-privileged, jobs queue
rather than fail, and Gitea has no concurrency: to clear them. That downtime
is accepted: nobody else ships here, and dejima-ci-hardener cannot put two
labels on one box without hand-editing a generated runner, which would defeat
the point of generating it.

Note also that this file cannot test itself. pull_request_target reads the
workflow from the base branch, so this change is exercised for the first time
after it lands. static.bats asserting on DEJIMA_CI_TIER's values is what
catches a half-done rename before then, and it is updated here.
2026-08-07 16:40:13 -07:00
branderson 57ad800c34 Merge pull request 'README: say what dejima is for, and what it does not guarantee' (#96) from readme-hierarchy into main
Reviewed-on: branderson/dejima#96
Reviewed-by: branderson <brad@branderson.io>
2026-08-07 16:11:55 -07:00
branderson c441116c3b docs: answer the second review round
tests / sandbox (proxy) (pull_request_target) Successful in 2m0s
tests / sandbox (providers) (pull_request_target) Successful in 3m49s
tests / runner-env (sandbox) (pull_request_target) Successful in 1m24s
tests / sandbox (static) (pull_request_target) Successful in 3m19s
tests / sandbox (remote) (pull_request_target) Successful in 4m36s
tests / host-docker (pull_request_target) Successful in 4m52s
tests / runner-env (host-docker) (pull_request_target) Successful in 1m21s
tests / sandbox (project) (pull_request_target) Successful in 5m11s
tests / sandbox (config) (pull_request_target) Successful in 5m16s
tests / sandbox (audit) (pull_request_target) Successful in 7m57s
- A git push credential is not a prerequisite. The default workflow is
  `local-only`, which pushes nowhere, so nothing about getting dejima running
  needs one. It moves to Setup, next to choosing a pushing workflow, which is
  the point at which it becomes required. The ordering it gained last round
  survives the move: a key of the session's own first, a forwarded agent as
  the fallback with the cost of authenticating as you stated.

- The loop section says dejima itself is developed under `worktree-pr`, so
  this repository is that workflow in use rather than a description of one.

- configuration.md drops "expect the session to be live". A reader
  configuring a project has no reason to hold that; it is dejima's
  implementation that has to assume it, which is why the reasoning lives in
  security.md, where it stays.

- The Grafana section covers the conversation stream, which had queries for
  DNS and connections only. Selecting it, filtering by role, by tool, and by
  session, plus why `session` is a field rather than a label, and what is
  deliberately absent: tool results and thinking blocks are never shipped, so
  a query shows that a session ran a command and never what came back.
2026-08-07 15:51:31 -07:00
branderson 97bada30d3 docs: answer the review on responsible-use.md
- "Code you couldn't should wait for containment you trust more" made this a
  question about trusting dejima. It is not. There is no containment that
  fully prevents exfiltration: a determined attacker with code execution and
  an outbound path will find one, and an allowlist narrows paths rather than
  closing them. So the question is whether you can accept the risk of the
  contents leaving, and if you cannot, the answer is not to hand them over.

- "Auditing the directory takes an extra minute" was wrong about the cost and
  wrong about the method. Reading a working tree by eye is not a minute's
  work and succeeds every time until the once it matters. Point dejima at a
  fresh clone: it contains what is in version control and nothing else, so
  the whole class of stray uncommitted files goes away rather than being
  searched for.

- The allowlist section buried its own rule in a distinction. It now opens
  with it: for every allowlist entry you give an agent, consider what an
  attacker could do with it. The worked examples follow as the reason the
  answer varies.

- `main` is named as "(or `master`, or whatever your default branch is
  called)", so this does not read as dejima assuming one branch name.

- "Safe and responsible are not the same thing" overlapped the final point
  about dejima's security saying nothing about your software's. This one is
  about the merge specifically: what makes a default branch different is that
  other things read it and believe it. The general code-quality argument
  belongs to the other point and is left there.

- The retrofit point goes further, because the important half was missing.
  Security is not something a future agent can add either. Early
  architectural decisions close off the fixes for whole classes of problem,
  and a later pass can find a missing bounds check but cannot move a boundary
  the system was built on top of. dejima makes writing a lot of code fast,
  which makes writing a lot of insecure code fast if nobody is holding that
  concern, and holding it is the reader's job at every step rather than a
  phase to schedule.
2026-08-07 15:51:31 -07:00
branderson 81123c6d05 docs: answer the review on configuration.md and security.md
configuration.md:

- "least specific first" said the order without saying what the order does.
  The layers add rather than override, so a narrower layer can only widen
  what a broader one permits and the result is a union. Read the old wording
  cold and you would guess the opposite.

- The historical justification for `forge = none` being the default is gone,
  along with the changed-default blockquote. Nobody needs the argument for
  why we do not ship a broken default, and install.sh covers the migration
  for the handful of configs it could apply to.

- Tarball digests: how to get one. Fetch and hash on the host, and prefer a
  published checksums file, because that tells you the archive is the one the
  project shipped rather than the one your connection produced.

- "a mixed-architecture fleet needs a spec per project" implied a feature
  that does not exist. A tarball dependency locks the project to one
  architecture, full stop, with #97 filed for per-architecture specs.

- The symlink paragraph was a vulnerability deep-dive in a configuration doc.
  What a reader here needs is that both writers refuse a symlink and that the
  refusal is the open itself. The attack and the O_NOFOLLOW reasoning move to
  security.md under a heading of their own.

- "Config parity with your host Claude Code setup" becomes "...with your host
  agent harness setup", with Claude Code as a subheading, so adding a second
  harness does not mean rewriting the heading every link targets. Headless
  git push is promoted back to a top-level section: it was nested under
  Claude Code by that change, and it is harness-agnostic.

- Claude Code is named only where it is actually specific. The .dejima.md
  passage keeps it, because that passage exists to explain why a
  harness-neutral name is needed.

- The Dockerfile caveat pointed at editing the base image. That is what
  dejima-dep is for, and a dependency added through it is declared, approved
  and survives a rebuild.

- Headless git push led with running unattended. The reason that matters is
  identity: a forwarded agent authenticates as you. Uptime is the lesser,
  second reason now.

- ~/shared says to clear it out periodically, for the same reason as the
  README.

security.md: the WARNING git-config tier is described by what it does rather
than by what it used to fail to do.
2026-08-07 15:51:31 -07:00
branderson c2023ff402 docs: add CONTRIBUTING.md, and answer the review on the README
CONTRIBUTING.md says the thing that is true: this is developed on a private
forge that allows neither account registration nor anonymous issues, so
there is currently no way for anyone outside to file either. That will
change. Until it does, fork it and change your fork. It also warns that a
fork taken today diverges quickly, which is the part worth knowing before
building on one.

README, from review:

- "Check whether they want it first" had no antecedent for "they". It names
  the project, and points at dejima's own CONTRIBUTING now that there is one.

- The push credential listed agent forwarding first. That is backwards: a
  forwarded agent authenticates the session as you, which is the arrangement
  docs/security.md argues against two sections earlier. `[core] ssh_key` with
  an identity of its own leads, forwarding is the fallback, and the cost of
  the fallback is stated rather than left to be discovered.

- The "typical loop" was one workflow, and not the default one. A reader
  would have concluded that pushing and opening a PR is how dejima works. It
  now leads with the part every workflow shares and names what each does
  after that.

- The merge-guard paragraph asserted the hooks do not stop a hostile session,
  unconditionally. That only holds if you gave the session a token that can
  merge. Stated conditionally, with a token scoped below merge named as a
  control alongside branch protection.

- "Every session ends at something you read before it lands" was still
  workflow-shaped: under local-only there is no pull request to read. Replaced
  with what is true of all three, that none of them puts work on your default
  branch.

- The session briefing was described as telling the agent to push and open a
  pull request. It carries whichever workflow's instructions were selected.

- ~/shared says to clear it out between sessions. It persists by design, so
  anything forgotten there is handed to every session after it.

- The approval sentence takes the reviewer's wording: approval is a separate
  act you do yourself on the host, by hand, and approvals are stored outside
  the reach of the session.
2026-08-07 15:51:31 -07:00
branderson 9374eb5757 docs: lead with the premise, not with a virtue
The first line was "Unsupervised agentic development you can take
responsibility for." Responsibility is something this project argues for, in
a section that exists precisely because nothing in the software enforces it.
dejima works fine for careless use. A first line implying otherwise claims a
property the tool does not have, which is the same overclaiming the merge
guard paragraph was just corrected for.

What dejima is actually built on is narrower and more distinctive: let the
agent implement unsupervised, and assume it is hostile while it does. Not
fallible, hostile. It can be turned mid-session by a prompt injection or a
poisoned dependency and will not announce that it has been, so the design
question is never whether to trust it, only what it can reach when it should
not be trusted.

The paragraph under it followed the old framing and described mistakes, which
undersells the premise, so it now describes the case the container is
actually for.
2026-08-07 15:51:31 -07:00
branderson 5c1717ebc4 docs: pick up what #95 changed about acknowledging findings
PR #95 left the docs alone deliberately, with this rewrite in flight, and
listed what the rewrite should pick up. This is that.

Approve and ignore were doing different things and the prompt made them look
identical, which is the confusion #95 fixed in the tool. Saying it in prose
is the other half: approving records the exact content, so any later change
re-fires; ignoring records the surface, so that key is fine whatever it later
contains. Approve a hook you have read. Ignore a key some tool rewrites every
run. Skipping decides nothing and asks again, which is the right default for
anything you are unsure about. The keymap is quoted so the doc and the prompt
agree.

A WARNING-tier git config change is now curatable, which the baseline
discussion here predates. It used to be the one class of finding with no id
to record a decision against, so keys that get rewritten every run re-fired
forever and pushed real findings out of the capped list.

Also states what neither verb can do: severity is decided by what the surface
is, an exec key or a shell alias is CRITICAL whatever the baseline says, and
an ignore entry is refused against a CRITICAL surface when the baseline is
read rather than only when it is written. A curation mechanism that could
quietly downgrade something is worse than none, so the limit is worth stating
where the mechanism is.

The audit discussion had no heading of its own, sitting unmarked inside a
five-hundred-line section, so nothing could link to it. It has one now.

Two other things ride along. The note that `--allow-github-push` is a
per-launch addition rather than a substitute for selecting a forge, preserved
from the README's Prerequisites tail. And three em-dashes this branch's rebase
onto main brought back into this file, cleared to match the rest of it.
2026-08-07 15:51:31 -07:00
branderson 4ba9a0499e docs: leave prerequisites as prerequisites, and move the schema
Prerequisites ran to about a hundred lines, most of it a thirty-two row
config table, and it sat between the responsible-use section and Setup. A
reader who wanted "what do I do" had to scroll the entire schema to reach it,
and the schema is the one thing nobody reads top to bottom: they arrive at it
looking for one key.

What is actually required stays: Docker, python3 for the credential proxy,
and a git push credential. Then a sentence saying everything else is optional
and lives in one file, and a link. Twenty-two lines, and Setup is next.

The table moves to a Configuration reference section in docs/configuration.md,
which is where someone editing their config already is. The reasoning for why
no forge is the default, and the changed-default migration notice, go with it.

The trailing "to push, set [core] workflow" paragraph is deleted rather than
moved: Setup's Core block already says all of it. Forgejo working unmodified
is still in the README. That `--allow-github-push` is a per-launch addition
rather than a substitute for selecting a forge is preserved separately, in
docs/security.md next to the posture it modifies.

Moving the table also moved the only mention of `[audit]` out of the README,
which test/config.bats caught. The test is right, and not merely about a
string: the feature list advertises an exit-time audit, and the README would
no longer have said what it watches, that it is advisory rather than a block,
or what `[audit] strict` gates. It gets a section, which is also where the
approve-versus-ignore distinction belongs in short form.
2026-08-07 15:51:31 -07:00
branderson 3b03545c3a docs: say what the loop guarantees, now that the guard does not 2026-08-07 15:51:31 -07:00
branderson f19f89c13a test: pin bin/dejima's docs/configuration.md and docs/security.md references
bin/dejima names docs/configuration.md in user-facing diagnostics, and
bin/dejima, bin/dejima-anthropic-proxy and bin/dejima-audit all name
docs/security.md the same way. Before the docs restructure these named the
README, which cannot be renamed; now they name files that can be, and a
rename would silently send an operator nowhere. Follow the shape of the
existing docs/remote-access.md path test in test/remote.bats: read the
referenced path out of the script rather than restating it, generalized to
walk every docs/*.md reference actually present rather than hardcoding the
two names again.
2026-08-07 15:51:31 -07:00
branderson fc9ab6768b docs: fix dead cross-references in docs/security.md left by the move
Four references pointed at headings that used to be in the same document
before content moved into README.md/docs/configuration.md/docs/providers.md
during the restructure: a deleted README section, two headings that now
live in docs/configuration.md, and one that lives in the README, wrongly
pointed at with 'above'. Turn each into a real markdown link to where the
target actually is, and clean up the double-semicolon left by the em-dash
removal in the GitHub-forge reference.
2026-08-07 15:51:31 -07:00
branderson dddeefcd21 docs: restore qualifications the restructure dropped from the README
The merge-guard claim, the GitHub-forge push cost, the writable-host-surface
answer, and the GitHub-push HTTPS exception all had their qualifying detail
moved into docs/ while the unqualified claim stayed in the README. Restore
each qualification in the README's own voice, next to the claim it modifies,
and link to where the fuller detail now lives. Also anchor the two
docs/security.md and docs/configuration.md links that already had a correct
anchored form available elsewhere in the README but weren't using it, and
correct the GitHub-forge Setup row to agree with docs/providers.md and to
point at the section that actually has the steps.
2026-08-07 15:51:31 -07:00
branderson 40e6c7ba8b docs: a file map of files that are here 2026-08-07 15:51:31 -07:00
branderson 599fc6e3eb docs: fix dead cross-references left by the README restructure
security.md pointed at a README subsection that became a Setup table
row; link the providers.md section that actually documents selecting
the GitHub forge instead. development.md pointed at a README section
that moved into security.md; link the heading it moved to. ci.md
pointed at a ci/ directory that does not exist in this repository;
point at dejima-ci-hardener, which is where that configuration
actually lives and which the rest of the file already links.
2026-08-07 15:51:31 -07:00
branderson 7ed9790b6c docs: semicolons only where a sentence follows 2026-08-07 15:51:31 -07:00
branderson d50343869f docs: replace em-dashes with punctuation that reads as written
Each em-dash was repunctuated by the job it was doing (colon for an
explanation, semicolon or a split sentence for two independent
clauses, parentheses or a comma pair for an aside, a plain comma for
a contrastive tail), not swapped for a single character throughout.
One sentence in README.md's Logging bullet was rewritten rather than
repunctuated, because the em-dash there was covering a fragment with
no verb of its own.

README.md ends at 5, docs/ci.md, docs/security.md and
docs/remote-access.md at 0. All 5 remaining are inside "An agent's
perspective on working inside", which is explicitly exempt and left
untouched. That is one more than the four this task's brief expected
there: direct measurement shows the section holds five, not four (one
line carries two). Per the exemption rule against editing so much as a
character of that section's punctuation, none of the five were
touched; the sweep of everything outside the exempt zone is still
clean.
2026-08-07 15:51:31 -07:00
branderson 70b2a1e392 docs: say what each option costs, not just that it exists 2026-08-07 15:51:30 -07:00
branderson cd6ad04145 docs: cut setup to what everyone needs, and index the rest 2026-08-07 15:51:30 -07:00
branderson 38ee9ee246 docs: say what the firewall actually allows, and point the right way 2026-08-07 15:51:30 -07:00
branderson e2ea1a8afb docs: fold the egress firewall and logging into the security doc
Moves the Network egress firewall, Network request logging, and GitHub
push sections (with their subsections) from README.md into
docs/security.md, after the existing Provider trust section, leaving
load-bearing stubs behind for the two security-guarantee sections and
an operational summary for logging. Repoints every anchor into the
moved headings (README's own Setup section, docs/configuration.md,
docker-compose.yml's comment) at their new home, and restates the
"deliberately do not touch a running container" guarantee in the
firewall stub since test/static.bats checks for it in README.md
itself.
2026-08-07 15:51:30 -07:00
branderson 313250f7c4 docs: name what the container does, not variables nobody sets 2026-08-07 15:51:30 -07:00
branderson fa106cd8ff docs: move day-to-day configuration out of the README 2026-08-07 15:51:30 -07:00
branderson 37e8609db3 docs: say how many suites there are by not saying 2026-08-07 15:51:30 -07:00
branderson c108e58992 docs: move the file map and suite detail into a development doc 2026-08-07 15:51:30 -07:00
branderson cdf0e78525 docs: the reasoning behind each responsible-use rule 2026-08-07 15:51:30 -07:00
branderson f72e4292d5 docs: keep one CI bullet, in the voice of its neighbours 2026-08-07 15:51:30 -07:00
branderson 42088f9c07 docs: say what dejima is for, and how to use it responsibly 2026-08-07 15:51:30 -07:00
branderson 6e969c636f Merge pull request 'gitea-pr: hide review threads that are already resolved' (#99) from gitea-pr-resolved into main
Reviewed-on: branderson/dejima#99
Reviewed-by: branderson <brad@branderson.io>
2026-08-07 15:49:06 -07:00
branderson 73e1a84414 gitea-pr: hide review threads that are already resolved
tests / sandbox (proxy) (pull_request_target) Successful in 2m0s
tests / sandbox (providers) (pull_request_target) Successful in 3m50s
tests / runner-env (sandbox) (pull_request_target) Successful in 1m24s
tests / sandbox (static) (pull_request_target) Successful in 3m20s
tests / sandbox (remote) (pull_request_target) Successful in 4m34s
tests / host-docker (pull_request_target) Successful in 4m47s
tests / runner-env (host-docker) (pull_request_target) Successful in 1m20s
tests / sandbox (config) (pull_request_target) Successful in 5m13s
tests / sandbox (audit) (pull_request_target) Successful in 7m39s
tests / sandbox (project) (pull_request_target) Successful in 5m8s
`gitea-pr comments` printed every inline comment a pull request had ever
received, resolved or not, in one flat list. On a PR reviewed in several
rounds that is mostly noise: the reader has to work out which comments are
outstanding by recognising which passages have already been rewritten.

The deciding field is `resolver`. A resolved comment carries the user who
resolved it; an open one carries null. Neither the review's `state`
(COMMENT vs REQUEST_CHANGES) nor whether comments arrived grouped under one
review says anything about it - both describe how the reviewer chose to
submit, and reading either as a resolution signal works until someone files
differently.

The catch, and the reason this is not a one-line filter: **Gitea stamps only
a thread's root comment.** A reply inside a closed thread comes back with
`resolver: null`, so judging each comment alone reports a thread you already
dealt with as still open. That happened while reading review feedback on #96:
a closed-and-refiled thread's reply presented as outstanding, and the passage
it pointed at had already been rewritten.

So a thread is every comment sharing a path and a diff position, and it is
resolved if any of them carries a resolver. Unresolved threads print by
default; `--all` includes the rest, marked. When anything is hidden it says
how much, because a list that silently shrank is worse than a long one.

## Also

.dejima.md gains the batching rule this session earned. Gitea does not
implement `concurrency:`, which the workflow file already records with a test,
so a second push to an open PR queues behind the first rather than cancelling
it. Pushing commit-by-commit while iterating queued enough runs to saturate
this project's forge: nginx could not open connections to Gitea, the requests
timing out were the runners' own UpdateLog and UpdateTask calls, and the
instance was down long enough to trip uptime alerts. Commit freely, push once.
The same section covers forge API reads, since `comments` costs one request
per review.

## Testing

Two tests, both confirmed to fail against the previous `gitea-pr` and at the
right assertions: the resolved-thread one on the reply leaking through, the
`--all` one on the missing marker. The shared curl stub now returns three
comments, two of which are one thread whose reply carries no resolver, so the
case that caused this is what the suite exercises.

static 204, providers 65. Both green.
2026-08-07 15:20:32 -07:00
branderson 0aef363910 Merge pull request 'gitea-pr: show the diff a review comment was left against' (#98) from gitea-pr-hunks into main
Reviewed-on: branderson/dejima#98
Reviewed-by: branderson <brad@branderson.io>
2026-08-07 14:19:28 -07:00
branderson 2222aef7d1 gitea-pr: show the diff a review comment was left against
tests / sandbox (proxy) (pull_request_target) Successful in 2m5s
tests / runner-env (sandbox) (pull_request_target) Successful in 1m28s
tests / sandbox (static) (pull_request_target) Successful in 3m33s
tests / sandbox (providers) (pull_request_target) Successful in 3m59s
tests / sandbox (remote) (pull_request_target) Successful in 4m41s
tests / host-docker (pull_request_target) Successful in 4m55s
tests / runner-env (host-docker) (pull_request_target) Successful in 1m23s
tests / sandbox (project) (pull_request_target) Successful in 5m19s
tests / sandbox (config) (pull_request_target) Successful in 5m25s
tests / sandbox (audit) (pull_request_target) Successful in 8m1s
`gitea-pr comments` rendered every inline review comment as `path:?`. Not
sometimes: always. The jq asked for `.line`, and Gitea's review-comment
objects have no `line` field, so `// "?"` was the only branch that could ever
be taken.

The effect is not cosmetic. A reviewer writing "this is confusing" or "let's
reword this" is pointing at a passage they can see and the reader cannot. All
that survived the fetch was the file. On a review of any size, several
comments land on the same file, and some of them say "this" about a phrase
that occurs more than once, so acting on the review means guessing which
passage was meant and sometimes guessing wrong. That happened on #96, on a
comment about historical content in a file with two candidate passages, and
cost a round trip to resolve.

The objects do carry what is needed: `diff_hunk`, which is the exact text the
comment was attached to, and `position`, the offset into the diff. Both are
printed now, with the comment marked off from the quoted code so the two
cannot be read as one block.

The hunk makes the output longer, and that is the right trade: scrolling
costs nothing, and guessing wrong costs a round trip through a human.

## Testing

The shared curl stub answered every read with `[]`, which meant `comments`
had no coverage at all: the reviews list came back empty, so the per-review
comment fetch never ran and the rendering under test was never reached. The
stub now answers the two review endpoints with shaped data and everything
else as before.

One test, and it was confirmed to fail against the previous `gitea-pr`,
at the assertion for the hunk text rather than incidentally.

static 202 and providers 65, both green.
2026-08-07 14:08:32 -07:00
branderson b71b0c1dda Merge pull request 'audit: stop re-asking about findings already decided, make the WARNING git-config tier ackable, and colour the review' (#95) from audit-ux into main
Reviewed-on: branderson/dejima#95
Reviewed-by: branderson <brad@branderson.io>
2026-08-06 23:39:48 -07:00
branderson e7e6a57dc3 audit: stop re-asking about findings already decided, and colour the review
tests / sandbox (proxy) (pull_request_target) Successful in 2m7s
tests / runner-env (sandbox) (pull_request_target) Successful in 1m26s
tests / sandbox (static) (pull_request_target) Successful in 3m32s
tests / runner-env (host-docker) (pull_request_target) Successful in 1m23s
tests / sandbox (project) (pull_request_target) Successful in 5m24s
tests / sandbox (providers) (pull_request_target) Successful in 4m0s
tests / sandbox (remote) (pull_request_target) Successful in 4m44s
tests / host-docker (pull_request_target) Successful in 4m59s
tests / sandbox (config) (pull_request_target) Successful in 5m28s
tests / sandbox (audit) (pull_request_target) Successful in 8m17s
Closes #65 and #45.

The headline bug was not what it looked like. Approvals were being written
correctly the whole time - `ack` simply never consulted the baseline when
deciding what to SHOW, so every later run re-presented the same CRITICALs
and appended a duplicate entry for each. It reads from the outside as
"exact approvals are not being recorded".

Two reports can also list the same finding, and approving it in the first
left the second permanently -CRITICAL.txt: bin/dejima then warned about a
report whose every finding had been dealt with, forever. The rename now runs
on a pass that records nothing new, so a report covered entirely by earlier
decisions stops counting as unacknowledged.

#45: a WARNING-tier git config change could not be acknowledged at all - no
`ack` arm derived an id for it, and neither branch of `compare` that emits
one consulted the baseline. It was the only class of finding with no way to
curate it, so keys some tools rewrite on every run re-fired every session
and pushed real WARNINGs out of the capped list. Safe by construction rather
than by care taken here: id_severity still calls an exec key or any alias
CRITICAL whatever wrote the entry, and baseline_suppresses still refuses a
`spam` entry against a CRITICAL surface at read time.

Findings that cannot be acknowledged at all - now only a cross-project write
- are presented once per report rather than on every run. There is nothing
to record for one, so a `reviewed <report>` line marks the report walked.
Only an un-ackable finding is governed by it; skip still means "ask again"
for everything else, which is the whole difference between skip and ignore.
Quitting does not mark it, since the report was not walked.

The keys: skip is [s], and the permanent option is [i]. Both prompts now say
what they do to future findings, because the difference between them was
invisible - approve pins this exact content and re-fires on any other,
ignore blankets the surface whatever it later contains, and the WARNING
prompt used to offer a bare "approve" that read as the same thing. The noun
follows the surface, so a git config key is not called a file.

Colour, from a small lib both bin/dejima and bin/dejima-audit source so the
launcher's warning and the review it sends you to agree about what red
means: severity on the tier, the file or key picked out of the label around
it, dim on the audit's own framing so it stops running together with the
content between it, and green/red on a retained diff. Off unless the stream
being written to is a terminal, and off for NO_COLOR and TERM=dumb - reports
and piped output are read back by machines, this suite included.

And the launcher's warning offers [a] to review right there. It already knew
the findings were unacknowledged and then asked the operator to go and run a
command in another terminal, at the one moment when nothing was waiting on
them: the container has not started yet. It loops, because `ack` walks one
report per run.

Tests: 7 new in audit.bats and 3 in static.bats, the four load-bearing ones
confirmed to fail against the previous dejima-audit. Suites green - audit
148, static 202, config 94, project 132, providers 64.

Docs are deliberately untouched: a README/docs rewrite is in flight, and
this belongs in that rather than in the version being replaced.
2026-08-06 20:39:54 -07:00
branderson d648c7743a Merge pull request 'gitea-actions: read CI through endpoints a non-owner token can reach' (#93) from gitea-actions-scope into main
Reviewed-on: branderson/dejima#93
Reviewed-by: branderson <brad@branderson.io>
2026-08-06 18:40:20 -07:00
branderson 00555eadd7 Merge pull request 'gitea: add edit to the PR and issue CLIs, brief the agent on body formatting, and make README Usage human-facing' (#92) from gitea-tools into main
Reviewed-on: branderson/dejima#92
Reviewed-by: branderson <brad@branderson.io>
2026-08-06 18:40:01 -07:00
branderson da045fa77f docs: say that the sandbox's account needs write and not ownership
tests / sandbox (proxy) (pull_request_target) Successful in 2m3s
tests / sandbox (static) (pull_request_target) Successful in 2m47s
tests / sandbox (providers) (pull_request_target) Successful in 3m53s
tests / runner-env (sandbox) (pull_request_target) Successful in 1m22s
tests / sandbox (remote) (pull_request_target) Successful in 4m36s
tests / host-docker (pull_request_target) Successful in 4m50s
tests / runner-env (host-docker) (pull_request_target) Successful in 1m22s
tests / sandbox (project) (pull_request_target) Successful in 5m14s
tests / sandbox (config) (pull_request_target) Successful in 5m20s
tests / sandbox (audit) (pull_request_target) Successful in 7m22s
"Give the sandbox its own identity" said write access without saying that
write is the ceiling. An account that owns the repo, or holds admin on it,
can merge its own work and edit the branch protections in that section's own
table - so every row goes back to being decorative, which is the failure the
section exists to prevent.

And the consequence a reader will otherwise meet as a puzzle: parts of
Gitea's API are gated on ownership rather than permission, and answer a
collaborator with "user should be the owner of the repo" whatever scopes its
token has. That reads as a scope error, and the reflex - widen the token -
cannot work, while the thing that would work is handing the sandbox an owner
token and undoing the section. Named here with what gitea-actions does
instead, so the dead end is documented rather than rediscovered.
2026-08-06 12:36:24 -07:00
branderson e241ff9a31 gitea-actions: read CI through endpoints a non-owner token can reach
tests / runner-env (sandbox) (pull_request_target) Successful in 1m35s
tests / sandbox (proxy) (pull_request_target) Successful in 2m13s
tests / sandbox (static) (pull_request_target) Successful in 3m1s
tests / sandbox (providers) (pull_request_target) Successful in 4m1s
tests / sandbox (remote) (pull_request_target) Successful in 4m44s
tests / host-docker (pull_request_target) Successful in 4m49s
tests / runner-env (host-docker) (pull_request_target) Successful in 1m21s
tests / sandbox (project) (pull_request_target) Successful in 5m19s
tests / sandbox (audit) (pull_request_target) Successful in 7m25s
tests / sandbox (config) (pull_request_target) Successful in 5m24s
The sandbox holds a token for a dedicated account with push access and
nothing more, which is the point of having one. Gitea gates
/repos/{owner}/{repo}/actions/runs behind reqOwner(), so that account got
"user should be the owner of the repo" and every subcommand failed at the
first call. It is an ownership check, not a scope check - widening the token
cannot fix it, and the only thing that would is handing the sandbox an owner
token, which is the opposite of the point.

Three sources replace it, all of them plain repo reads:

  actions/tasks            one record per job, carrying run number,
                           head_sha, event and result
  commits/<sha>/statuses   the same jobs as statuses, and the only place a
                           job's index is visible to a non-owner
  the web log route        the run view's own endpoint, same token header

The job index is what addresses a log and is not a field anywhere - only the
tail of a status's target_url. Statuses are written twice per job and
filtered here by run number as well as commit, since a re-run puts a second
run's statuses on the same one.

Also drops `column`, which is not in the sandbox image: being last in the
pipeline, a missing one fails with 127 after the request has succeeded, so
`runs` and `jobs` have been broken in the shipped image while looking like
an API problem. Padded with printf like dejima-audit and dejima-attach.

Verified against the live forge with the dejima-agent token that could not
run any of this before: runs, jobs, logs by default/--job name/--job index,
and both not-found paths.
2026-08-06 12:22:48 -07:00
branderson 5fa4be3cac test: give the stub repo its own git identity instead of the runner's
tests / sandbox (proxy) (pull_request_target) Successful in 2m6s
tests / runner-env (sandbox) (pull_request_target) Successful in 1m25s
tests / sandbox (static) (pull_request_target) Successful in 2m58s
tests / sandbox (providers) (pull_request_target) Successful in 3m57s
tests / sandbox (remote) (pull_request_target) Successful in 4m37s
tests / host-docker (pull_request_target) Successful in 4m38s
tests / runner-env (host-docker) (pull_request_target) Successful in 1m21s
tests / sandbox (project) (pull_request_target) Successful in 5m14s
tests / sandbox (config) (pull_request_target) Successful in 5m17s
tests / sandbox (audit) (pull_request_target) Successful in 7m22s
The new CLI tests build a throwaway repo and commit into it so that
create's `rev-parse --abbrev-ref HEAD` has a branch to report. `git commit`
needs an author, and a CI job container has no git config at all - it exited
128 with "Author identity unknown" and took three of the four tests with it,
while every local run passed on whoever happened to be configured.

Identity is now passed per-command, so nothing about the environment is
assumed. Verified by running the whole suite with HOME emptied and both
GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM pointed at /dev/null: 197 tests,
no failures, where the same run reproduced all three CI failures before.
2026-08-06 12:08:38 -07:00
branderson 8122267f2b docs: make Usage about using dejima, not about the CLIs the session uses
tests / sandbox (proxy) (pull_request_target) Successful in 2m3s
tests / runner-env (sandbox) (pull_request_target) Successful in 1m26s
tests / sandbox (providers) (pull_request_target) Successful in 3m48s
tests / sandbox (remote) (pull_request_target) Successful in 4m28s
tests / sandbox (audit) (pull_request_target) Successful in 7m7s
tests / sandbox (static) (pull_request_target) Failing after 2m51s
tests / host-docker (pull_request_target) Successful in 4m49s
tests / runner-env (host-docker) (pull_request_target) Successful in 1m21s
tests / sandbox (project) (pull_request_target) Successful in 5m6s
tests / sandbox (config) (pull_request_target) Successful in 5m9s
The section had grown into agent instructions: which gitea-* subcommands
exist, why --body-file beats a heredoc, how deny-dangerous-bash.sh reads a
command string, when to write Fixes #N. None of that is something a person
running dejima does or needs - the agent gets it from the session briefing,
which is where it already lives.

What is left is the loop a human is actually in: point it at a project, ask
for a change, review and merge the PR it opens. Plus the one thing that
governs how far a session goes, which is the workflow setting.
2026-08-06 11:53:16 -07:00
branderson 0e29236dff gitea: let an agent edit a PR or issue body, and tell it how to write one
Two halves of #79.

An agent that said it had updated a description had not, because there was
no way to: create was write-once and rewriting the file the body came from
sends nothing. Both CLIs gain `edit N [--title] [--body|--body-file]`, which
sends only the fields it was given - a fixed {title, body} pair would have
traded a silent no-op for a worse bug, every title-only edit wiping the
description. An edited body keeps create's provenance marker rather than
shedding it.

The hard-wrapping half is left to the briefing, deliberately. A tool that
reflows prose has to guess which newlines the author meant, and it will be
wrong about trailers, addresses and one-line-per-item text; these stay dumb
and pass the body through as written. The worktree-pr fragment now says not
to wrap, why (the forge renders a newline as a break, worst on a phone),
which breaks to keep, and that revising the source file sends nothing.

gitea-pr adopts gitea-issue's api_or_die and parse_body_args rather than
growing a fifth hand-rolled response check and a second copy of the body
parser. That is what makes create and edit agree about --body vs
--body-file, and it leaves the two files the twins they are documented to be.

First behavioural tests for any of these CLIs, against a stub curl: what
lands in the request, not what the source greps like. They cover create too,
which had none before its parser moved.
2026-08-06 11:53:16 -07:00
branderson aec22e32b7 Merge pull request 'firewall: keep GitHub port-restricted however it was allowlisted' (#90) from extra-domains-github-split into main
Reviewed-on: branderson/dejima#90
Reviewed-by: branderson <brad@branderson.io>
2026-08-06 11:10:04 -07:00
branderson 5a2d460075 Merge pull request 'docs: name the CI exposure in the opening list' (#91) from readme-ci-bullet into main
Reviewed-on: branderson/dejima#91
Reviewed-by: branderson <brad@branderson.io>
2026-08-06 10:15:44 -07:00
branderson 1e65852f2c docs: name the CI exposure in the opening list
tests / runner-env (sandbox) (pull_request_target) Successful in 1m30s
tests / sandbox (proxy) (pull_request_target) Successful in 2m6s
tests / sandbox (providers) (pull_request_target) Successful in 3m54s
tests / host-docker (pull_request_target) Successful in 4m39s
tests / sandbox (project) (pull_request_target) Successful in 5m15s
tests / sandbox (static) (pull_request_target) Successful in 2m54s
tests / sandbox (remote) (pull_request_target) Successful in 4m40s
tests / runner-env (host-docker) (pull_request_target) Successful in 1m21s
tests / sandbox (config) (pull_request_target) Successful in 5m20s
tests / sandbox (audit) (pull_request_target) Successful in 7m19s
The list of what running an agent unattended takes is where a reader decides
whether this is a serious tool, and it ran to nine bullets without mentioning
the one exposure the container cannot close. CI came ~1000 lines later, which
is after the point where someone has already pointed dejima at a project and
given a session push access.

The bullet is deliberately the odd one out. Every other entry is something
dejima provides; this one is something it does not, and says so - the
container bounds what a session reaches directly, not what it can arrange to
have run elsewhere. Both destinations get named there rather than deferred:
docs/ci.md for the reasoning, dejima-ci-hardener for the tool.

Placed directly after the PR and issue workflow, which is where push access
is introduced and therefore where the consequence belongs.
2026-08-06 10:03:14 -07:00
branderson 5a80e6f76f firewall: keep GitHub port-restricted however it was allowlisted
tests / sandbox (proxy) (pull_request_target) Successful in 2m2s
tests / runner-env (sandbox) (pull_request_target) Successful in 1m24s
tests / sandbox (remote) (pull_request_target) Successful in 4m38s
tests / sandbox (config) (pull_request_target) Successful in 5m20s
tests / sandbox (static) (pull_request_target) Successful in 2m43s
tests / sandbox (providers) (pull_request_target) Successful in 3m55s
tests / sandbox (project) (pull_request_target) Successful in 5m15s
tests / sandbox (audit) (pull_request_target) Successful in 7m25s
tests / runner-env (host-docker) (pull_request_target) Successful in 1m22s
tests / host-docker (pull_request_target) Successful in 5m4s
GitHub's addresses live in github-domains, which init-firewall.sh opens on
443 and nothing else. That restriction is the read-only guarantee: with 22
shut there is no transport left for the forwarded ssh-agent's key to push
with, whatever a hook does or does not catch.

Two paths put addresses somewhere else. init-firewall.sh's extra-domains loop
and bin/dejima-allow's live add both wrote unconditionally into
allowed-domains, which carries no port restriction at all. So a project with
github.com in its .dejima/allowed-domains - this one has, and so does any
project whose CI runner needs GitHub, since the hardener reads that same file
- opened port 22 to whichever address GitHub returned at container start.

dns-proxy.sh already got this right and says so in a comment: dnsmasq applies
the longest matching ipset= line, so GitHub names reach github-domains no
matter which list allowlisted them. The other two paths never asked.
ipset_for_domain now answers for all three, and lives in fixed-domains.sh for
the reason that file exists - so the answer cannot drift between the places
that need it.

How it presented is worth recording, because it is nearly the opposite of
what it was. The port-22 self-check resolves github.com a second time, at the
end of startup. When GitHub returns the same address the check sees the open
port and kills the container; when the name has rotated in between, the
second address is only in github-domains and the check passes. So a security
hole surfaced as an intermittent boot failure, on a commit that had nothing
to do with it - #88 was suspected and reverted-in-preparation on exactly that
evidence. The self-check earned its keep here; what it could not do was say
which of the two answers it was looking at.

.dejima/allowed-domains keeps its GitHub entries. They are redundant for the
sandbox, which allowlists GitHub by published range and by DNS suffix
already, but dejima-ci-hardener reads the same file to build the CI runner's
egress allowlist, where they are not redundant at all.

The test asserts the address, not the symptom: github.com resolved through
the container's own dnsmasq is in github-domains, is not in allowed-domains,
and port 22 does not connect. That holds whichever way the second lookup
falls, which the self-check alone does not.
2026-08-06 09:37:13 -07:00
branderson 415564f00f Merge pull request 'firewall: stop assuming the embedded resolver's forward stays on loopback' (#88) from dns-forward-egress into main
Reviewed-on: branderson/dejima#88
Reviewed-by: branderson <brad@branderson.io>
2026-08-06 02:31:01 -07:00
branderson 8cc9e29ab3 firewall: stop assuming the embedded resolver's forward stays on loopback
tests / runner-env (sandbox) (pull_request_target) Successful in 1m27s
tests / sandbox (proxy) (pull_request_target) Successful in 2m5s
tests / sandbox (static) (pull_request_target) Successful in 2m56s
tests / sandbox (remote) (pull_request_target) Successful in 4m34s
tests / sandbox (project) (pull_request_target) Successful in 5m10s
tests / sandbox (audit) (pull_request_target) Successful in 7m16s
tests / sandbox (providers) (pull_request_target) Successful in 3m53s
tests / sandbox (config) (pull_request_target) Successful in 5m14s
tests / host-docker (pull_request_target) Successful in 4m54s
tests / runner-env (host-docker) (pull_request_target) Successful in 1m21s
The loopback rule carried the claim that DNS "goes entirely through Docker's
embedded resolver at 127.0.0.11, which stays on loopback, so this also covers
DNS". Only the first two hops do. 127.0.0.11 does not answer external names
itself - it forwards them to the host's nameservers, and on Docker 26.1.5
that forward leaves the container's network namespace, so it traverses OUTPUT
like anything else. Nothing there can match it: the host's resolver is in
neither ipset and is not the bridge gateway. It reaches the catch-all REJECT,
every lookup returns SERVFAIL, and the container dies in a self-check that
names a host rather than the cause.

Measured, not deduced: on the failing host the OUTPUT catch-all had taken ten
packets of 646 bytes - DNS query size, one per SERVFAIL in dnsmasq's log -
while every ACCEPT rule including the bridge gateway's sat at zero.

This is not a property of any host's configuration. Two machines with
byte-identical /etc/resolv.conf behaved differently; what differed was the
engine, 29.7.1 forwarding somewhere this ruleset never sees and 26.1.5 not.
Debian's docker.io is 26.x, so the obvious way to install Docker on the
distribution this project's own runner runs is the way that breaks.

UDP/53 and TCP/53 are therefore opened, restricted to root-owned sockets by
the owner match. That restriction is the whole design, not a detail: the
forwarding socket belongs to dockerd, which is root, while the session runs
as `node` and the image ships no sudo - so a compromised session still cannot
address a resolver of its own choosing, and the DNS allowlist still holds
against the attack it exists for, which is data carried out in a query name
to a resolver no allowlist of names can vet. What the rule newly permits is
DNS from processes that are already root in a container holding NET_ADMIN,
and those can flush this ruleset outright instead.

Guarded rather than asserted, since xt_owner is a module: a kernel without it
should produce a container that explains itself, not one that aborts on an
iptables usage error.

The self-check gains a DNS probe ahead of both connectivity checks. It is
what should have made this a one-line failure. With DNS dead the negative
control fails for the wrong reason and prints "example.com is unreachable, as
it should be", so the run reports a passing check and then dies on the
positive control - which said only that a host was unreachable, having sent
curl's actual complaint to /dev/null. The new probe names the hop, prints
dig's answer, the packets the catch-all took, and dnsmasq's last lines.

runtime.bats gains the assertion that makes the opening safe rather than
merely convenient: `node` resolves an allowlisted name, and `node` cannot
reach 1.1.1.1 directly. A real resolver, so that a pass means the packet was
stopped rather than that the probe was aimed at nothing.
2026-08-06 02:19:50 -07:00
branderson 3fbd6ad75f Merge pull request 'Keep one copy of the pull_request_target rationale' (#87) from wf-comment into main
Reviewed-on: branderson/dejima#87
Reviewed-by: branderson <brad@branderson.io>
2026-08-05 22:49:17 -07:00
branderson 5dc158f3a1 Merge pull request 'Document that the sandbox authenticates as the operator' (#80) from sandbox-identity into main
Reviewed-on: branderson/dejima#80
2026-08-05 22:48:52 -07:00
branderson 14bb297dbc Merge branch 'main' into sandbox-identity
tests / runner-env (sandbox) (pull_request_target) Successful in 1m30s
tests / sandbox (proxy) (pull_request_target) Successful in 2m16s
tests / sandbox (static) (pull_request_target) Successful in 3m6s
tests / sandbox (providers) (pull_request_target) Successful in 4m18s
tests / sandbox (project) (pull_request_target) Successful in 5m52s
tests / host-docker (pull_request_target) Successful in 6m13s
tests / sandbox (remote) (pull_request_target) Successful in 5m9s
tests / sandbox (config) (pull_request_target) Successful in 6m1s
tests / runner-env (host-docker) (pull_request_target) Successful in 1m26s
tests / sandbox (audit) (pull_request_target) Successful in 8m52s
2026-08-05 22:03:44 -07:00
branderson 1cb1be5ee8 Merge pull request 'Say so when the marker directory is there but unwritable' (#85) from notify-dir-check into main
Reviewed-on: branderson/dejima#85
Reviewed-by: branderson <brad@branderson.io>
2026-08-05 22:03:19 -07:00
branderson 799ffa0770 docs: the sandbox authenticates as the operator, so identity controls are guards
tests / sandbox (proxy) (pull_request_target) Successful in 2m38s
tests / sandbox (providers) (pull_request_target) Successful in 4m24s
tests / runner-env (sandbox) (pull_request_target) Successful in 1m28s
tests / sandbox (project) (pull_request_target) Successful in 6m4s
tests / sandbox (static) (pull_request_target) Successful in 2m56s
tests / sandbox (config) (pull_request_target) Successful in 6m9s
tests / sandbox (remote) (pull_request_target) Successful in 5m6s
tests / host-docker (pull_request_target) Successful in 5m40s
tests / sandbox (audit) (pull_request_target) Successful in 8m31s
tests / runner-env (host-docker) (pull_request_target) Successful in 1m35s
A session pushes with the operator's forwarded SSH key and a token issued under
their account, so at the forge a push, a pull request, a comment or a merge
made from inside the sandbox is not similar to one the operator made - it is
the same event. Every forge control that answers "who did this" therefore
admits the sandbox exactly as it admits the operator, and there is no way to
configure around that while the identity is shared.

That matters most where someone would reasonably believe they had fixed
something. Putting yourself on a branch's merge whitelist puts the session on
it too. Required approvals are not merely weak but unusable, since a forge
will not let a pull request's author approve it and the session opens them as
you. And the forge's own audit log cannot attribute - the same shape as the
exit-time audit's time-window caveat, which this section points at rather than
restating.

The remedy is configuration rather than a missing feature, and it ships
alongside the problem so a reader is not left with nothing to do: a dedicated
forge account with write access, named by [forge.gitea] token_file and
[core] ssh_key, at which point the merge whitelist, the push whitelist and an
approval requirement all start enforcing what they appear to.

Two limits are stated rather than left to be discovered. Nothing enforces
which credential a push actually uses - [core] ssh_key is an independent path,
so a forwarded agent may still sign, making the separation decorative for the
one operation it was meant to constrain, which is worth a test push rather
than an assumption. And a separate identity does nothing for CI: a
write-capable account still pushes feature branches, which is what makes CI
execute code, and the token a forge injects into a job is a third principal
subject to none of those settings.

docs/ci.md's branch-protection prerequisite links here, because that section
told an operator to protect main without saying that on a default dejima setup
branch protection does not distinguish them from the session they are
protecting against.

static.bats pins both halves in security.md - the problem and the separation
that fixes it - and the link from ci.md. The anchor was checked against the
heading a renderer would derive, since a dead cross-document anchor is silent
in both files.
2026-08-05 21:57:19 -07:00
branderson bb2c1f218f test: give the marker-directory tests their own containers
tests / sandbox (config) (pull_request_target) Successful in 6m1s
tests / runner-env (host-docker) (pull_request_target) Successful in 1m26s
tests / sandbox (audit) (pull_request_target) Successful in 9m3s
tests / sandbox (proxy) (pull_request_target) Successful in 2m14s
tests / runner-env (sandbox) (pull_request_target) Successful in 1m26s
tests / sandbox (static) (pull_request_target) Successful in 3m6s
tests / sandbox (providers) (pull_request_target) Successful in 4m13s
tests / sandbox (remote) (pull_request_target) Successful in 5m3s
tests / sandbox (project) (pull_request_target) Successful in 5m55s
tests / host-docker (pull_request_target) Successful in 6m39s
They called container_name(), which is this file's SHARED container - created
in setup_file and used by every dexec test in the run. Starting a second
container under that name is the conflict CI reported, and the `docker rm -f`
at the end of each body would have destroyed the shared one had the name not
collided first. The failure was the only thing that stopped it.

Tests that start their own container here use a distinct prefix and register
it in teardown, which is unconditional; cleanup at the end of a body is not,
because bats runs bodies under errexit and a failed assertion abandons the
rest. Both now do that.

Two things in the first test would also have let it pass for the wrong reason.

It mounted the directory read-only to make it unwritable, which makes the
"reported, not repaired" assertion vacuous - root could not have chowned it
even if entrypoint.sh tried. Mode 555 on an ordinary mount leaves it
repairable from inside the container and still unwritable by the session user,
so that assertion tests something.

And it asserted the directory was still owned by root, which depends on who
ran the suite: mktemp -d follows the caller, so a non-root run would have
failed on ownership having nothing to do with the check. It now asserts what
the check actually reads and what repairing it would change - that the session
user still cannot write there.
2026-08-05 21:52:50 -07:00
branderson 4e6261561e remote: say so when the marker directory is there but unwritable
Restarting a sandbox container rather than relaunching it silently breaks
remote access, in a way that presents as three unrelated symptoms with no
obvious moment they started: notifications stop arriving, the session is
missing from dejima-attach, and the notify hook reports EACCES.

bin/dejima creates the marker directory per launch and removes it on exit, so
a restarted container has no bind source left. Docker's answer to a missing
bind source is not to refuse - it creates one, root-owned. The directory then
exists, which is all the notify hook's own `[ -d ]` guard asks, so the guard
passes and the write fails instead of the hook exiting quietly the way it does
when remote access simply is not configured. The session's registry entry went
with the same directory, which is the half that explains dejima-attach.

entrypoint.sh now checks it at startup, because that is the only place that
runs on a container restart - bin/dejima is not involved in one, so a check
there would have missed this entirely. A launcher-side check is added too, for
the different case of a stale root-owned leaf that mkdir -p happily succeeds
against, but it is the container-side one that catches the reported failure.

Reported, not repaired. Chowning the directory would restore notifications
while the session stayed absent from the registry - which bin/dejima writes
host-side and never mounts into the container - leaving a session that looks
fixed with half the feature still broken. The message names relaunching
instead.

Both checks are inside the "was this configured at all" guard, and there is a
test for that rather than only for the warning: remote access is opt-in and off
by default, so for almost every session this must say nothing. A warning about
an unconfigured optional feature would fire on every start, and a warning that
fires on every start is one nobody reads.

bin/dejima warns and continues rather than refusing. The state-dir check just
above it does refuse, but that one is about a symlink handing a session an
attacker-chosen path; this is only about an optional feature working, and
blocking a launch over undeliverable notifications costs more than it saves.

runtime.bats covers both directions against real containers - an unwritable
marker directory is reported and left alone, and an ordinary session says
nothing. static.bats pins the guard's placement, that nothing chowns the
directory, and that the launcher does not exit.
2026-08-05 21:52:50 -07:00
55 changed files with 7511 additions and 2462 deletions
+101 -5
View File
@@ -10,7 +10,7 @@ told) and `README.md`.
`test/runtime.bats` builds the image and boots real containers, so it needs a
working Docker. A dejima sandbox does not have one — the container has no Docker
socket, by design. Everything else runs anywhere, which is what the CI job names
mean: `sandbox` for the suites that need only a shell, `host-docker` for the one
mean: `confined` for the suites that need only a shell, `privileged` for the one
that does not.
## `bats` is a declared dependency here, not part of the image
@@ -48,13 +48,13 @@ a change to a shipped doc or a `docker/` file usually needs `static.bats` *as
well as* whatever else it touched — that suite asserts on files it does not own.
**CI runs `test/runtime.bats` on every pull request**, as a separate
`host-docker` job in `.gitea/workflows/tests.yml`. So being unable to run it
`privileged` job in `.gitea/workflows/tests.yml`. So being unable to run it
locally is not a reason to stop, to hedge, or to leave a change unfinished. Run
the sandbox suites, open the PR, and say plainly in the PR body that the
host-docker half was not run locally.
the confined suites, open the PR, and say plainly in the PR body that the
privileged half was not run locally.
**But do not read "CI covers it" as "it passed."** That job is pinned to
`runs-on: dejima-runtime`, a label only the one configured self-hosted runner
`runs-on: dejima-privileged`, a label only the one configured self-hosted runner
carries — it needs the host network namespace, the Docker socket, and a shared
volume, none of which a workflow can request. If that runner is offline the job
**queues instead of failing**, and the PR sits with the runtime half neither
@@ -80,6 +80,34 @@ a change would have CI reach a new host, that belongs in
allowlist is read from `main`, so an undeclared destination fails rather than
connects. See [docs/ci.md](docs/ci.md).
## Batch your pushes, because runs queue rather than supersede
Every push to an open pull request starts a full workflow run, and Gitea does
not implement `concurrency:` (see the comment at the top of
`.gitea/workflows/tests.yml`, which records the test). A second push does not
cancel the first: it queues behind it. So four pushes in ten minutes is four
full runs, and the load keeps arriving long after you have stopped pushing,
because the backlog is still draining.
That is not hypothetical here. A session pushing commit-by-commit while
iterating on a pull request queued enough runs to saturate this project's
forge: nginx stopped being able to open connections to Gitea at all, and the
requests that timed out were the runners' own `UpdateLog` and `UpdateTask`
calls plus the operator's browser. The forge went down for several minutes and
tripped uptime alerts.
So: **finish a change, then push once.** Commit as often as you like locally;
that costs nothing. What costs something is every `git push`. If you have three
commits to make, make them and push at the end, rather than pushing after each.
The same applies to forge API reads. `gitea-pr comments` fetches one request
per review on the pull request, so on a heavily reviewed one that is dozens of
requests per invocation. Run it when you need the answer, not repeatedly while
iterating, and never in a loop.
If a push does supersede a run you no longer care about, cancel the stale one
by hand in the web UI. Nothing does it for you.
## A test that reads its environment will pass here and fail on your machine
Twice now a test has stated a precondition by leaving something unset, which
@@ -95,3 +123,71 @@ the code was being worked on:
Both are fixed by stating the precondition (`env -u`, a stubbed `ss`) rather
than assuming it. When a test fails only in one environment, suspect this before
suspecting the code — and when writing one, set what you depend on.
## What lands on `main` here is a trust anchor, not just a release
`docs/responsible-use.md` is this project's own policy, and two of its points
bind while you are working *on* dejima rather than merely with it.
**`.dejima/allowed-domains` is read from `origin/main` by
dejima-ci-hardener**, which treats whatever it finds there as already
reviewed - that is how a CI runner decides what a job may reach. Adding a
domain to that file is not a convenience for one branch; it widens what every
future CI job on that runner can talk to, from the moment it merges. Say what
a domain is for in the pull request, and expect the question.
**Security here is not a later pass.** This repository's product is an
argument that a boundary holds. A change that quietly weakens one - a mount
that widens, a check that moves after the thing it guards, a test that stops
asserting what its name claims - is a defect in the product, not a rough edge
to tidy up afterwards. If you notice one while doing something else, say so
rather than leaving it for a pass that may not come.
## Write the comment that prevents the bug, not the one that shows your work
The prose here gets a compression pass periodically, and the same things get
cut every time. Save that pass the work:
- **Keep** the issue number, the failure mode, and why a check is shaped the
way it is. Someone has to be able to not reintroduce the bug.
- **Cut how you found it.** Which CI run failed, how many attempts it took,
what you suspected first: that is your story, not the reader's.
- **Cut the second and third consequence.** State the first. A reader who
needs the chain can follow it.
- **Cut anything the code directly below already says.**
- **Cut meta-commentary** - "the useful way to read this is", "what follows
is why". Say the thing instead.
- **No numbers that drift.** Test counts, directory sizes, how many files a
sweep found: wrong within a month, and nothing checks them. This applies to
pull request descriptions too, which are read once and then stop being
true.
- **One place.** An edge case belongs in `docs/`, not also in the README -
the README is what someone needs on first contact.
This is not an argument for thinner documentation: the note on voice in the
README prefers thorough over polished, deliberately. It is an argument
against padding. A paragraph carrying one fact should be one sentence.
The blocks that survive every pass are the ones where the argument *is* the
content: the `O_NOFOLLOW` open in `dejima-project-lib.sh`, the id-derivation
anchoring in `dejima-audit`'s `ack`, `--check-origin` in `dejima-ttyd`.
Length is not the problem; length carrying no fact is.
## Target `development`, not `main`
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:
```sh
gitea-pr create --base development --title ... --body-file -
```
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.
Rebase onto `origin/development` too, for the same reason: rebasing onto
`main` puts anything already merged to `development` back in your diff.
+16 -15
View File
@@ -8,9 +8,10 @@
# and boots real containers, so it needs a Docker daemon the sandbox
# deliberately does not have. That constraint is what a contributor has to
# reason about when deciding what they can run before pushing, so the jobs are
# named for it.
# named for the runner tier they need: `confined` for the ones a shell can
# run anywhere, `privileged` for the one that needs a daemon.
#
# The sandbox half is a matrix over its files rather than one invocation. The
# The confined half is a matrix over its files rather than one invocation. The
# files are independent, so running them together only serialised them: wall
# clock was their sum rather than the slowest one. It also means a failure
# names the suite that failed in the job list, instead of one red check
@@ -54,7 +55,7 @@ on:
# supersede, and cancel the stale run by hand if it is in the way.
jobs:
sandbox:
confined:
# A label carried only by a runner configured for this - see ci/ and
# docs/ci.md. Not a generic one, even though these suites genuinely do run
# anywhere a shell does, because "runs anywhere" is a statement about what
@@ -69,7 +70,7 @@ jobs:
# If no runner carries this label, these jobs queue. That is the intended
# failure: it is a better one than a green run on a host that grants
# everything.
runs-on: dejima-ci
runs-on: dejima-confined
strategy:
# One suite per job. fail-fast off because these cover unrelated areas -
# cancelling the other six on the first failure would hide whether a
@@ -85,7 +86,7 @@ jobs:
- proxy
- config
- providers
name: sandbox (${{ matrix.suite }})
name: confined (${{ matrix.suite }})
container:
# Debian bookworm plus a node binary - node is what actions/checkout
# needs to run at all, and apt-get is what everything below needs.
@@ -165,7 +166,7 @@ jobs:
chown -R node:node .
su node -c "TERM=xterm-256color bats test/${{ matrix.suite }}.bats"
host-docker:
privileged:
# A different label from the job above, and the only one in this file that
# asks for anything privileged. This job needs the host network namespace,
# the Docker socket and the shared volume below, none of which a workflow
@@ -180,7 +181,7 @@ jobs:
# The runner behind this label can be taken by anything it runs (see
# ci/act-runner-runtime.example.yaml), so it belongs on a host that exists
# for that and holds nothing else.
runs-on: dejima-runtime
runs-on: dejima-privileged
container:
image: node:20-bookworm
# One volume, and it is load-bearing - see TMPDIR on the bats step for
@@ -447,9 +448,9 @@ jobs:
# These fail on how the RUNNER is configured, not on anything in a pull
# request. A red check here means the machine needs attention; it does not
# mean the branch does.
runner-env-sandbox:
runs-on: dejima-ci
name: runner-env (sandbox)
runner-env-confined:
runs-on: dejima-confined
name: runner-env (confined)
container:
image: node:20-bookworm
steps:
@@ -516,11 +517,11 @@ jobs:
env:
# Declared, not detected: landing on the wrong runner should fail
# rather than quietly select a different set of assertions.
DEJIMA_CI_TIER: sandbox
DEJIMA_CI_TIER: confined
runner-env-host-docker:
runs-on: dejima-runtime
name: runner-env (host-docker)
runner-env-privileged:
runs-on: dejima-privileged
name: runner-env (privileged)
container:
image: node:20-bookworm
steps:
@@ -581,4 +582,4 @@ jobs:
- uses: actions/checkout@v4
- run: bats test/ci-runner.bats
env:
DEJIMA_CI_TIER: host-docker
DEJIMA_CI_TIER: privileged
+66
View File
@@ -0,0 +1,66 @@
# Attribution
The code in the dejima repositories is GPL-3.0-or-later; see `LICENSE`. The
artwork is not code and is not covered by it.
This file is the canonical statement for the dejima marks. Other repositories
link here rather than restating the terms.
## Authorship
All logos, assets, and artwork are human-made. No AI tools were used in their
creation. The artist is anonymous.
The palette is based on [gruvbox](https://github.com/morhetz/gruvbox) by Pavel
Pertsev, which is MIT-licensed. The colour values are gruvbox's; the marks
drawn with them are not.
## What is covered
Both marks, in every form:
- the logo: the filled tiles and the outline variants, in both light-mode and
dark-mode renderings, at any resolution, and the favicon;
- the wordmark: the solid and outline renderings, again in both modes.
The terms attach to the marks rather than to this list, so renderings not
listed here are covered too.
## Terms
The dejima name, logo and wordmark are trademarks of Brad Anderson **as they
identify this software**. A trademark attaches to a mark as used for
particular goods and services, never to a word in the abstract, and the claim
here goes no further than that.
Dejima is a real place - the island in Nagasaki harbour this project is named
after, and rebuilt there today. Nothing in this file claims the word itself,
its historical or geographic sense, or any use unconnected with this
software. Writing about the island, naming something after it, or using the
name in any way that is not about this project is outside these terms and
always was.
The marks are not licensed under the GPL; GPL-3.0 §7(e) permits declining to
grant trademark rights. All rights not granted below are reserved.
**You may, without asking:**
- reproduce them unmodified to refer to dejima;
- redistribute them unmodified as part of an unmodified copy of a repository
that ships them.
**You may not, without written permission:**
- modify the artwork: recolour, redraw, crop, animate, or fold it into
another mark;
- use the artwork in your own work, or as the mark of any other project,
product or organisation;
- use the name for software in a way likely to be taken as referring to this
project - which is the only sense in which the name is claimed at all;
- use any of them so as to suggest that dejima endorses, sponsors or produced
your work.
A fork that remains a copy of dejima may keep the files. A fork that diverges
into a project of its own must replace them with a mark of its own.
Copyright in the image files is reserved.
+39
View File
@@ -0,0 +1,39 @@
# Contributing
[← dejima README](README.md)
dejima is under active, rapid development, and it is developed on a private
forge. That forge does not allow account registration, and it does not accept
anonymous issues or pull requests. So there is currently no way for you to
file either, and that is a property of where this lives rather than a comment
on the contribution.
That will change: this project will provide a way to submit issues and pull
requests. Until it does, if you are using dejima and need it to do something
different, **fork it and make the change in your fork**. Nothing here depends
on an upstream maintainer accepting a patch. The fork is yours to run, and
running it is the point.
## Branches
Work is opened against `development`, which merges to `main` periodically.
`main` is what a reader should expect to be releasable; `development` is
where it happens first.
## Expect it to move
Significant change is coming in the near future. Config keys, interfaces, the
layout of the docs, and the boundaries between the CLIs are all still
settling. A fork taken today will diverge from upstream quickly.
If you do fork, pinning to a commit you have read is more useful here than
tracking a branch that is being rewritten under you.
## If you are running an agent against this repository
`docs/responsible-use.md` applies to dejima itself as much as to anything else
you point it at, and one rule in it is load-bearing here: what reaches `main`
becomes the baseline for everything after it. dejima-ci-hardener reads
`.dejima/allowed-domains` from `origin/main` and treats what it finds there as
reviewed, so a change to that file is a change to what a CI runner will let
through.
+410 -971
View File
File diff suppressed because it is too large Load Diff
+77
View File
@@ -0,0 +1,77 @@
# Security policy
This is the security policy for every repository in the dejima organisation.
Individual repositories state what is specific to them and link here for the
rest.
## What dejima claims
It bounds what an agent session can reach. It does not make an agent safe to
run.
A session runs in a container with no permission prompts, one bind-mounted
project directory, a deny-by-default egress allowlist covering DNS as well as
IP, and no copy of the credentials it authenticates with. That allowlist
bounds where a prompt injection or a compromised dependency can send code and
where it can fetch its next instruction. It does not prevent either one.
Nothing here holds against a container escape or a kernel bug. The in-session
hooks are fast-fail guards, not a boundary. The exit-time audit runs after the
session has ended and attributes nothing to it. And none of it speaks to
whether the code a session wrote is correct, secure, or fit to merge.
[docs/security.md](docs/security.md) states the threat model and covers each
of these in detail.
## Reporting a vulnerability
Email **dejima@branderson.io**. Please do not open a public issue for a
vulnerability; the tracker is public, and a report there is a disclosure.
Useful things to include:
1. what an attacker gets;
2. the steps to reproduce it;
3. the repository and the commit you tested.
## What you can expect in return
Nothing is promised: no reply, no acknowledgement, no triage, no fix, and no
timeline for any of them. Reports are read when there is time to read them.
Nothing is asked in return either. There is no embargo period and no
coordinated disclosure window. Disclose on whatever schedule you think is
right.
## This is not proven software
These projects are in early development. Nothing in them has been through
outside review, an external audit, or adversarial testing by anyone other
than the people who wrote them. Where a design argument is stated it is one
person's reasoning; where a test asserts something it asserts what that
person thought to check.
Treat every security property described in any of these repositories as a
claim awaiting scrutiny rather than one that has survived it, and do not
deploy them where being wrong would matter.
## There is no support
These are personal projects with one maintainer.
There is no paid version, no support contract, no service level, no sponsorship
attached to them, and no commercial relationship with anyone who uses them.
Questions may go unanswered. Issues may go unread. Nothing here is developed or
supplied in the course of a commercial activity.
Whoever deploys this is responsible for the result.
## Supported versions
The tip of `main`, and nothing else. There are no maintenance branches, no
backports and no security releases for older tags or commits.
## No warranty
The software is provided without warranty of any kind. See sections 15 and 16
of each repository's `LICENSE`.
+19
View File
@@ -0,0 +1,19 @@
# assets
The dejima wordmark, in the two versions the README needs: `-darkmode` for a
page that renders dark, `-lightmode` for one that renders light. The suffix
names the mode a file is for, not the colour of its ink, so `-darkmode` is the
pale one.
The README points at `-darkmode` unconditionally. A mirror on a light-themed
host changes that one line; the comment above the tag says why it is not
conditional.
**These are trademarks and are not covered by this repository's GPL licence.**
Who holds them, and what may be done with them, is stated once for the whole
organisation, in this repository:
[ATTRIBUTION.md](../ATTRIBUTION.md)
Other repositories in the organisation link to that file by absolute URL, so
it does not move or get renamed without their links breaking with it.
+21
View File
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="122" height="20" role="img"
aria-label="Developed in dejima">
<title>Developed in dejima</title>
<linearGradient id="g" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r"><rect width="122" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="79" height="20" fill="#282828"/>
<rect x="79" width="43" height="20" fill="#8ec07c"/>
<rect width="122" height="20" fill="url(#g)"/>
</g>
<g font-family="Verdana,DejaVu Sans,Geneva,sans-serif" font-size="11"
text-anchor="middle">
<text x="39.5" y="15" fill="#010101" fill-opacity=".3">Developed in</text>
<text x="39.5" y="14" fill="#ebdbb2">Developed in</text>
<text x="100.5" y="15" fill="#010101" fill-opacity=".3">dejima</text>
<text x="100.5" y="14" fill="#282828">dejima</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 969 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

+409 -136
View File
@@ -21,6 +21,15 @@ COMPOSE_FILE="$REPO_ROOT/docker/docker-compose.yml"
source "$SCRIPT_DIR/dejima-project-lib.sh"
# shellcheck source=dejima-provider-lib.sh
source "$SCRIPT_DIR/dejima-provider-lib.sh"
# shellcheck source=dejima-color-lib.sh
source "$SCRIPT_DIR/dejima-color-lib.sh"
# shellcheck source=dejima-slug-lib.sh
source "$SCRIPT_DIR/dejima-slug-lib.sh"
# shellcheck source=dejima-harness-lib.sh
source "$SCRIPT_DIR/dejima-harness-lib.sh"
# fd 2: everything this script says for itself goes to stderr, so the
# session's own stdout stays exactly what the container wrote.
color_init 2
# Flags come first, then the optional workspace path, then "--" and
# everything after that reaches the container command untouched - including a
@@ -54,6 +63,65 @@ _need_value() {
}
}
# Per-launch additions to [mounts] readonly and [mounts] read_write, for the
# one-off cases: reading another checkout while migrating code out of it,
# writing to a scratch directory for one session. Repeatable, one path each.
#
# These append to the config's own lists rather than carrying their own mount
# code, which is the whole design. Everything that already applies to a
# configured mount then applies to a flag automatically and with nothing
# duplicated: the existence check, the refusal when a path names the approval
# store or the audit stores, the refusal when the same path appears in both
# lists, and the read-write paths the session briefing and the audit report
# header name. A flag is a way to say where, not a way around any of that.
EXTRA_RO_FLAGS=()
EXTRA_RW_FLAGS=()
FLAG_MOUNTS=""
_add_mount_flag() {
local flag="$1" value="$2"
# Both lists are colon-separated, so a path containing one cannot be
# represented in them at all - it would silently split into two mounts
# that each fail the existence check. Say so instead.
case "$value" in
*:*) echo "$flag path must not contain a colon: $value" >&2; exit 1 ;;
esac
# Same leading-~ expansion the config's list:path type does, for the
# quoted case the shell leaves alone.
value="${value/#\~/$HOME}"
# Relative paths are worth accepting here and meaningless in a config
# file, which has no working directory to be relative to. Made absolute
# by prefixing rather than by readlink -f: resolving would mount the
# symlink's target under a path the operator never typed.
case "$value" in /*) ;; *) value="$PWD/$value" ;; esac
case "$flag" in
--extra-readonly) EXTRA_RO_FLAGS+=("$value") ;;
--extra-read-write) EXTRA_RW_FLAGS+=("$value") ;;
esac
# Recorded here, at parse time, and not where the lists are assembled:
# that happens inside a command substitution, whose subshell would throw
# the assignment away and leave every flag reported as a config entry.
FLAG_MOUNTS="${FLAG_MOUNTS:+$FLAG_MOUNTS:}$value"
}
_append_mounts() {
local list="$1"; shift
local p
for p in "$@"; do
list="${list:+$list:}$p"
done
printf '%s' "$list"
}
# Which of the two the operator actually wrote, for the refusals below.
# Without this a mistyped --ro is reported as a "[mounts] readonly entry",
# sending someone to a config file that does not mention the path at all.
_mount_label() {
local path="$1" from_config="$2" from_flag="$3"
case ":$FLAG_MOUNTS:" in
*":$path:"*) printf '%s' "$from_flag" ;;
*) printf '%s' "$from_config" ;;
esac
}
PRINT_CONFIG=0
PRINT_BRIEFING=0
ALLOW_GITHUB_PUSH_FLAG=0
@@ -67,6 +135,12 @@ while [ $# -gt 0 ]; do
--cmd) _need_value --cmd "${2:-}"; _set_override core.command "$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)
_need_value --extra-readonly "${2:-}"
_add_mount_flag --extra-readonly "$2"; shift 2 ;;
--extra-read-write|--rw)
_need_value --extra-read-write "${2:-}"
_add_mount_flag --extra-read-write "$2"; shift 2 ;;
--*) echo "unknown option: $1" >&2; exit 1 ;;
*) break ;;
esac
@@ -157,28 +231,18 @@ if ! config_exists; then
exit 1
fi
# A selected forge's env_export can name a variable core also fills from its
# own config. Today that is GH_TOKEN, which core sets from the read-only
# credential at [github] token_file - one every session gets regardless of
# which forge is selected - and which a forge exporting the same name takes over
# for the launch, since a single variable can only carry one token. Said out
# loud rather than left as two configured credentials with one silently
# ignored: an operator who set both is entitled to know which is in effect. A
# warning, not a refusal - the forge's value winning is exactly what
# selecting that forge asked for.
# A forge's env_export can name a variable core also fills - today GH_TOKEN,
# which core sets from [github] token_file for every session regardless of
# forge. One variable carries one token, so the forge's wins. Warned rather
# than left silent: an operator who configured both is entitled to know which
# is in effect. Not a refusal, since the forge winning is what selecting it
# asked for.
#
# Driven off the manifest's env_export rather than off the forge's name: the
# collision is a property of the variable a forge exports, identical for any
# forge that exports it, and core has no business knowing which forge that is
# today. Checked here, ahead of --print-config's own exit below (like
# _warn_legacy_env above it) rather than down by the GH_TOKEN block further
# down that --print-config never reaches, so --print-config reports it too.
#
# Both files, not just core's: a forge key resolving to nothing is the
# "wholly unconfigured" case forge_env_exports deliberately emits no pair for
# (see its own comment), leaving core's own value in effect - so claiming the
# forge's key won would be false in exactly that state. The warning only
# means something once both are actually set.
# Driven off env_export rather than the forge's name - the collision belongs
# to the variable, not to any particular forge. Checked here, ahead of
# --print-config's exit, so that reports it too. Both values must actually be
# set: a forge key resolving to nothing leaves core's in effect, and claiming
# the forge won would be false.
_warn_forge_env_collisions() {
local forge="$1" pair name ref core core_name core_section core_leaf
local core_file forge_file
@@ -234,8 +298,42 @@ fi
DEJIMA_FORGE_NAME="$(config_get core forge)"
DEJIMA_WORKFLOW_NAME="$(config_get core workflow)"
DEJIMA_HARNESS_NAME="$(config_get core harness)"
# No default: claude-code was one, and a release changing it would move an
# operator who never chose to a different harness on upgrade.
if [ -z "$DEJIMA_HARNESS_NAME" ]; then
echo "[core] harness is not set in $(config_path)." >&2
echo "There is no default, so a later release cannot change your choice." >&2
echo "" >&2
echo " [core]" >&2
echo " harness = claude-code" >&2
echo "" >&2
echo "Available: $(provider_list harness | tr '\n' ' ')" >&2
exit 1
fi
provider_validate_selection "$DEJIMA_FORGE_NAME" "$DEJIMA_WORKFLOW_NAME" "$DEJIMA_HARNESS_NAME" || exit 1
# The selected harness's plugin: what dejima does for this harness that no
# manifest value could state. Loaded once the selection is validated, and
# before anything that calls a hook. A manifest without a plugin is refused
# rather than treated as a lighter kind of support - see "Adding a new
# harness" in docs/development.md.
harness_plugin_load "$DEJIMA_HARNESS_NAME"
# Whether GitHub is a push destination this session, which the briefing tells
# the agent about. Two ways in: the per-launch flag, or a forge whose hosts
# core allowlists (github) under a workflow that pushes. Derived from the
# manifests directly rather than from DEJIMA_WORKFLOW_PUSH, which is resolved
# further down - this has to be settled before --print-briefing exits, or that
# would render a briefing a real launch does not.
DEJIMA_GITHUB_PUSH=off
if [ "$ALLOW_GITHUB_PUSH_FLAG" = 1 ]; then
DEJIMA_GITHUB_PUSH=on
elif [ "$(manifest_get forge "$DEJIMA_FORGE_NAME" hosts_allowlisted_by_core)" = "on" ] \
&& [ "$(manifest_get workflow "$DEJIMA_WORKFLOW_NAME" push)" = "on" ]; then
DEJIMA_GITHUB_PUSH=on
fi
export DEJIMA_GITHUB_PUSH
# Resolved here, ahead of --print-briefing below, because briefing_compose
# needs DEJIMA_RW_MOUNTS (the {{writable_mounts}} placeholder) to render the
# same sentence a real launch renders - see the read_write mounts block this
@@ -261,6 +359,8 @@ WORKSPACE_DIR="$(cd "$WORKSPACE_DIR" && pwd)"
AUDIT_DIR="$(config_get audit dir)"
BASELINE_DIR="$(config_get audit baselines_dir)"
SANDBOX_EXTRA_MOUNTS="$(config_get mounts readonly)"
SANDBOX_EXTRA_MOUNTS="$(_append_mounts "$SANDBOX_EXTRA_MOUNTS" \
${EXTRA_RO_FLAGS[@]+"${EXTRA_RO_FLAGS[@]}"})"
# Read-write host mounts. Distinct from [core] share_dir, which is mounted at
# ~/shared - a well-known handoff point the agent is told about by name -
@@ -281,7 +381,41 @@ _paths_overlap() {
case "$b/" in "$a"/*) return 0 ;; esac
return 1
}
# The stores that decide what a session may do next, and the record of what
# it did. Read-only is not enough to make mounting them harmless: the
# approval store discloses every project's approved domains and
# dependencies, and the audit baselines say which findings have already been
# waved through. Refusing beats mounting and hoping the mode holds (#15).
GUARDED_STORES=()
_guarded_stores() {
[ ${#GUARDED_STORES[@]} -eq 0 ] || return 0
GUARDED_STORES=("$(config_root)" "$AUDIT_DIR" "$BASELINE_DIR")
}
# Refuse $2 if it overlaps any of the paths in $3.., in either direction: an
# ancestor of a store exposes it just as surely as the store itself.
_refuse_guarded_mount() {
local kind="$1" path="$2"; shift 2
local real guarded guarded_real
# readlink -m rather than -f, same reason as the read_write guard below:
# -f gives nothing for a path that does not exist yet, and an empty
# result compares equal to nothing and would sail through.
real="$(readlink -m "$path")"
for guarded in "$@"; do
[ -n "$guarded" ] || continue
guarded_real="$(readlink -m "$guarded")"
_paths_overlap "$real" "$guarded_real" || continue
echo "Refusing $kind: $path" >&2
echo "It overlaps $guarded_real, which dejima needs out of a session's reach." >&2
echo "Even read-only, that hands the session every project's approvals or the" >&2
echo "record of what earlier sessions were caught doing." >&2
exit 1
done
}
SANDBOX_RW_MOUNTS="$(config_get mounts read_write)"
SANDBOX_RW_MOUNTS="$(_append_mounts "$SANDBOX_RW_MOUNTS" \
${EXTRA_RW_FLAGS[@]+"${EXTRA_RW_FLAGS[@]}"})"
RW_MOUNT_PATHS=()
DEJIMA_RW_MOUNTS=""
if [ -n "$SANDBOX_RW_MOUNTS" ]; then
@@ -289,7 +423,7 @@ if [ -n "$SANDBOX_RW_MOUNTS" ]; then
for path in "${rw_paths[@]}"; do
[ -n "$path" ] || continue
if [ ! -d "$path" ]; then
echo "[mounts] read_write entry is not an existing directory: $path" >&2
echo "$(_mount_label "$path" "[mounts] read_write entry" "--extra-read-write path") is not an existing directory: $path" >&2
exit 1
fi
# readlink -m, not -f: -f yields nothing unless at most the final
@@ -306,7 +440,7 @@ if [ -n "$SANDBOX_RW_MOUNTS" ]; then
"$HOME/.claude" "$HOME/.local/bin" "$REPO_ROOT" "$WORKSPACE_DIR"; do
guarded_real="$(readlink -m "$guarded")"
_paths_overlap "$rw_real" "$guarded_real" || continue
echo "Refusing [mounts] read_write entry: $path" >&2
echo "Refusing $(_mount_label "$path" "[mounts] read_write entry" "--extra-read-write path"): $path" >&2
echo "It overlaps $guarded_real, which dejima needs out of a session's reach." >&2
echo "A writable mount there would let a session approve its own domains and" >&2
echo "dependencies, delete the audit findings it is named in, or plant code" >&2
@@ -471,7 +605,7 @@ if [ -n "$SANDBOX_SSH_KEY" ]; then
echo "Recommended: chmod 600 $SANDBOX_SSH_KEY" >&2
fi
# SANDBOX_SSH_KEY is meant to be a dedicated, low-privilege deploy key
# (see README "Headless git push"), not your real identity - it stays
# (see docs/configuration.md "Headless git push"), not your real identity - it stays
# loaded in an in-container agent for the whole session, unattended.
# Nothing enforces that convention, so at least catch the common case
# of pointing it at a default personal keyfile by mistake.
@@ -480,7 +614,7 @@ if [ -n "$SANDBOX_SSH_KEY" ]; then
echo "Warning: $SANDBOX_SSH_KEY looks like a default personal SSH key," \
"not a dedicated one." >&2
echo "It will be loaded into the sandbox for the whole unattended session -" \
"see README 'Headless git push'." >&2
"see docs/configuration.md." >&2
;;
esac
fi
@@ -496,19 +630,27 @@ fi
if [ "$DEJIMA_WORKFLOW_PUSH" = "on" ] && [ "$have_forwarded_agent" = 0 ] && [ -z "$SANDBOX_SSH_KEY" ]; then
echo "SSH_AUTH_SOCK is not set or not a valid socket, and [core] ssh_key is not set." >&2
echo "Either start ssh-agent and run ssh-add, or set [core] ssh_key in $(config_path) to a" >&2
echo "dedicated key for headless use - see README 'Headless git push' section." >&2
echo "dedicated key for headless use - see docs/configuration.md." >&2
exit 1
fi
# A valid forwarded socket with zero identities loaded forwards fine (the
# container sees a live, working agent) but is useless for git-over-ssh -
# warn now rather than have that surface confusingly deep into a session as
# a failed push. Non-fatal: a task might not need git-over-ssh at all, and
# SANDBOX_SSH_KEY (if set) provides its own identity independently of this.
if [ "$have_forwarded_agent" = 1 ] && ! ssh-add -l >/dev/null 2>&1; then
# a failed push. Non-fatal: a task might not need git-over-ssh at all.
#
# Silent when [core] ssh_key is set, because then the premise is false: the
# key is loaded into the container's own agent and pushes work without the
# forwarded one carrying anything. It is also the better arrangement of the
# two - a dedicated identity that works unattended, rather than the
# operator's own - so warning about the fallback being empty, while the
# recommended path is configured and working, tells someone who took the
# advice that they got it wrong.
if [ "$have_forwarded_agent" = 1 ] && [ -z "$SANDBOX_SSH_KEY" ] \
&& ! ssh-add -l >/dev/null 2>&1; then
echo "Warning: ssh-agent has no identities loaded (ssh-add -l shows none)." >&2
echo "git-over-ssh (e.g. pushing to the configured forge) will fail until you ssh-add a key," >&2
echo "unless [core] ssh_key is also set." >&2
echo "or set [core] ssh_key in $(config_path) to a dedicated key - see docs/configuration.md." >&2
fi
# Deliberately not `gh auth token` / gh's own hosts.yml: a normal `gh
@@ -565,46 +707,29 @@ if ! settings_error="$(jq . "$HOME/.claude/settings.json" 2>&1 >/dev/null)"; the
exit 1
fi
# Claude Code's own internal per-command sandboxing (~/.claude/settings.json's
# "sandbox" block) is redundant here - it can't even function inside this
# container (bwrap can't create a namespace, and getting it to would need
# privilege escalation approaching --privileged, undoing this container's own
# hardening), and this container is already a real, working sandbox on its
# own. A session can't be trusted to turn it off itself, so the override is
# generated here, on the host, and reaches Claude Code as an extra settings
# layer: docker-compose.yml mounts this file read-only at a container-local
# path and claude-wrapper.sh passes it as --settings, which loads above
# ~/.claude/settings.json in the precedence order. Everything else in the
# operator's real settings (hooks, permissions, plugins) still applies from
# there, so this file holds only what the sandbox overrides.
# Claude Code's own per-command sandboxing is redundant here and cannot work
# anyway - bwrap can't create a namespace without privileges that would undo
# this container's hardening - and a session can't be trusted to turn it off
# itself. So the override is generated host-side and reaches Claude Code as
# an extra --settings layer above ~/.claude/settings.json, which holds only
# what the sandbox overrides; the operator's hooks, permissions and plugins
# still apply from there.
#
# The earlier arrangement - a full modified copy of the operator's settings,
# mounted over ~/.claude/settings.json - detached itself whenever the host
# rewrote the real file, because that mount point sits inside the read-write
# ~/.claude mount and a rename unlinks the dentry the mount is pinned to. The
# session then silently fell back to the operator's settings, with sandboxing
# back on and the notify hooks below gone. Mounting outside ~/.claude removes
# that: nothing on the host can rename over a container-local path. See #47.
# It is mounted at a container-local path, not over ~/.claude/settings.json.
# That earlier arrangement detached whenever the host rewrote the real file
# (a rename unlinks the dentry the mount is pinned to), silently restoring
# the operator's settings with sandboxing back on. See #47.
SANDBOX_SETTINGS_FILE="$(mktemp "${TMPDIR:-/tmp}/dejima-settings.XXXXXX.json")"
jq -n '{sandbox: {enabled: false}}' > "$SANDBOX_SETTINGS_FILE"
# ~/.claude.json mixes live app state (project registry, usage stats,
# caches) with config (mcpServers) that can't be split at the mount level -
# unlike settings.json above, it can't just be made read-only either:
# Claude Code does an atomic write-temp-then-rename against this exact file
# during normal startup (before login even completes), and a read-only
# file makes that hang instead of failing cleanly (confirmed against a
# real read-only copy of this file - see
# https://github.com/anthropics/claude-code/issues/29162). Generate a
# throwaway copy instead, seeded from the real file, and mount *that*
# read-write in its place - same "generate a sandbox-specific file on the
# host" pattern as SANDBOX_SETTINGS_FILE above, just solving a different
# problem: nothing a compromised session writes here (mcpServers or
# anything else) ever reaches the real file, since the copy is discarded,
# not merged back, when this session ends. This also means concurrent
# sandbox sessions never contend over the same file - each gets its own
# copy. See README "Throwaway ~/.claude.json" for exactly what that means
# doesn't carry over from a sandboxed session.
# ~/.claude.json mixes live app state with config (mcpServers) that cannot be
# split at the mount level, and read-only is not an option: Claude Code does
# an atomic write-and-rename against it during startup, and a read-only file
# makes that HANG rather than fail cleanly (anthropics/claude-code#29162).
# So it gets a throwaway copy, seeded from the real file and discarded rather
# than merged back - nothing a session writes here reaches the host, and
# concurrent sessions never contend. See docs/configuration.md, "Config
# parity with your host Claude Code setup".
SANDBOX_CLAUDE_JSON_FILE="$(mktemp "${TMPDIR:-/tmp}/dejima-claude-json.XXXXXX.json")"
if [ -f "$HOME/.claude.json" ]; then
cp "$HOME/.claude.json" "$SANDBOX_CLAUDE_JSON_FILE"
@@ -631,11 +756,32 @@ SANDBOX_AUDIT_STATE="$(mktemp "${TMPDIR:-/tmp}/dejima-audit.XXXXXX")"
# and SANDBOX_DERIVED_CONTEXT is one of the `rm -rf` arguments.
DEJIMA_NOTIFY_DIR=""
DEJIMA_SESSION_FILE=""
SANDBOX_REMOTE_SETTINGS_TMP=""
SANDBOX_NVIM_LOCKFILE=""
SANDBOX_DERIVED_DOCKERFILE=""
SANDBOX_DERIVED_CONTEXT=""
# Names each host-side task in cleanup() as it starts. Between the container
# exiting and the operator's shell prompt returning, dejima audits every
# surface the session could have written to and reads back the transcripts it
# left; on a real project that is seconds of nothing on screen, and a
# terminal that prints nothing reads as one that has hung. The remedy an
# operator reaches for then is Ctrl-C, which is precisely when the audit
# must not be interrupted.
#
# Only once a snapshot exists, which is the same condition `dejima-audit
# compare` uses to decide it has anything to do: an exit before the session
# ever started is instant, and its last line is the error that caused it -
# announcing work that is not happening would push that off the screen.
#
# stderr, like everything else this trap prints, and host-side only: none of
# it is ever routed back into a session's context.
exit_step() {
# `return 0` rather than letting the test be the last command: a nonzero
# status from anything in an EXIT trap replaces the script's own.
[ -s "${SANDBOX_AUDIT_STATE:-}" ] && printf 'dejima: %s...\n' "$1" >&2
return 0
}
cleanup() {
# Every rm below is guarded with `|| true`: under set -e, a failing
# command in an EXIT trap replaces the shell's own exit status, so none
@@ -667,7 +813,7 @@ cleanup() {
fi
rm -f "$SANDBOX_SETTINGS_FILE" "$SANDBOX_CLAUDE_JSON_FILE" "${SANDBOX_NVIM_LOCKFILE:-}" "${SANDBOX_FAKE_CREDENTIALS_FILE:-}" "${SANDBOX_DERIVED_DOCKERFILE:-}" "${SANDBOX_BRIEFING_FILE:-}" || true
rm -rf "${SANDBOX_GITCONFIG_DIR:-}" "${SANDBOX_ANTHROPIC_PROXY_DIR:-}" "${SANDBOX_DERIVED_CONTEXT:-}" || true
rm -f "${DEJIMA_SESSION_FILE:-}" "${SANDBOX_REMOTE_SETTINGS_TMP:-}" || true
rm -f "${DEJIMA_SESSION_FILE:-}" || true
# Unlike the silent `|| true` guards above, this removal (and, before
# the audit below was moved in, the last command of an EXIT trap) warns
# out loud when it fails: the marker directory is bind-mounted
@@ -679,6 +825,15 @@ cleanup() {
# dejima-notify-watch's marker consumption.
rm -rf "${DEJIMA_NOTIFY_DIR:-}" \
|| echo "warning: could not remove the marker directory ${DEJIMA_NOTIFY_DIR:-}" >&2
exit_step "session ended - keeping what it compacted away"
# Keep whatever the session compacted away, into a store nothing mounts.
# `|| true` for the same reason every other line in this trap has it: a
# failure here must not replace the container's exit status.
#
# 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
# 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
@@ -688,6 +843,7 @@ cleanup() {
# STATE is dropped from the rm -f above and freed only after compare has
# read it, so this function's own cleanup can't race the audit that
# needs it - and its removal is guarded too, so it stays truly last.
exit_step "checking what changed on the host while it ran"
DEJIMA_RW_MOUNTS="$DEJIMA_RW_MOUNTS" "$SCRIPT_DIR/dejima-audit" compare "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
rm -f "$SANDBOX_AUDIT_STATE" || true
}
@@ -764,6 +920,42 @@ case "$SANDBOX_CMD" in
;;
esac
export SANDBOX_CMD
# What the wrapper inside the container needs to shadow the harness's binary
# and pin its flags, resolved here because the manifests are host-side data
# the container never sees. Every value is the selected harness's own; none
# of it is written down in the image.
#
# SANDBOX_HARNESS_COMMAND is the name to shadow, and is the manifest's rather
# than [core] command: overriding the latter to `nvim` changes what dejima
# launches, not which harness is wrapped, and an editor-driven session that
# then runs the harness from a terminal must still get the flags.
SANDBOX_HARNESS_COMMAND="$(manifest_get harness "$DEJIMA_HARNESS_NAME" command)"
SANDBOX_HARNESS_REAL="$(manifest_get harness "$DEJIMA_HARNESS_NAME" command_path)"
SANDBOX_HARNESS_ARGS="$(manifest_get harness "$DEJIMA_HARNESS_NAME" no_confirm_args)"
# Named in full rather than templated: the settings layer's container-side
# path is dejima's own fixed contract (docker-compose.yml mounts it there),
# not a value that varies per run, and {{...}} in a manifest belongs to the
# briefing composer's substitution rather than to a second mechanism that
# merely looks like it.
SANDBOX_HARNESS_SETTINGS="$(manifest_get harness "$DEJIMA_HARNESS_NAME" settings_args)"
# Unattended operation is R1 of the capability bar, and a harness whose
# manifest names no way to ask for it would run asking for confirmation on
# every tool call, against a container built on the premise that it does not.
# That fails silently and looks like the harness hanging, so refuse instead.
if [ -z "$SANDBOX_HARNESS_ARGS" ]; then
echo "Harness '$DEJIMA_HARNESS_NAME' declares no no_confirm_args." >&2
echo "dejima runs unattended; a harness that stops to ask cannot be run that" >&2
echo "way. See \"What a harness must be able to do\" in docs/development.md." >&2
exit 1
fi
export SANDBOX_HARNESS_COMMAND SANDBOX_HARNESS_REAL SANDBOX_HARNESS_ARGS \
SANDBOX_HARNESS_SETTINGS
# [core] command defaults to the selected harness's own command rather than
# to a literal in core's schema, which is where `claude` used to sit.
[ -n "$SANDBOX_CMD" ] || SANDBOX_CMD="$SANDBOX_HARNESS_COMMAND"
export SANDBOX_CMD
# docker-compose.yml/entrypoint.sh compare these against "1"/"0", not
# "on"/"off" - config_get_bool is what produces that, config_get would pass
# "on"/"off" straight through and (among other things) silently disable the
@@ -833,6 +1025,13 @@ for mounted_dir in hooks skills commands agents output-styles; do
case " $mounted_dirs " in
*" $real_dir "*) continue ;;
esac
# The target is discovered, not configured, so nothing has vetted it
# (#15). A hook symlinked into the approval store would mount it
# here, read-only and silently. Refusing rather than skipping: a
# skipped mount leaves the symlink dangling inside the container and
# the hook failing for a reason nothing states.
_guarded_stores
_refuse_guarded_mount "~/.claude symlink target" "$real_dir" "${GUARDED_STORES[@]}"
mounted_dirs="$mounted_dirs $real_dir"
extra_mounts+=(-v "$real_dir:$real_dir:ro")
done
@@ -877,9 +1076,15 @@ if [ -n "$SANDBOX_EXTRA_MOUNTS" ]; then
for path in "${extra_paths[@]}"; do
[ -n "$path" ] || continue
if [ ! -e "$path" ]; then
echo "[mounts] readonly entry does not exist: $path" >&2
echo "$(_mount_label "$path" "[mounts] readonly entry" "--extra-readonly path") does not exist: $path" >&2
exit 1
fi
# [mounts] read_write has been guarded since it was added; this one
# never was, so `readonly = ~/.config/dejima` mounted the approval
# store into the container and nothing said a word (#15).
_guarded_stores
_refuse_guarded_mount "$(_mount_label "$path" "[mounts] readonly entry" "--extra-readonly path")" \
"$path" "${GUARDED_STORES[@]}"
extra_mounts+=(-v "$path:$path:ro")
done
fi
@@ -921,36 +1126,30 @@ if [ -d "$WORKSPACE_DIR/.git/hooks" ]; then
extra_mounts+=(-v "$WORKSPACE_DIR/.git/hooks:$WORKSPACE_DIR/.git/hooks:ro")
fi
# ~/.claude/statusline.sh is the same class of file as the config staged at
# /etc/dejima-claude by docker-compose.yml: settings.json's statusLine.command
# points at it, and Claude Code executes it on the host. Staged the same way,
# for entrypoint.sh to copy into place, rather than mounted at its real path.
# The state this harness keeps on the host, and which of it this workspace
# is entitled to see. Harness-specific by nature - which directories exist,
# and what decides that one of their entries belongs to this workspace, is a
# fact about the harness rather than about dejima - so it lives in the
# harness plugin, which declares each mount through emit_mount. The guarded-
# store refusal runs there, at the point of declaration, rather than being
# something each plugin has to remember. See bin/dejima-harness-lib.sh.
harness_hook harness_state_mounts "$WORKSPACE_DIR"
# statusline.sh is executed on the host by Claude Code (settings.json's
# statusLine.command), so it is staged for entrypoint.sh to copy rather than
# mounted at its real path - same as the config in docker-compose.yml.
#
# It used to be mounted read-only at $HOME/.claude/statusline.sh, and that
# protected only the plain-regular-file case - confirmed against a live
# container, not just reasoned about. When statusline.sh was itself a symlink
# (the common case - typically into a dotfiles checkout), the read-write
# ~/.claude mount was established first, so the destination already had that
# symlink sitting on it, and bind-mount destination resolution (runc's
# SecureJoin) followed it: the :ro mount landed on the target directory
# instead of the symlink dentry, which stayed writable and replaceable. There
# was no mount-based fix while ~/.claude was mounted whole, and the
# compensating control was detection after the fact (bin/dejima-audit hashes
# the symlink's target string). Staging closes it outright: Docker resolves
# the symlink when it sets up the staging mount, so entrypoint.sh copies the
# real content to a path nothing on the host can reach.
# A :ro mount there protected only the plain-file case, confirmed against a
# live container. When statusline.sh is itself a symlink (the common case),
# bind-mount destination resolution follows it, so the mount lands on the
# target and the link stays replaceable. Staging closes that: Docker resolves
# the symlink setting up the staging mount, and the copy lands where the host
# cannot reach it.
#
# Conditional on existence because not every host has one, and mounting a
# nonexistent source would make Docker create a root-owned directory at the
# target instead - worse than the gap this closes.
#
# A *dangling* symlink (target doesn't exist, e.g. a dotfiles checkout that
# isn't present) is neither "exists" nor safely mountable, so it gets its own
# branch that refuses to launch rather than either silently skipping the
# mount (reopens the gap plain -e had) or attempting it anyway (Docker can't
# bind-mount a dangling symlink - it fails deep in mount setup with an opaque
# "mkdir ...: file exists", since the symlink already occupies that path,
# with nothing connecting that error back to the statusline for the user).
# Conditional on existence - mounting a missing source makes Docker create a
# root-owned directory at the target. A dangling symlink is neither
# mountable nor "exists", so it refuses rather than skipping the mount
# (reopening the gap) or failing later with an opaque "mkdir: file exists".
if [ -L "$HOME/.claude/statusline.sh" ] && [ ! -e "$HOME/.claude/statusline.sh" ]; then
echo "$HOME/.claude/statusline.sh is a symlink to a target that doesn't" >&2
echo "exist. Docker can't bind-mount a dangling symlink, and skipping the" >&2
@@ -961,16 +1160,12 @@ elif [ -e "$HOME/.claude/statusline.sh" ]; then
extra_mounts+=(-v "$HOME/.claude/statusline.sh:/etc/dejima-claude/statusline.sh:ro")
fi
# Keep the real Anthropic OAuth credential out of the container entirely:
# run a host-side proxy that injects it per-request (see
# bin/dejima-anthropic-proxy and docs/security.md). Nothing needs masking
# any more - ~/.claude isn't mounted as a whole, so the real credential (and
# the proxy's .credentials-proxy-tmp staging directory beside it) simply
# never crosses into the container. That masking is what #50 was: both masks
# were single-file/-directory mounts nested in the read-write ~/.claude
# mount, and the proxy's own write-back renamed the credential out from
# under them, detaching the mask and exposing the real token from the first
# refresh on.
# The real Anthropic OAuth credential never enters the container: a host-side
# proxy injects it per request (bin/dejima-anthropic-proxy, docs/security.md).
# Nothing needs masking now that ~/.claude isn't mounted whole - and masking
# is what #50 was, a single-file mount nested in the read-write ~/.claude
# that the proxy's own write-back renamed out from under, exposing the real
# token on the first refresh.
#
# How the container then reaches the proxy depends on the transport. In the
# default tls one, entrypoint.sh points api.anthropic.com at an in-container
@@ -1095,6 +1290,25 @@ if remote_enabled; then
DEJIMA_NOTIFY_DIR="$DEJIMA_STATE_DIR/notify/$DEJIMA_RUN_ID"
mkdir -p -m 700 "$DEJIMA_NOTIFY_DIR"
# Verify what was just created, because everything downstream assumes it.
# A leaf left behind root-owned by an earlier container restart (see
# entrypoint.sh) is the shape this catches: mkdir -p succeeds against an
# existing directory regardless of who owns it, so without this the mount
# goes ahead and the failure surfaces later as EACCES in a hook.
#
# A warning rather than a refusal. Remote access is opt-in, and the rest
# of the session works without it - blocking a launch over undeliverable
# notifications would cost more than it saves. The neighbouring state-dir
# check does refuse, but that one is about a symlink handing a session an
# attacker-chosen path; this one is only about the feature working.
if [ ! -O "$DEJIMA_NOTIFY_DIR" ] || [ ! -w "$DEJIMA_NOTIFY_DIR" ]; then
echo "warning: $DEJIMA_NOTIFY_DIR is not a directory this user owns and can write." >&2
echo "Remote access is enabled, so notifications and dejima-attach will not work for" >&2
echo "this session. A leftover root-owned directory here is usually Docker's doing," >&2
echo "from a container that was restarted rather than relaunched; removing it and" >&2
echo "starting again clears it." >&2
fi
# $TMUX_PANE is set by tmux in the launching shell. Empty when the
# sandbox wasn't started from tmux: the session is still registered, just
# listed as not-attachable, rather than silently omitted.
@@ -1161,26 +1375,42 @@ if remote_enabled; then
# own ID.
extra_mounts+=(-e "DEJIMA_RUN_ID=$DEJIMA_RUN_ID")
# The notify hook is registered per session, into the override layer from
# above and nowhere else: registering it in the operator's real
# settings.json would run it for every sandbox and for the host's own
# Claude Code, which share that file, when it is only meaningful for a
# session that has a marker directory. $SANDBOX_SETTINGS_FILE is
# already a throwaway mktemp file, so this rewrite cannot reach the
# operator's real settings.json; it is mounted read-only, so the
# session cannot unregister itself either. Claude Code merges hooks
# across settings layers rather than letting the higher one replace the
# lower, so these are added to whatever the operator already has rather
# than displacing it. $HOME is left unexpanded for Claude Code to resolve
# at hook-run time - inside the container, not here - matching the
# convention install.sh already uses.
SANDBOX_REMOTE_SETTINGS_TMP="$(mktemp "${TMPDIR:-/tmp}/dejima-settings-remote.XXXXXX.json")"
jq '.hooks.Notification = ((.hooks.Notification // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/dejima-notify.sh waiting"}]}])
| .hooks.Stop = ((.hooks.Stop // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/dejima-notify.sh done"}]}])' \
"$SANDBOX_SETTINGS_FILE" > "$SANDBOX_REMOTE_SETTINGS_TMP"
mv "$SANDBOX_REMOTE_SETTINGS_TMP" "$SANDBOX_SETTINGS_FILE"
# Registered per session, into the override layer from above and nowhere
# else: putting it in the operator's real settings.json would run it for
# every sandbox and for the host's own agent, which share that file, when
# it is only meaningful for a session that has a marker directory.
# $SANDBOX_SETTINGS_FILE is already a throwaway mktemp file, so this
# rewrite cannot reach the operator's real settings; it is mounted
# read-only, so the session cannot unregister itself either.
#
# How a harness is told to run something on those two events is the
# harness's own business - Claude Code merges hook entries across
# settings layers, another harness may take a config key or an
# environment variable - so the writing is the plugin's. Whether it
# happens at all is core's, and is gated on the harness having declared
# the notify guard.
if harness_has_guard notify; then
harness_hook harness_register_notify "$SANDBOX_SETTINGS_FILE"
fi
fi
# Everything from here to the container's own first output is silent: the
# audit snapshot, an image build that prints nothing when every layer is
# cached, and Docker creating the container with a mount per allowed path.
# Long enough to read as hung, so say what is happening first. A first run,
# or one after a Dockerfile change, builds the image and takes minutes rather
# than seconds - the line names that, since it is the case where someone is
# most likely to give up and interrupt.
printf '%sdejima%s is starting, please wait...\n' "$C_KEY" "$C_RESET" >&2
printf '%sA first run builds the container image, which takes several minutes.%s\n' \
"$C_DIM" "$C_RESET" >&2
# Named here, once, at the moment the operator is choosing to proceed. Silent
# when the harness hosts every guard, so this is not a line anyone learns to
# scroll past - it appears only when a protection they have elsewhere is
# genuinely not in force here.
harness_report_guards "$DEJIMA_HARNESS_NAME"
# Snapshot host-persistence surfaces so the post-run audit can report
# anything that changed while this session ran (advisory - see bin/dejima-audit).
"$SCRIPT_DIR/dejima-audit" snapshot "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
@@ -1234,6 +1464,22 @@ if [ -n "$PROJECT_STORE" ] && [ -s "$PROJECT_STORE/dependencies" ]; then
fi
export SANDBOX_IMAGE
# nvim's data, state and cache are Docker volumes rather than host paths, so
# nothing a session installs reaches the host's own nvim. They did outlive
# the container under one fixed name each, though, shared by every project
# on the machine: a session that drops a file under `lazy/`, or replaces a
# binary under `mason/bin/`, is writing something a later session in an
# unrelated project executes the next time nvim runs in the sandbox. That is
# the persistence this repository closes everywhere else, so it is scoped
# the same way the derived image tag is - per project.
#
# The prefix is not decoration: project_slug maps a leading `/` to `-`, and
# a Docker volume name may not start with one.
_nvim_volume_slug="$(project_slug "$WORKSPACE_DIR")"
export SANDBOX_NVIM_DATA="dejima_nvim-data${_nvim_volume_slug}"
export SANDBOX_NVIM_STATE="dejima_nvim-state${_nvim_volume_slug}"
export SANDBOX_NVIM_CACHE="dejima_nvim-cache${_nvim_volume_slug}"
# Not exec'd (the EXIT trap cleans up the generated temp files) and not a
# bare command under set -e (a nonzero/interrupted container run - e.g.
# Ctrl-C returning 130 - would otherwise abort the script here, skipping the
@@ -1245,7 +1491,7 @@ export SANDBOX_IMAGE
# because exit-time output can be ignored exactly once, permanently. This is
# host-side and stays host-side: it must never be routed into the session's
# context, which would make it a cross-session persistence channel.
audit_slug="$(printf '%s' "$WORKSPACE_DIR" | sed 's/[^a-zA-Z0-9]/-/g')"
audit_slug="$(ws_slug "$WORKSPACE_DIR")"
audit_reports="$AUDIT_DIR/$audit_slug"
# `|| true`: a workspace with no history yet (the common case - nothing has
# ever run against it, or every past finding is already acknowledged) has no
@@ -1254,16 +1500,43 @@ audit_reports="$AUDIT_DIR/$audit_slug"
# every ordinary launch.
pending_crit="$(find "$audit_reports" -maxdepth 1 -name '*-CRITICAL.txt' 2>/dev/null | sort)" || true
if [ -n "$pending_crit" ]; then
n="$(printf '%s\n' "$pending_crit" | wc -l)"
echo "" >&2
echo " dejima: $n unacknowledged CRITICAL finding(s) in this workspace" >&2
printf '%s\n' "$pending_crit" | sed 's#.*/##; s#-CRITICAL\.txt$##; s#^# from run #' >&2
echo " Review with: dejima-audit ack \"$WORKSPACE_DIR\"" >&2
if [ -t 0 ]; then
printf ' [c] continue [q] quit: ' >&2
read -r reply || reply=q
case "$reply" in q|Q) echo "aborted." >&2; exit 1 ;; esac
elif [ "$(config_get_bool audit strict)" = "1" ]; then
# Re-read after an inline `ack`, so approving everything here clears the
# warning instead of asking again about reports that no longer carry a
# CRITICAL. Looping rather than asking once: `ack` walks ONE report per
# run (see select_report), so a workspace with several needs several
# passes, and sending the operator back to their shell between each is
# the friction this option exists to remove (#65).
while [ -n "$pending_crit" ]; do
n="$(printf '%s\n' "$pending_crit" | wc -l)"
echo "" >&2
printf ' dejima: %s%s unacknowledged %sCRITICAL%s finding(s) in this workspace\n' \
"$C_CRIT" "$n" "$C_CRIT" "$C_RESET" >&2
printf '%s\n' "$pending_crit" \
| sed "s#.*/##; s#-CRITICAL\.txt\$##; s#^# from run ${C_INFO}#; s#\$#${C_RESET}#" >&2
printf ' Review with: %sdejima-audit ack "%s"%s\n' \
"$C_KEY" "$WORKSPACE_DIR" "$C_RESET" >&2
if [ -t 0 ]; then
printf ' %s[a]%s review now %s[c]%s continue %s[q]%s quit: ' \
"$C_KEY" "$C_RESET" "$C_KEY" "$C_RESET" "$C_KEY" "$C_RESET" >&2
read -r reply || reply=q
case "$reply" in
q|Q) echo "aborted." >&2; exit 1 ;;
a|A)
# Run it here rather than telling the operator to run it
# in another terminal: this is the moment they are
# looking at the finding, and the container has not
# started, so nothing is waiting on the answer.
"$SCRIPT_DIR/dejima-audit" ack "$WORKSPACE_DIR" || true
pending_crit="$(find "$audit_reports" -maxdepth 1 -name '*-CRITICAL.txt' 2>/dev/null | sort)" || true
continue
;;
esac
fi
break
done
fi
if [ -n "$pending_crit" ] && [ ! -t 0 ]; then
if [ "$(config_get_bool audit strict)" = "1" ]; then
echo " audit.strict is on and stdin is not a tty - refusing to start." >&2
exit 1
else
+12 -2
View File
@@ -20,6 +20,12 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=dejima-project-lib.sh
source "$SCRIPT_DIR/dejima-project-lib.sh"
# For ipset_for_domain: the live add below writes into a running container's
# ipsets, and which set a name belongs in is the container's rule, not this
# script's. Sourced rather than restated so the two cannot drift - the same
# reason init-firewall.sh and dns-proxy.sh share this file.
# shellcheck source=../docker/fixed-domains.sh
source "$SCRIPT_DIR/../docker/fixed-domains.sh"
usage() {
echo "Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>" >&2
@@ -133,9 +139,13 @@ if [ "$session_wanted" -eq 1 ]; then
exit 1
fi
# allowed-domains carries no port restriction, so a GitHub address added
# there is reachable on 22 as well - which is the read-only guarantee gone,
# from a command whose whole output says it granted one domain.
target_set=$(ipset_for_domain "$domain")
for ip in $ips; do
echo "Allowing $ip ($domain)"
docker exec -u root "$container" ipset add allowed-domains "$ip" -exist
echo "Allowing $ip ($domain -> $target_set)"
docker exec -u root "$container" ipset add "$target_set" "$ip" -exist
done
echo "$domain allowed for the running session ($container)."
+30 -47
View File
@@ -24,23 +24,17 @@ fi
STATE_DIR="$(remote_state_dir)"
SESSIONS_DIR="$STATE_DIR/sessions"
# gruvbox dark. Every value below is upstream's, tagged with the name of the
# color.table row it came from:
# gruvbox dark, every value upstream's and tagged with its color.table row:
# https://github.com/gruvbox-community/gruvbox-contrib/blob/master/color.table
# Carrying the name and not just the hex is the point - a palette this widely
# known is one almost nobody has memorised to six digits, so a wrong value is
# only catchable by reading if there is something to check it against.
# The names are carried so a wrong hex is catchable by reading.
#
# Truecolor only, and deliberately not paired with a 256-colour table: a
# terminal without it - or tmux without `Tc` - approximates 38;2 to its own
# nearest colour rather than printing the escape, so the fallback already
# exists one layer down and a second palette here would only duplicate it.
# Truecolor only: a terminal without it, or tmux without `Tc`, approximates
# 38;2 to its nearest colour rather than printing the escape, so the fallback
# already exists one layer down.
#
# Everything below is empty unless stdout is a terminal that says it can show
# colour, which is what keeps --list clean: it is a machine interface (ttyd's
# --url-arg feeds run ids back in, and the README documents the rows as
# scriptable), and it is also how the bats suite reads this script, so its
# assertions see the same bare words either way.
# Empty unless stdout is a colour-capable terminal, which keeps --list a
# machine interface (ttyd feeds run ids back in) and is also how the bats
# suite reads this script.
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-dumb}" != dumb ]; then
C_OFF=$'\033[0m'; C_BOLD=$'\033[1m'
C_BADGE=$'\033[38;2;254;128;25m' # bright_orange #fe8019, the wordmark
@@ -95,22 +89,16 @@ state_color() {
# How long after its own started= a registered session may still have no
# container, before the prune in list_sessions treats that as "gone".
#
# Sized against the step that actually holds a launch up: bin/dejima writes
# the registry entry, then rewrites the settings copy, snapshots the
# workspace for the audit, runs an UNCONDITIONAL `docker compose build
# claude`, optionally builds up to two derived images on top of it, and only
# then reaches `docker compose run`. A cold base build is the Dockerfile's
# apt and npm layers fetched over the network - minutes, not seconds - and
# the derived builds add to it, while a warm run reaches the container in
# seconds and never comes near this window.
# Sized against what holds a launch up: the registry entry is written before
# an unconditional `docker compose build`, plus up to two derived image
# builds, and a cold base build fetches apt and npm layers - minutes. A warm
# run never comes near this window.
#
# The two errors are not symmetric, which is why this is generous. Too long
# only leaves a session that really did die listed until the window passes,
# and the next listing prunes it. Too short deletes the registry entry of a
# session that is about to start: bin/dejima never rewrites that entry, so
# the session is permanently absent from both front doors, and the marker
# directory deleted with it is the source of a bind mount the imminent
# `docker compose run` declares.
# Generous because the errors are not symmetric. Too long leaves a dead
# session listed until the window passes. Too short deletes the entry of a
# session about to start: bin/dejima never rewrites it, so the session is
# permanently absent from both front doors, and the marker directory removed
# with it is a bind source the imminent `compose run` declares.
STARTUP_GRACE=900
# Parsed, never sourced: this file is host-written, but treating state files
@@ -281,26 +269,21 @@ attach() {
exit 1
fi
group="dejima-$run_id"
# Both values come from the same live pane, in one round trip. Taking the
# session name from the recorded label instead would follow a name rather
# than the sandbox: an operator renaming sessions is enough to point it at
# an unrelated session that has since taken the old name.
# Both values from the same live pane, one round trip. Taking the session
# name from the recorded label would follow a name rather than the
# sandbox, and a rename is enough to point it at an unrelated session.
#
# The name is a starting point, never a guarantee. It is frequently
# ANOTHER remote client's throwaway group (tmux reports whichever session
# holding the window is most recent, and every grouped session here is
# newer than the operator's own), and those carry destroy-unattached - so
# the base can be made to stop existing on demand by anyone holding a
# second connection. That is why the session created from it is verified
# below instead of trusted: `new-session -t` does not fail on a name that
# matches nothing, it silently creates a fresh UNGROUPED session, which
# for a remote client is a plain host shell with no sandbox in it.
# The name is a starting point, not a guarantee: it is often another
# remote client's throwaway group, and those carry destroy-unattached, so
# anyone with a second connection can make the base stop existing. Hence
# the verification below - `new-session -t` does not fail on a name
# matching nothing, it silently creates an UNGROUPED session, which for a
# remote client is a plain host shell with no sandbox in it.
#
# display-message prints nothing and still exits 0 for a target that no
# longer exists, so an empty result means the pane went away between the
# check above and here. That case is caught by the verification below too;
# bailing early just avoids creating a junk session to immediately kill,
# and says what actually happened.
# display-message prints nothing and exits 0 for a vanished target, so an
# empty result means the pane went away since the check above. The
# verification catches that too; bailing early just avoids creating a
# junk session and says what happened.
target="$(tmux display-message -p -t "$entry_pane" '#{session_name}:#{window_index}' 2>/dev/null || true)"
# Session names may contain ':', window indexes can't.
base="${target%:*}"
+580 -212
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
#!/bin/bash
# Terminal colour, shared by bin/dejima and bin/dejima-audit.
#
# Shared rather than duplicated because the two are one surface to the
# operator: the launcher's "unacknowledged CRITICAL" warning and the audit
# review it sends them to must agree about what red means, or the colour is
# decoration instead of information.
#
# Nothing here decides WHAT to highlight - that belongs with the text. It
# decides only whether colour is safe to emit at all, and provides the names.
#
# Off unless the stream being written to is a terminal, so a redirected
# report or a piped test never receives escape sequences: the audit's output
# is read back by tests and, in the reports it writes, by future runs.
# NO_COLOR (any value, https://no-color.org) and TERM=dumb both turn it off
# as well.
#
# The variables are always DEFINED, empty when colour is off, so callers
# interpolate them unconditionally and no call site needs an if.
color_init() {
local fd="${1:-1}"
C_RESET=""; C_CRIT=""; C_WARN=""; C_INFO=""; C_PATH=""
C_KEY=""; C_DIM=""; C_ADD=""; C_DEL=""; C_OK=""
[ -t "$fd" ] || return 0
[ -z "${NO_COLOR+x}" ] || return 0
case "${TERM:-}" in ''|dumb) return 0 ;; esac
C_RESET=$'\033[0m'
C_CRIT=$'\033[1;31m' # bold red - runs unsandboxed host code
C_WARN=$'\033[33m' # yellow
C_INFO=$'\033[36m' # cyan
C_PATH=$'\033[1;36m' # bold cyan - the file or key a finding is about
C_KEY=$'\033[1m' # bold - the keystroke an operator has to press
C_DIM=$'\033[2m' # dim - the audit's own framing around content
C_ADD=$'\033[32m' # green - a diff's added lines
C_DEL=$'\033[31m' # red - a diff's removed lines
C_OK=$'\033[32m' # green
}
# The colour for a tier name, so the mapping lives in one place rather than
# at each site that prints one.
tier_color() {
case "$1" in
CRITICAL) printf '%s' "$C_CRIT" ;;
WARNING) printf '%s' "$C_WARN" ;;
*) printf '%s' "$C_INFO" ;;
esac
}
+17 -20
View File
@@ -22,33 +22,27 @@
# Schema
#
# Three TAB-separated columns: key, type, default. One source of truth - both
# _config_spec and _config_keys read this, so a key cannot exist for lookup
# but be missing from enumeration (which is what --print-config walks).
# _config_spec and _config_keys read it, so a key cannot be lookup-able but
# missing from --print-config's enumeration.
#
# A "?" prefix on the type means the empty string is a legal value. Without
# it, empty is rejected: the distinction is between a setting that is
# meaningfully absent (ssh_key, loki_url) and one that must always have a
# A "?" type prefix means empty is legal, distinguishing a setting that is
# meaningfully absent (ssh_key, loki_url) from one that must always have a
# value (firewall, share_dir).
#
# The three axis keys are `provider:<axis>` - validated by looking the name
# up under the provider root (see _config_extra_type_valid below and
# dejima-provider-lib.sh) rather than a fixed enum, so adding a provider is
# adding a directory, not editing this schema.
#
# [forge.<name>] and [harness.<name>] keys are absent from this table for the
# same reason: enumerating a forge's own keys here would mean a new forge
# needs a core edit, which is exactly what the provider axes are for.
# Those two sections are resolved instead through the _config_extra_*
# functions below, stubbed here and overridden by dejima-provider-lib.sh once
# it is sourced, against the type+default each provider's own `keys` manifest
# entry declares (see _provider_keyspec_parse there).
# The axis keys are `provider:<axis>`, validated against the provider root
# rather than a fixed enum, so adding a provider is adding a directory.
# [forge.<name>] and [harness.<name>] keys are absent here for the same
# reason - enumerating them would make a new forge need a core edit. They
# resolve through the _config_extra_* functions below, stubbed here and
# overridden by dejima-provider-lib.sh, against each provider's own `keys`
# manifest entry.
# ---------------------------------------------------------------------------
_config_table() {
cat <<'EOF'
core.harness provider:harness claude-code
core.harness provider:harness
core.forge provider:forge none
core.workflow provider:workflow local-only
core.command identifier claude
core.command ?identifier
core.firewall bool on
core.share_dir path ~/.local/share/dejima/shared
core.ssh_key ?path
@@ -61,6 +55,9 @@ credentials.proxy bool on
credentials.transport enum:tls|loopback tls
logging.loki_url ?url
logging.conversation bool off
epitaph.enabled bool on
epitaph.dir path ~/.local/share/dejima/epitaphs
epitaph.loki bool off
audit.dir path ~/.local/share/dejima/audit
audit.baselines_dir path ~/.local/share/dejima/baselines
audit.keep int:1:1000 10
@@ -313,7 +310,7 @@ _config_extra_section_known() { return 1; }
_config_section_known() {
case "$1" in
core|github|mounts|credentials|logging|audit|remote-access) return 0 ;;
core|github|mounts|credentials|logging|audit|remote-access|epitaph) return 0 ;;
*) _config_extra_section_known "$1" ;;
esac
}
+171
View File
@@ -0,0 +1,171 @@
#!/bin/bash
# Epitaph: what a session leaves behind when its context dies.
#
# Claude Code writes a summary each time a session is compacted. It is the
# only durable account of what a long session worked out, and it goes with
# the container. This keeps it.
#
# Runs entirely on the host, from bin/dejima's exit trap. Nothing in the
# container writes here and the store is never mounted, so a session cannot
# read what earlier sessions concluded or edit what this one is about to
# record - the same arrangement as the audit store, and for the same reason
# (#104: anything a session writes and a later one reads is a channel into
# that later session's premises).
#
# The transcript IS session-writable, so a summary is only as honest as the
# file it came from. This preserves what was recorded, it does not attest to
# it.
#
# Usage: dejima-epitaph save <workspace> [transcripts-dir]
set -uo pipefail
# 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"
config_validate || exit 1
cmd="${1:-}"; ws_arg="${2:-}"; transcripts="${3:-}"
[ "$(config_get epitaph enabled)" = "on" ] || exit 0
STORE="$(config_get epitaph dir)"
[ -n "$transcripts" ] || transcripts="$(harness_config_get projects_dir)"
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> [transcripts-dir]" >&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
# of which an earlier run already wrote.
mkdir -p "$STORE" 2>/dev/null || exit 0
chmod 700 "$STORE" 2>/dev/null || true
saved=0
while IFS= read -r slug; do
[ -n "$slug" ] || continue
dir="$transcripts/$slug"
[ -d "$dir" ] || continue
out="$STORE/$slug"
for f in "$dir"/*.jsonl; do
[ -f "$f" ] || continue
# python3 is already a hard dependency (install.sh refuses without
# it), and this is line-delimited JSON with embedded newlines in the
# payload - not something to take apart with sed.
n="$(python3 - "$f" "$out" <<'PY'
import json, os, sys
path, out = sys.argv[1], sys.argv[2]
pending = None # metadata from the compact_boundary that precedes a summary
written = 0
for line in open(path, errors="replace"):
line = line.strip()
if not line or not line.startswith("{"):
continue
try:
rec = json.loads(line)
except ValueError:
continue
if rec.get("subtype") == "compact_boundary":
pending = rec.get("compactMetadata") or {}
continue
if not rec.get("isCompactSummary"):
continue
body = rec.get("message", {}).get("content")
if isinstance(body, list):
body = "\n".join(p.get("text", "") for p in body if isinstance(p, dict))
if not isinstance(body, str) or not body.strip():
pending = None
continue
uid = rec.get("uuid") or ""
stamp = (rec.get("timestamp") or "").replace(":", "").replace("-", "")[:15]
name = "%s-%s.md" % (stamp or "unknown", uid[:8] or "nouuid")
dest = os.path.join(out, name)
if os.path.exists(dest):
pending = None
continue
meta = pending or {}
head = [
"# Epitaph",
"",
"- session: %s" % (rec.get("sessionId") or "unknown"),
"- at: %s" % (rec.get("timestamp") or "unknown"),
"- trigger: %s" % (meta.get("trigger") or "unknown"),
]
if meta.get("preTokens") is not None:
head.append("- tokens: %s before, %s after"
% (meta.get("preTokens"), meta.get("postTokens")))
head += [
"",
"Written by the session and preserved verbatim. The transcript it came",
"from is writable from inside the container, so this records what was",
"there rather than vouching for it.",
"", "---", "",
]
tmp = dest + ".part"
os.makedirs(out, exist_ok=True)
with open(tmp, "w") as fh:
fh.write("\n".join(head) + body.rstrip() + "\n")
os.chmod(tmp, 0o600)
os.rename(tmp, dest)
written += 1
pending = None
print(written)
PY
)" || n=0
saved=$((saved + ${n:-0}))
done
# The directory is made by the first write into it, not by looking at the
# slug: most worktree slugs have transcripts and no compaction summary in
# any of them, so creating one per slug up front filled the store with
# empty directories and made it look like it held something for each. The
# rmdir clears the ones earlier runs already left; it cannot touch a slug
# that kept anything.
rmdir "$out" 2>/dev/null || true
done < <(workspace_slugs_sorted "$ws")
[ "$saved" -gt 0 ] || exit 0
echo "dejima: wrote $saved epitaph(s) to $STORE" >&2
# Optional, and off by default: the store is the durable copy, this is for
# 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.
[ "$(config_get epitaph loki)" = "on" ] || exit 0
loki="$(config_get logging loki_url)"
[ -n "$loki" ] || exit 0
while IFS= read -r slug; do
[ -n "$slug" ] || continue
for f in "$STORE/$slug"/*.md; do
[ -f "$f" ] && [ ! -f "$f.shipped" ] || continue
if python3 - "$loki" "$slug" "$f" <<'PY'
import json, sys, time, urllib.request
loki, slug, path = sys.argv[1], sys.argv[2], sys.argv[3]
body = json.dumps({"streams": [{
"stream": {"job": "dejima-epitaph", "project": slug},
"values": [[str(time.time_ns()), open(path, errors="replace").read()]],
}]}).encode()
req = urllib.request.Request(loki.rstrip("/") + "/loki/api/v1/push", data=body,
headers={"Content-Type": "application/json"})
urllib.request.urlopen(req, timeout=10).read()
PY
then
: > "$f.shipped"
fi
done
done < <(workspace_slugs_sorted "$ws")
exit 0
+241
View File
@@ -0,0 +1,241 @@
# Shared library: loading a harness plugin, and the interface it declares
# through. Sourced by bin/dejima. Not on PATH and not executable.
#
# Three tiers, and which one a thing belongs in is the whole design:
#
# core - the mechanism, and every enforcement point.
# manifest - data. harnesses/<name>/harness.conf. Never sourced, never
# executed. Selects among named core mechanisms, supplies paths.
# plugin - bin/harness/<name>.sh. The harness-specific remainder, as
# code, in this repository, supported one harness at a time.
#
# A feature belongs in the manifest if it can be stated as a value. It moves
# here only when it cannot: opencode resolving its state directory through a
# database, Claude Code pairing file-history to transcripts by session id.
# "Add a manifest key" is the answer until it demonstrably is not.
#
# Plugins live under bin/ rather than beside their manifest because
# dejima-audit's tcb_snapshot covers install.sh, bin/, hooks/, docker/ and
# test/ - and not harnesses/. Host-side plugin code under harnesses/ would be
# code that runs on the host and that a session could edit in dejima's own
# checkout without raising a CRITICAL, which is the exact class the audit
# exists to catch. Under bin/ it is covered for free, and "manifests are
# data, never executed" (docs/security.md) stays true word for word.
#
# Plugins are SOURCED, not invoked. They are part of the trusted computing
# base, so the risk worth designing against is not a careless plugin - it is
# three plugins implementing the security model three different ways.
# Sourcing lets a plugin use core's own primitives (_refuse_guarded_mount,
# sanitize, ws_slug, harness_config_get) rather than re-deriving them, which
# is what keeps one security model instead of one per harness. An invoked
# protocol would also put a new text parser on the launch path, and every
# parsing bug this repository has had is in that family.
#
# The rule that replaces isolation: A PLUGIN COMPUTES, CORE ENFORCES.
# A plugin declares through the emit_* functions below, which validate at the
# point of declaration. It never touches extra_mounts itself - a static test
# holds that, because a plugin that appended directly would skip the guarded-
# store refusal, and would skip it differently in each harness.
# 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_state_mounts harness_register_notify harness_register_guards harness_host_exec_surfaces"
# 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
# manifest schema and the validation that rejects a name outside it: a
# constant the validation depends on must live where the validation does, or
# a caller that loads only provider-lib validates against an empty set and
# rejects every guard there is.
_guard_description() {
case "$1" in
bash-deny) printf 'obviously destructive shell commands are not refused' ;;
push-guard) printf 'pushes to hosts and branches outside the allowed set are not refused' ;;
merge-guard) printf 'the agent can merge its own pull requests' ;;
notify) printf 'remote access gets no waiting/done notifications' ;;
*) printf 'unknown guard' ;;
esac
}
# Whether the selected harness declared a given guard. Core gates the work
# on this rather than on whether the plugin happens to define a hook: a
# harness that forgot the declaration but implemented the hook would get a
# guard the launch message has just told the operator it does not have.
harness_has_guard() {
case " $(manifest_get harness "$DEJIMA_HARNESS_NAME" guards) " in
*" $1 "*) return 0 ;;
*) return 1 ;;
esac
}
# Says which guards the selected harness cannot host. Silent when it hosts
# them all, which is the common case and must not become noise an operator
# learns to scroll past - the point is that the line only appears when
# something really is missing.
harness_report_guards() {
local harness="$1" declared guard missing=()
declared="$(manifest_get harness "$harness" guards)"
for guard in $DEJIMA_KNOWN_GUARDS; do
case " $declared " in
*" $guard "*) ;;
*) missing+=("$guard") ;;
esac
done
[ "${#missing[@]}" -gt 0 ] || return 0
printf ' %sharness %s does not support %s of dejima'"'"'s guards:%s\n' \
"$C_WARN" "$harness" "${#missing[@]}" "$C_RESET" >&2
for guard in "${missing[@]}"; do
printf ' %s%s%s - %s\n' "$C_KEY" "$guard" "$C_RESET" "$(_guard_description "$guard")" >&2
done
printf ' The container still bounds this session; these are the fast-fail\n' >&2
printf ' guards it does not get. See docs/development.md.\n' >&2
}
# Sources the selected harness's plugin. Hard error when it is missing:
# support is per harness and deliberate (see "Adding a new harness" in
# docs/development.md), so a harness with a manifest and no plugin is a
# half-finished integration rather than a lightweight one.
harness_plugin_load() {
local name="$1" plugin
[ -n "$name" ] || return 0
# Resolved from this file's own location, not from a caller's SCRIPT_DIR:
# install.sh sets that to the repo root while bin/dejima sets it to bin/,
# and a library that only works for one of its callers is a trap for the
# next one.
plugin="$(cd "${BASH_SOURCE[0]%/*}" && pwd)/harness/$name.sh"
if [ ! -f "$plugin" ]; then
echo "No harness plugin for '$name' at $plugin." >&2
echo "A supported harness has both a manifest and a plugin; see" >&2
echo "\"Adding a new harness\" in docs/development.md." >&2
exit 1
fi
# shellcheck disable=SC1090
source "$plugin"
}
# Calls one hook of the loaded plugin, if it defines it. Absence is normal:
# not every harness needs every hook, and the manifest is where a harness
# says what it cannot do at all.
harness_hook() {
local hook="$1"; shift
declare -F "$hook" >/dev/null 2>&1 || return 0
"$hook" "$@"
}
# --- what a plugin declares through --------------------------------------
#
# Each of these validates and then appends to core's own state. A plugin
# calls them; it does not reach past them.
# A host path mounted into the container at the same path. $2 is the mode
# ("ro", or empty for read-write).
#
# The guarded-store refusal runs here, at the point of declaration, so it
# cannot be skipped by a plugin that forgot it: a harness state directory
# that overlapped the approval or audit or epitaph store would otherwise
# hand a session exactly what those stores exist to withhold, and a harness
# is precisely the kind of thing whose paths are computed rather than
# written down.
emit_mount() {
local path="$1" mode="${2:-}"
[ -n "$path" ] || return 0
case "$path" in
*:*)
echo "Harness plugin emitted a mount path containing a colon: $path" >&2
exit 1 ;;
esac
_guarded_stores
_refuse_guarded_mount "harness state mount" "$path" \
${GUARDED_STORES[@]+"${GUARDED_STORES[@]}"}
if [ -n "$mode" ]; then
extra_mounts+=(-v "$path:$path:$mode")
else
extra_mounts+=(-v "$path:$path")
fi
}
# An environment variable for the container. Name-checked because it becomes
# a `-e NAME=value` argument, and a name carrying a space or an `=` would
# split into something else entirely.
emit_env() {
local name="$1" value="${2:-}"
case "$name" in
''|*[!A-Za-z0-9_]*)
echo "Harness plugin emitted an invalid environment variable name: $name" >&2
exit 1 ;;
esac
extra_mounts+=(-e "$name=$value")
}
# --- host-execution surfaces ---------------------------------------------
#
# Paths a harness causes to run ON THE HOST, unsandboxed, from configuration
# a session can reach. Claude Code's statusline is the instance that exists:
# settings.json's statusLine.command names a script the host runs, so a
# session that can write that file has a way onto the host that no container
# boundary sees.
#
# Declared by the plugin rather than by the manifest, and the reason is a
# trust boundary rather than convenience. dejima-audit's `ack` derives a
# finding's id by stripping a FIXED label from the front of the finding text
# - anchored deliberately, because an unanchored match lets chosen text
# derive an id in the wrong namespace and baseline an approval that never
# matches while the real finding is counted as acknowledged (#45). Those
# labels are therefore part of a parser's grammar. Manifests are data, and
# provider directories are NOT in tcb_snapshot's list, so in the self-mount
# case a session can edit one without raising a CRITICAL. A plugin is under
# bin/, which the audit does watch. The label stays a literal in trusted
# code; it just lives with the harness that owns it.
#
# Each surface carries its own namespace, so ids stay stable per harness and
# no rename is forced on anyone's baseline.
DEJIMA_HOST_EXEC=()
# namespace, leaf, path, label. The id is "<namespace>:<leaf>", which is what
# a baseline stores, so neither may change for a harness already in use.
emit_host_exec() {
local ns="$1" leaf="$2" path="$3" label="$4"
[ -n "$path" ] || return 0
case "$ns" in
''|*[!a-z0-9-]*)
echo "Harness plugin emitted an invalid host-exec namespace: $ns" >&2
exit 1 ;;
esac
# The leaf lands in an id, which is compared against baseline entries and
# written into a report; a tab or newline in either would split a record.
case "$leaf$label" in
*$'\t'*|*$'\n'*)
echo "Harness plugin emitted a host-exec leaf or label containing a tab or newline" >&2
exit 1 ;;
esac
[ -n "$leaf" ] && [ -n "$label" ] || {
echo "Harness plugin emitted a host-exec surface with no leaf or label" >&2
exit 1
}
DEJIMA_HOST_EXEC+=("$ns"$'\t'"$leaf"$'\t'"$path"$'\t'"$label")
}
# Asks the loaded plugin for its surfaces. Empty when no harness is selected
# or the harness has none, which is a normal state rather than an error.
host_exec_load() {
DEJIMA_HOST_EXEC=()
harness_hook harness_host_exec_surfaces
}
# The host path behind an id, or nothing if that id is not a host-exec one.
host_exec_path_for_id() {
local id="$1" rec ns leaf path label
for rec in ${DEJIMA_HOST_EXEC[@]+"${DEJIMA_HOST_EXEC[@]}"}; do
IFS=$'\t' read -r ns leaf path label <<< "$rec"
[ "$id" = "$ns:$leaf" ] || continue
printf '%s' "$path"
return 0
done
return 1
}
# Whether an id names a host-execution surface. Used where severity is
# decided: every one of these is CRITICAL, because the whole category is
# "runs on the host, outside everything".
host_exec_is_id() { host_exec_path_for_id "$1" >/dev/null; }
+14 -20
View File
@@ -7,30 +7,24 @@
# (no argument) watch until killed - what the systemd unit runs
# --once one scan, then exit
#
# This script is the trust boundary. Everything under the marker root is
# written by a sandboxed session that may be compromised, and the device it
# ultimately reaches - the operator's phone - is outside the sandbox. The
# in-container hook's own enum constrains nothing here: a session owns the
# contents of its bind-mounted marker directory and can create any name it
# likes in it, hook or no hook. The guarantee rests entirely on this file:
# This script is the trust boundary: a possibly-compromised session writes
# the markers, and the phone they reach is outside the sandbox. The
# in-container hook's enum constrains nothing here - a session owns its
# marker directory and can create any name it likes. The guarantee is here:
#
# - the marker NAME is the only thing consulted, and only by iterating a
# literal enum, so a name outside it is never stat'd, read, or removed
# - marker CONTENT is never read, so there is nothing to inject
# - names are never interpolated into a command
# - `rm -f` unlinks a symlink instead of following it, so a marker aimed at
# a host file removes the link and leaves the file alone
# - only the marker NAME is consulted, by iterating a literal enum, so a
# name outside it is never stat'd, read, or removed
# - marker CONTENT is never read, and names never reach a command
# - `rm -f` unlinks a symlink rather than following it
# - the click URL and every ntfy header are composed from host-side data
#
# That last point is what the enum is really for. ntfy's own headers include
# X-Actions (which can make the phone issue an arbitrary HTTP request or fire
# an Android intent), X-Attach and X-Icon (which fetch URLs), X-Email and
# X-Call. A single attacker-controlled header here would turn the phone into
# a confused deputy inside the operator's network.
# The last point is what the enum is for: ntfy's X-Actions can make the phone
# issue an HTTP request or fire an Android intent, X-Attach and X-Icon fetch
# URLs. One attacker-controlled header makes the phone a confused deputy
# inside the operator's network.
#
# One scan covers every run directory, so nothing a single session can do to
# its own markers may abort it: that would let one compromised sandbox silence
# every other session's notifications.
# One scan covers every run directory, so nothing a session does to its own
# markers can abort it and silence every other session's notifications.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
+29 -41
View File
@@ -115,24 +115,20 @@ read_declared() {
# Shared argument parsing for dejima-allow and dejima-dep, so the two CLIs
# cannot drift on what --project means.
#
# The hard part is that --project's path is OPTIONAL, which makes
# `--project X` ambiguous on its own: X could be the path, or the required
# positional. Deciding by "is X the last token" is wrong for `dejima-dep list
# --project ~/p`, which has no trailing positional to protect and would
# silently resolve to the current directory instead - the scope quietly
# becoming one the caller did not name. So the decision is made against how
# many positionals the command actually requires after the flag:
# --project's path is OPTIONAL, so `--project X` is ambiguous: X could be the
# path or the required positional. Deciding by "is X the last token" breaks
# `dejima-dep list --project ~/p`, which has no trailing positional and would
# silently scope to the current directory instead. So:
#
# --project takes the next token as its path only if doing so still leaves
# the command its required trailing positional(s).
# --project takes the next token as its path only if that still leaves the
# command its required trailing positional(s).
#
# Required counts are per-command constants declared by the caller, not
# inferred here.
# Required counts are per-command constants the caller declares.
#
# Sets dj_global, dj_project, dj_project_set, dj_container and the
# dj_positional array. dj_project_set exists because "--project with no path"
# (the current directory) and "no --project at all" are different scopes and
# an empty dj_project cannot tell them apart.
# Sets dj_global, dj_project, dj_project_set, dj_container and dj_positional.
# dj_project_set exists because "--project with no path" (the current
# directory) and "no --project at all" are different scopes that an empty
# dj_project cannot distinguish.
parse_scope_args() {
local required="$1"; shift
local -a args=("$@")
@@ -336,34 +332,26 @@ declare_entry() {
local project="$1" kind="$2" entry="$3" validator
validator="$(_approved_validator "$kind")" || return 1
"$validator" "$entry" || { echo "Invalid $kind entry: $entry" >&2; return 1; }
# This is the one write in this file that lands on the session-writable
# bind mount, so it is the one that must not follow a symlink. A
# compromised session can plant one and then ask the user - through the
# startup briefing, which tells it to hand over exactly this command - to
# run the CLI that trips it. Following it would aim a host-privileged
# append anywhere the user can write: at ~/.config/dejima/allowed-domains,
# silently turning a project approval into a global one; at
# ~/.config/dejima/dependencies, where the entry is installed as root at
# image build, on the one code path with no egress firewall in front of it;
# or at any host file at all.
# The one write here that lands on the session-writable bind mount, so
# the one that must not follow a symlink. A session can plant one and
# then ask the user - via the briefing, which hands over exactly this
# command - to run the CLI that trips it, aiming a host-privileged
# append at ~/.config/dejima/allowed-domains (project approval becomes
# global) or dependencies (installed as root at image build, the one
# path with no firewall in front of it).
#
# A test would not be enough. The briefing tells the agent to have the user
# run `dejima-allow --project` from a terminal outside the sandbox, so the
# session is normally still LIVE while this runs - and since it is the one
# asking for the command, it picks the moment and can spin a rename loop
# across any window between a check and the write. So there is no check:
# the guard is the open itself. O_NOFOLLOW makes a symlink at either path
# fail the open outright, the directory is opened O_DIRECTORY and the file
# is opened relative to that fd, so neither can be swapped for the other's
# sake afterwards, and the whole read-then-append happens through that one
# fd. A session that wins the swap gets an open of the object it replaced,
# not a redirected write.
# Checking first is not enough: the session is normally still live when
# the user runs this, and it chose the moment, so it can spin a rename
# loop across any check-to-write window. The guard is the open itself.
# O_NOFOLLOW fails outright on a symlink, the directory is opened
# O_DIRECTORY and the file relative to that fd, and the whole
# read-then-append goes through it - so winning the swap gets an open of
# the replaced object, not a redirected write.
#
# bash cannot open a path that way, so this is python3 - a hard dependency
# already (install.sh refuses to install without it). O_NONBLOCK and the
# S_ISREG check cover the non-symlink shapes: a FIFO planted here would
# otherwise block a host CLI the user was told to run for as long as the
# session declines to open the other end.
# bash cannot open a path that way, hence python3 (already a hard
# dependency). O_NONBLOCK and the S_ISREG check cover the non-symlink
# shapes: a planted FIFO would otherwise hang the host CLI for as long as
# the session declined to open the other end.
python3 - "$project/.dejima" "$kind" "$entry" "$DEJIMA_MAX_DECLARED" <<'PY'
import errno, os, stat, sys
+107 -21
View File
@@ -141,6 +141,10 @@ EOF
;;
harness) cat <<'EOF'
command identifier claude
command_path ?path
guards ?list:identifier
no_confirm_args ?template
settings_args ?template
keys ?list:keyspec
resume_command ?template
briefing ?relpath
@@ -329,28 +333,17 @@ manifest_get() {
# directory whose manifest declares its own keys, not editing core.
# ---------------------------------------------------------------------------
# config_validate re-parses the WHOLE config file on every config_get call
# (see its own comment - "on the order of thirty reads" per launch), and each
# pass through a [forge.<name>]/[harness.<name>] line calls back into here.
# Going through manifest_get/manifest_validate here would mean thirty-odd
# full-manifest validations (every field, not just `keys`) for every key in
# those sections - so this reads and validates only the `keys` field itself,
# a plain line-scan plus the one type check _provider_valid_value already
# does for it, skipping fields (pr_command, capabilities, env_export, ...)
# that this lookup never uses. The manifest's OTHER fields still get their
# full manifest_validate elsewhere before anything consequential reads them
# (provider_validate_selection, forge_hosts, forge_env_exports,
# briefing_compose) - this function only ever feeds a config schema type
# check, never a forge action.
# config_validate re-parses the whole config on every config_get (~thirty per
# launch), and each [forge.<name>] line calls back here. Going through
# manifest_validate would mean that many full-manifest validations, so this
# line-scans the `keys` field alone. Every other field still gets a full
# manifest_validate before anything consequential reads it - this feeds a
# config schema type check, never a forge action.
#
# Cached per axis+name for the rest of THIS command substitution: a
# manifest cannot change mid-launch, so re-deriving it for every key in the
# same section within one config_validate pass is pure waste. The cache
# itself does not survive past this subshell (bash forks a new one per
# config_get call), so it only dedupes the N keys of one section against
# each other, not across a launch's ~thirty reads - a cheap, safe partial
# win rather than a cross-process cache this file has no story for
# invalidating.
# Cached per axis+name for this command substitution only. bash forks a new
# subshell per config_get, so it dedupes the keys of one section against each
# other rather than across a launch: a cheap partial win, not a cross-process
# cache this file has no way to invalidate.
declare -gA _PROVIDER_KEYS_CACHE
_provider_keys_cached() {
local axis="$1" name="$2" cache_key="$axis:$name"
@@ -371,6 +364,10 @@ _provider_keys_cached() {
# here is a per-line fork thirty times over.
_provider_keys_field() {
local axis="$1" name="$2" dir file line key value="" seen=0 type n=0
# An unselected axis contributes no keys - a different question from
# whether a selection is valid. Without this, --print-config prints
# "invalid harness name:" and fails at the thing it is for.
[ -n "$name" ] || return 1
dir="$(provider_dir "$axis" "$name")" || return 1
file="$dir/$(_provider_manifest_name "$axis")"
[ -f "$file" ] || return 1
@@ -433,6 +430,37 @@ _config_extra_spec() {
return 1
}
# One key of the SELECTED harness, rather than of a harness named literally
# at the call site.
#
# `keys` is declared per harness, so reading one through a fixed section name
# makes it a declaration only one harness can ever be read through: a second
# harness gets selected, gets validated, and is then handed the first one's
# paths with nothing erroring anywhere. That is a silent wrong answer rather
# than a failure, which is why it is worth a helper rather than a longer
# expression at each site.
#
# Empty when no harness is selected. bin/dejima refuses to start in that
# state (there is no default, deliberately - see [core] harness), so it is
# reachable only from the standalone tools, where a harness-specific path
# that resolves to nothing is the honest answer for "no harness chosen".
harness_config_get() {
local name
name="$(config_get core harness)"
[ -n "$name" ] || return 0
config_get "harness.$name" "$1"
}
# Every guard rail dejima knows how to apply. A harness declares which of
# them it can host, in its manifest's `guards`; the ones it cannot are named
# at launch (see harness_report_guards) rather than quietly absent.
#
# Here rather than in dejima-harness-lib.sh because provider_validate_selection
# rejects a name outside this set, and a constant the validation depends on
# has to live where the validation does - providers.bats sources only this
# file, and against an empty set every guard is unknown.
DEJIMA_KNOWN_GUARDS="bash-deny push-guard merge-guard notify"
# Enumerates the keys of the SELECTED forge/harness (core.forge/core.harness,
# falling back to their schema default when no config file exists yet to
# read a selection from - e.g. an isolated call against a fresh checkout)
@@ -472,6 +500,25 @@ provider_validate_selection() {
manifest_validate workflow "$workflow" || return 1
manifest_validate harness "$harness" || return 1
# Every guard rail dejima knows how to apply. A harness declares which of
# them it can host; the ones it cannot are named at launch rather than
# quietly absent, because an operator who has them everywhere else would
# otherwise have to read a manifest to find out they are gone here.
#
# 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.
local guard
for guard in $(manifest_get harness "$harness" guards); do
case " $DEJIMA_KNOWN_GUARDS " in
*" $guard "*) ;;
*)
echo "harness '$harness' declares an unknown guard: $guard" >&2
echo "Known guards: $DEJIMA_KNOWN_GUARDS" >&2
return 1 ;;
esac
done
caps="$(manifest_get forge "$forge" capabilities)"
requires="$(manifest_get workflow "$workflow" requires)"
for need in $requires; do
@@ -726,6 +773,14 @@ What this means:
.dejima/allowed-domains, so it is shared with everyone working on it
rather than living on one machine. Quote that --container value exactly as
written here - it is this container's real ID, not something to infer.
And a domain can be an action rather than a source: a deploy hook, a
payment or production API acts on the world the moment it is called, with
no diff for anyone to review afterwards. Do not ask for one of those to
finish a task - say what you need and let the operator decide.
- The project directory is mounted whole, so it can hold things that are not
code: a .env, a database dump, a key someone left in the tree. Say so if
you find one, and do not open, copy or send it anywhere unless the task
actually requires it.
- Stay within the task you were given. Since there are no permission
prompts here, be more conservative than usual about anything destructive,
irreversible, or outside the project directory.
@@ -750,6 +805,33 @@ _briefing_core_worktree() {
`.worktrees/` to `.git/info/exclude` (not `.gitignore` - this is
sandbox-local, not something to commit) so it doesn't clutter `git
status` for the user's real checkout.
- A worktree isolates the working tree, NOT `.git/config`, which is shared
with the parent checkout. A bare `git config <key> <value>` run from
inside a worktree writes to the user's real repository config, not to
anything scoped to your branch. Use `git -C <path> config` against a
throwaway repo when setting config for a test or a repro. This matters
beyond a stray `user.name`: `core.hooksPath`, `credential.helper` and
`!`-aliases all execute on the host, and the same unscoped command
reaches them.
BRIEFING_EOF
}
# Only when GitHub is a push destination this session. The default is off for
# a reason the agent cannot infer from having the ability: docs/security.md,
# "GitHub push". An agent that finds a real bug in a dependency and helpfully
# opens a pull request on it has not made a mistake about permissions - it
# has done something the operator was supposed to decide, and added to a queue
# maintainers are already drowning in. Saying so here is cheaper than
# discovering it afterwards, and the flag is exactly when it becomes possible.
_briefing_core_github_push() {
cat <<'BRIEFING_EOF'
- GitHub push is enabled for this session, for the repository you were
pointed at and nothing else. Do NOT push a branch, open a pull request, or
file an issue on any other project, however good the change would be. An
unsolicited pull request is a cost imposed on maintainers who did not ask
for it, not a contribution, and it arrives under the operator's name. If
you find a real bug somewhere else, say so in your reply and let them
decide what to do about it.
BRIEFING_EOF
}
@@ -1013,6 +1095,10 @@ briefing_compose() {
text+=$'\n\n'
text+="$workflow_frag"
text+=$'\n\n'
if [ "${DEJIMA_GITHUB_PUSH:-off}" = "on" ]; then
text+="$(_briefing_core_github_push)"
text+=$'\n\n'
fi
text+="$(_briefing_core_closing_a)"
text+=$'\n\n'
text+="$harness_frag"
+81
View File
@@ -0,0 +1,81 @@
# Shared library: the ~/.claude/projects slug scheme, and which slugs belong
# to a given workspace. Sourced by bin/dejima (to decide what to mount) and
# bin/dejima-epitaph (to decide whose transcripts to keep). Not on PATH and
# not executable.
#
# One definition, because the callers must not disagree. A slug the mount
# exposes but a caller treats as foreign is a session whose work is silently
# left behind; a slug a caller treats as the workspace's own but the mount
# does not expose is a hole that nothing reports.
# Claude Code's own project key: the absolute cwd with every non-alphanumeric
# replaced by a dash. Matches docker/log-shipper.sh's Loki key and
# dejima-project-lib.sh's project_slug, which cannot source this file (one
# runs inside the container, the other is a separate library).
ws_slug() { printf '%s' "$1" | sed 's/[^a-zA-Z0-9]/-/g'; }
# The slugs belonging to $1: its own, plus one per live git worktree nested
# underneath it. One per line, sorted, no duplicates.
#
# Derived from real worktree paths rather than from the slug string, and that
# is the whole point of the function. Slugs cannot distinguish a child from a
# sibling: `/w/proj/wt1` and `/w/proj-wt1` both slug to `-w-proj-wt1`, so a
# `$slug-*` glob matches any sibling project whose directory name merely
# extends this one's - on a host with `proj` and `proj-ci` checked out side by
# side, `proj-ci` reads as part of `proj`. A path test cannot make that
# mistake, because a sibling is not under the workspace directory.
#
# Listing from a worktree names the main checkout and its siblings too. Those
# are not under $1, so they drop out here: launching against a worktree
# exposes that worktree and nothing else, which is correct.
workspace_slugs() {
local ws="$1" wt
ws="${ws%/}"
ws_slug "$ws"
printf '\n'
# A path containing a newline splits into two lines that name no real
# directory, so both fail the -d test below rather than being parsed as
# some other worktree. git has no unambiguous non-`-z` output here.
git -C "$ws" worktree list --porcelain 2>/dev/null \
| sed -n 's/^worktree //p' \
| while IFS= read -r wt; do
case "$wt" in
"$ws"/*) ;;
*) continue ;;
esac
[ -d "$wt" ] || continue
ws_slug "$wt"
printf '\n'
done
}
# Every slug in workspace_slugs, sorted and deduplicated.
workspace_slugs_sorted() { workspace_slugs "$1" | sort -u; }
# The Claude Code session ids belonging to workspace $2, one per line, taken
# from the transcript filenames under $1 (a ~/.claude/projects directory).
#
# ~/.claude/file-history is keyed by session id rather than by project, so
# there is no slug in its own paths to scope it by. The transcript is what
# ties the two together: projects/<slug>/<id>.jsonl and file-history/<id>
# name the same session, so the slugs this workspace owns select the ids it
# owns. A session id that has no transcript belongs to no project this can
# see, and is left out.
workspace_session_ids() {
local projects_dir="$1" ws="$2" slug f id
[ -d "$projects_dir" ] || return 0
while IFS= read -r slug; do
[ -n "$slug" ] || continue
[ -d "$projects_dir/$slug" ] || continue
for f in "$projects_dir/$slug"/*.jsonl; do
[ -f "$f" ] || continue
id="$(basename "$f" .jsonl)"
# Only a real session id, never whatever else a directory might
# hold: this value becomes a mount path.
case "$id" in
*[!a-fA-F0-9-]*) continue ;;
esac
[ -n "$id" ] && printf '%s\n' "$id"
done
done < <(workspace_slugs_sorted "$ws") | sort -u
}
+18 -28
View File
@@ -5,37 +5,27 @@
# systemd/dejima-ttyd.service.
# See docs/remote-access.md.
#
# --interface 127.0.0.1 is a security requirement, not a preference.
# docker/init-firewall.sh:187 accepts container OUTPUT to the default-route
# address - the Docker bridge gateway, i.e. the host - and attaching tmux
# gives a HOST shell (the prefix key opens a new window outside the
# container). A listener on any address the container can route to would hand
# a compromised session its own escape hatch. Loopback is not one of those
# addresses: the container has no route to the host's 127.0.0.1. Put nginx in
# front, bound to one specific LAN address - see
# docs/nginx-dejima.conf.example.
# --interface 127.0.0.1 is a requirement, not a preference. init-firewall.sh
# accepts container OUTPUT to the bridge gateway (the host), and attaching
# tmux gives a HOST shell - the prefix key opens a window outside the
# container. A listener on any address the container can route to is a
# compromised session's escape hatch; loopback is not one of them. Put nginx
# in front, bound to one LAN address - docs/nginx-dejima.conf.example.
#
# ttyd is given no credential of its own, so the reverse proxy is the only
# thing authenticating a request, and the loopback bind is what makes that
# safe rather than careless: nothing but a process on this host can open the
# socket at all. --writable is what makes the terminal a terminal rather than
# a viewer.
# ttyd carries no credential of its own, so the proxy is the only thing
# authenticating and the loopback bind is what makes that safe. --writable
# makes it a terminal rather than a viewer.
#
# --check-origin is the other half of that, and it is not optional. Without
# it ttyd does no origin validation at all (it only calls check_host_origin
# when the flag is set), and browsers do not apply the same-origin policy to
# WebSockets: any page the operator's browser loads could open
# wss://<front-door>/ws, have the browser attach its cached credential for
# that origin, and be handed this host shell. Proxy authentication cannot
# stop that - it is satisfied by the victim's own browser. ttyd compares the
# Origin authority against the Host header, which is why the nginx example
# passes Host through unchanged; the two go together.
# --check-origin is not optional: without it ttyd does no origin validation
# at all, and browsers do not apply same-origin to WebSockets, so any page
# the operator loads could open wss://<front-door>/ws with the browser
# attaching its cached credential and be handed this shell. Proxy auth cannot
# stop that - the victim's own browser satisfies it. ttyd compares Origin
# against Host, which is why the nginx example passes Host through unchanged.
#
# --url-arg lets the client pass ?arg=<run-id>, which is what the notification
# deep link uses. It reaches dejima-attach as argv, never as a shell word, and
# anything there that is not --list or the empty string (a bare `?arg=`, which
# lands on the picker) has to match the run-id shape before it is used as a
# path component or anything else.
# --url-arg carries ?arg=<run-id> for the notification deep link. It reaches
# dejima-attach as argv, never a shell word, and anything but --list or empty
# must match the run-id shape before it is used as a path component.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
+232
View File
@@ -0,0 +1,232 @@
# Harness plugin: Claude Code.
#
# Sourced by bin/dejima through harness_plugin_load when [core] harness names
# this harness. Part of the trusted computing base - see the interface notes
# in bin/dejima-harness-lib.sh for why plugins live here and why they are
# sourced rather than invoked.
#
# What is here is what could not be stated as a manifest value. The paths
# themselves come from the manifest ([harness.claude-code] projects_dir); the
# reason this is code is the pairing in the second block, which resolves one
# directory tree through the filenames of another.
# Registers the waiting/done notification handlers into dejima's settings
# layer, in the shape Claude Code reads them.
#
# Claude Code merges hook entries across settings layers rather than letting
# the higher one replace the lower, so these are added to whatever the
# operator already has rather than displacing it. $HOME is left unexpanded
# for Claude Code to resolve at hook-run time - inside the container, not
# here - matching the convention install.sh already uses.
#
# Written through a temp file and renamed: the destination is the settings
# layer bin/dejima is about to mount, and a jq that failed partway would
# otherwise leave the session with a truncated one.
harness_register_notify() {
local settings="$1" tmp
tmp="$(mktemp "${TMPDIR:-/tmp}/dejima-settings-remote.XXXXXX.json")"
if jq '.hooks.Notification = ((.hooks.Notification // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/dejima-notify.sh waiting"}]}])
| .hooks.Stop = ((.hooks.Stop // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/dejima-notify.sh done"}]}])' \
"$settings" > "$tmp"; then
mv "$tmp" "$settings"
else
rm -f "$tmp"
echo "warning: could not register the notification hooks; remote access" >&2
echo "will not be told when this session wants attention." >&2
fi
}
# The per-project and per-session state this harness keeps on the host, and
# which parts of it this workspace is entitled to see.
harness_state_mounts() {
local ws="$1" projects slug sid
projects="$(harness_config_get projects_dir)"
[ -n "$projects" ] || return 0
# projects/, one slug at a time: the tree is every project's transcripts
# and memory, and memory is loaded into context at session start, so a
# write into another slug is background a future session there believes
# (#104). projects/ itself stays container-local, so an unmounted slug is
# still writable and just does not survive - which is how Claude Code
# creates a slug it does not find. Existing slugs only, apart from the
# workspace's own, so a checkout full of worktrees does not litter the
# host with empty directories for worktrees nothing has run in.
mkdir -p "$projects/$(ws_slug "$ws")"
while IFS= read -r slug; do
[ -n "$slug" ] || continue
[ -d "$projects/$slug" ] || continue
emit_mount "$projects/$slug"
done < <(workspace_slugs_sorted "$ws")
# file-history, same shape, sharper reason: `<session>/<hash>@v1` is a
# verbatim copy of an edited file, so the tree is other projects' source
# rather than a record of it, and it was writable - tampering there puts
# chosen content into a later restore. Keyed by session id, so there is
# no slug to match on; workspace_session_ids resolves it through the
# transcript names. The starting session's id cannot be known yet, so its
# directory is container-local: undo history for it is lost if it is
# resumed on the host.
#
# The pairing is why the plugin is code, and the split is the design in
# miniature: both directories are statable, so both are manifest keys.
# What cannot be stated is the rule that a name in one selects an entry
# in the other, so that is here.
local history
history="$(harness_config_get file_history_dir)"
[ -n "$history" ] || return 0
mkdir -p "$history"
while IFS= read -r sid; do
[ -n "$sid" ] || continue
[ -d "$history/$sid" ] || continue
emit_mount "$history/$sid"
done < <(workspace_session_ids "$projects" "$ws")
}
# Puts dejima's guard hooks where Claude Code will find them: symlinked into
# ~/.claude/hooks, and registered in ~/.claude/settings.json. Run by
# install.sh, not at launch - this writes the operator's own config, and the
# operator is who decides to install.
#
# $1 is the repo root, $2 the directory to move a displaced file into. Every
# change is printed as it happens, because it is someone's real config.
#
# Each guard is gated on the harness having declared it: a harness declares
# what it can host (see `guards` in its manifest), and registering something
# it did not declare would contradict the launch message that says so.
harness_register_guards() {
local repo="$1" backup="$2" settings="$HOME/.claude/settings.json"
mkdir -p "$HOME/.claude/hooks"
_link_hook() {
local name="$1" src="$repo/hooks/$1" dest="$HOME/.claude/hooks/$1"
if [ -L "$dest" ] && [ "$(readlink -f "$dest")" = "$(readlink -f "$src")" ]; then
echo "Skipping: $name already linked to this repo's copy"
return
fi
if [ -e "$dest" ] || [ -L "$dest" ]; then
mkdir -p "$backup"
echo "Moving: existing ~/.claude/hooks/$name to $backup/$name"
mv "$dest" "$backup/$name"
fi
echo "Linking: $name ($src -> $dest)"
ln -s "$src" "$dest"
}
# Not a guard: this is R3, the briefing's delivery mechanism, and a
# session without it does not know it is in a sandbox.
_link_hook sandbox-context.sh
harness_has_guard push-guard && _link_hook git-push-guard.sh
harness_has_guard merge-guard && _link_hook merge-guard.sh
# Symlink from before the gitea-merge-guard.sh -> merge-guard.sh rename
# points at a hooks/ path that no longer exists. Only remove it if it is
# actually dangling, so an operator's own file at this name is left alone.
local old_merge_guard="$HOME/.claude/hooks/gitea-merge-guard.sh"
if [ -L "$old_merge_guard" ] && [ ! -e "$old_merge_guard" ]; then
echo "Removing: dangling pre-rename symlink $old_merge_guard"
rm -f "$old_merge_guard"
fi
# Linked so it exists at a stable path for the read-only ~/.claude/hooks
# mount, but deliberately NOT registered below: bin/dejima registers it
# per session, into a throwaway settings copy, and only when remote
# access is configured. Registering it here would fire it for every
# unsandboxed host session too, where it has no marker directory to
# write to.
harness_has_guard notify && _link_hook dejima-notify.sh
# deny-dangerous-bash.sh is the one hook that is NOT sandbox-gated: it
# has no DEJIMA check, so it applies to the operator's own host sessions
# as well. That is deliberate, and it is why it may already be owned by
# another install (dotfiles, say) - only claim it if nothing else
# provides it, so this repo works standalone without clobbering an
# existing setup.
if harness_has_guard bash-deny; then
local deny_dest="$HOME/.claude/hooks/deny-dangerous-bash.sh"
if [ -e "$deny_dest" ] || [ -L "$deny_dest" ]; then
echo "Skipping: deny-dangerous-bash.sh already present at $deny_dest (leaving it as-is)"
else
echo "Linking: deny-dangerous-bash.sh ($repo/hooks/deny-dangerous-bash.sh -> $deny_dest)"
ln -s "$repo/hooks/deny-dangerous-bash.sh" "$deny_dest"
fi
fi
echo
if [ ! -f "$settings" ]; then
echo "Creating $settings"
echo '{}' > "$settings"
fi
# The registration that went with the symlink removed above. Two things
# depend on it going: Claude Code reports a missing hook command on every
# session start while it points at a path that no longer exists, and -
# until the dedupe below stopped matching it - it also stood in for the
# registration it was renamed from, so `_register merge-guard.sh` skipped
# and the merge guard was registered nowhere at all. Dropped only when
# the path it names is gone, so an operator's own file at that name keeps
# its registration, matching the symlink check above.
if [ ! -e "$old_merge_guard" ] && jq -e --arg s "/gitea-merge-guard.sh" \
'[.hooks.PreToolUse[]?.hooks[]?.command // empty] | any(endswith($s))' \
"$settings" >/dev/null 2>&1; then
echo "Removing: stale pre-rename gitea-merge-guard.sh registration from $settings"
local stale_tmp; stale_tmp=$(mktemp)
jq --arg s "/gitea-merge-guard.sh" \
'.hooks.PreToolUse = ((.hooks.PreToolUse // [])
| map(.hooks = ((.hooks // []) | map(select((.command // "") | endswith($s) | not))))
| map(select((.hooks | length) > 0)))' \
"$settings" > "$stale_tmp" && mv "$stale_tmp" "$settings"
fi
_register() {
local event="$1" matcher="$2" script_name="$3" description="$4"
# endswith on the leading slash, not test(): test() is an unanchored
# regex, so a hook whose name merely ends in another's - which is what
# a rename like gitea-merge-guard.sh -> merge-guard.sh produces - read
# as already registered, and the new hook was silently never added.
if jq -e --arg s "/$script_name" \
"[.hooks.${event}[]?.hooks[]?.command // empty] | any(endswith(\$s))" \
"$settings" >/dev/null 2>&1; then
echo "Skipping: $script_name already registered as a $event hook"
return
fi
echo "Registering $script_name as a $event hook in $settings ($description)"
local tmp
tmp=$(mktemp)
jq --arg matcher "$matcher" --arg cmd "\$HOME/.claude/hooks/$script_name" \
".hooks.${event} = ((.hooks.${event} // []) + [{\"matcher\": \$matcher, \"hooks\": [{\"type\": \"command\", \"command\": \$cmd}]}])" \
"$settings" > "$tmp" && mv "$tmp" "$settings"
}
_register SessionStart "startup|resume|clear|compact" sandbox-context.sh \
"injects sandbox context on session start"
harness_has_guard push-guard && _register PreToolUse Bash git-push-guard.sh \
"blocks pushes outside the allowed host(s)/branches"
harness_has_guard merge-guard && _register PreToolUse Bash merge-guard.sh \
"blocks the configured forge's merge command inside the sandbox"
harness_has_guard bash-deny && _register PreToolUse Bash deny-dangerous-bash.sh \
"blocks obviously destructive Bash commands"
echo
return 0
}
# What Claude Code causes to run on the HOST, unsandboxed.
#
# settings.json's statusLine.command names a script the host executes on
# every session, so a session that can write that file has a path onto the
# host that no container boundary sees. bin/dejima stages the file rather
# than mounting over it, because a :ro mount landing on a symlink - the
# common case - protects the target and leaves the link replaceable;
# detecting a change here is the compensating control for that.
#
# The namespace and leaf together form the id a baseline stores
# ("statusline:statusline.sh"), so neither may change while anyone has
# approvals recorded. The label is the fixed prefix `ack` strips to recover
# that id, which is why it lives here as a literal rather than in the
# manifest - see the host-exec notes in bin/dejima-harness-lib.sh.
harness_host_exec_surfaces() {
local p
p="$(harness_config_get statusline_path)"
emit_host_exec statusline statusline.sh "$p" \
"HOST-CODE-EXECUTION statusline.sh changed (runs UNSANDBOXED on the host via settings.json statusLine.command): "
}
+28 -29
View File
@@ -77,19 +77,15 @@ ENV DEJIMA=1
# this user, so it doesn't need to own anything itself.
RUN useradd -r -s /usr/sbin/nologin ulogd
# Every fetch from here down retries, and none is single-shot. What matters
# isn't the odds of a particular download failing, it's the odds of any of
# them failing - and without retries a single blip anywhere throws away the
# whole build.
# Every fetch from here down retries. What matters is not the odds of one
# download failing but of ANY of them failing, and without retries a single
# blip discards the whole build.
#
# These steps deliberately sit *above* the SANDBOX_* ARGs, not below. Nothing
# between here and `USER` needs the sandbox user to exist yet - they all
# install system-wide as root - so keeping them ahead of the ARGs means a
# build that overrides one (test/runtime.bats does, and so does any host
# whose uid isn't 1000) still reuses every layer down to this point instead
# of re-running all of it. That single ordering was most of the cost of the
# override rebuild: it was 1169s of a 1379s test run, for a difference that
# amounts to one useradd.
# These steps sit ABOVE the SANDBOX_* ARGs on purpose: nothing here needs the
# sandbox user, so a build overriding a uid (runtime.bats, and any host not
# on 1000) still reuses every layer to this point. That ordering alone was
# most of the cost of an override rebuild, for a difference amounting to one
# useradd.
# gosu lets entrypoint.sh (root) drop to the node user for the actual
# session after setting up the firewall, without leaving any setuid/sudo
# escalation path available to node afterward. Verified against the
@@ -185,7 +181,11 @@ RUN mkdir /commandhistory && \
# from the host, and Docker creates the parents of a mount point root-owned
# if they don't already exist - which would leave the sandbox user unable to
# write the caches and history it keeps directly in ~/.claude.
RUN mkdir -p $SANDBOX_HOME/.ssh $SANDBOX_HOME/.claude && \
# projects/ is in that list for the same reason one level down: it is no
# longer mounted itself, only individual slug directories inside it, so
# without this Docker creates it root-owned while setting those up and the
# session cannot write a slug of its own.
RUN mkdir -p $SANDBOX_HOME/.ssh $SANDBOX_HOME/.claude $SANDBOX_HOME/.claude/projects $SANDBOX_HOME/.claude/file-history && \
chown -R $SANDBOX_USER:$SANDBOX_USER $SANDBOX_HOME/.ssh $SANDBOX_HOME/.claude && \
chmod 700 $SANDBOX_HOME/.ssh $SANDBOX_HOME/.claude
@@ -255,24 +255,23 @@ RUN npm install -g tree-sitter-cli@0.24.7
# matching Chromium build into node's own cache, both at build time so a
# session never needs network access for either.
#
# Chromium's own internal process sandbox is questionable here: it launches
# without --no-sandbox and without error, but its setuid sandbox helper
# (chrome_sandbox) isn't actually setuid-root in this image, and this host
# has the same unprivileged-userns restriction that broke bubblewrap - so
# its namespace-based isolation almost certainly isn't active either, it's
# just not hard-failing the way bubblewrap did. Not treated as a real
# security boundary either way: this container is already the boundary that
# matters, and a compromised session already has full `node`-level bash
# access, which a Chromium exploit couldn't exceed regardless of whichever
# of its own internal sandbox layers happen to be active.
# Chromium's own process sandbox is probably not active here: it launches
# without error, but chrome_sandbox is not setuid-root in this image and the
# unprivileged-userns restriction that broke bubblewrap applies, so it just
# fails softer than bubblewrap did. Not treated as a boundary either way -
# the container is the boundary, and a session already has node-level shell
# access that a Chromium exploit could not exceed.
RUN npm install -g playwright@${PLAYWRIGHT_VERSION} && \
npx playwright install chromium
# Copy and set up firewall + DNS-proxy + entrypoint scripts, every forge's
# CLI helpers, and the claude wrapper that pins
# --dangerously-skip-permissions. The launched application itself
# (SANDBOX_CMD, default `claude`) and any editor config for it are supplied
# at runtime, not baked into the image - see docker-compose.yml/entrypoint.sh.
# CLI helpers, and the harness wrapper that pins whichever no-confirm flags
# the selected harness needs. The wrapper is installed under a neutral name:
# the image is shared across harnesses and the selection is per launch, so
# entrypoint.sh is what links it into place as the harness's own command.
# The launched application itself (SANDBOX_CMD) and any editor config for it
# are supplied at runtime, not baked into the image - see
# docker-compose.yml/entrypoint.sh.
COPY docker/fixed-domains.sh /usr/local/bin/
COPY docker/dns-proxy.sh /usr/local/bin/
COPY docker/init-firewall.sh /usr/local/bin/
@@ -282,7 +281,7 @@ COPY docker/ulogd.conf /etc/ulogd-sandbox.conf
COPY docker/sandbox-clipboard.lua /usr/local/share/nvim/runtime/plugin/sandbox-clipboard.lua
COPY docker/entrypoint.sh /usr/local/bin/
COPY docker/anthropic-proxy-forwarder.js /usr/local/bin/anthropic-proxy-forwarder.js
COPY docker/claude-wrapper.sh /usr/local/bin/claude
COPY docker/harness-wrapper.sh /usr/local/bin/dejima-harness-wrapper
# Back to root before the first RUN that writes outside /home/node. The npm
# installs above run as node deliberately; COPY writes as root regardless of
# USER, so the copies above are unaffected, but the install(1) below is a RUN
@@ -296,7 +295,7 @@ COPY forges/ /tmp/forges/
RUN find /tmp/forges -type f -path '*/bin/*' -exec install -m 755 {} /usr/local/bin/ \; \
&& rm -rf /tmp/forges
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/claude && \
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 && \
echo 'export PATH="$PATH:/usr/local/share/npm-global/bin"' > /etc/profile.d/npm-global-path.sh
# Entrypoint runs as root (no USER node here) so it can set up the firewall
-16
View File
@@ -1,16 +0,0 @@
#!/bin/bash
# Shadows the real `claude` binary (installed under npm-global/bin, later on
# PATH) so every invocation - interactive shell, or claudecode.nvim's
# terminal - runs with --dangerously-skip-permissions, without callers
# needing to know that.
#
# The sandbox's settings overrides arrive the same way: as an extra
# --settings layer rather than by mounting over ~/.claude/settings.json (see
# docker-compose.yml and #47). That only holds if every entry point passes
# the flag, and shadowing the binary is what makes it true for the
# invocations the entrypoint never sees. The file is absent when a container
# is started without bin/dejima, as test/runtime.bats does.
settings=()
[ -f /etc/dejima-settings.json ] && settings=(--settings /etc/dejima-settings.json)
exec /usr/local/share/npm-global/bin/claude --dangerously-skip-permissions "${settings[@]}" "$@"
+41 -29
View File
@@ -68,9 +68,18 @@ services:
# just live at ~/.gitconfig directly.
GIT_CONFIG_GLOBAL: ${HOME}/.gitconfig-sandbox/.gitconfig
ALLOWED_DOMAINS: ${ALLOWED_DOMAINS:-}
# What actually runs as the sandbox user - defaults to bare `claude`.
# See entrypoint.sh and bin/dejima's SANDBOX_NVIM_CONFIG.
# What actually runs as the sandbox user - the selected harness's own
# command unless [core] command overrides it. See entrypoint.sh and
# bin/dejima's SANDBOX_NVIM_CONFIG.
SANDBOX_CMD: ${SANDBOX_CMD:-claude}
# The selected harness's manifest, resolved host-side: which command
# the wrapper shadows, the real binary behind it, the flags that make
# the session unattended, and how this harness is told to read dejima's
# settings layer. Nothing about a harness is baked into the image.
SANDBOX_HARNESS_COMMAND: ${SANDBOX_HARNESS_COMMAND:-}
SANDBOX_HARNESS_REAL: ${SANDBOX_HARNESS_REAL:-}
SANDBOX_HARNESS_ARGS: ${SANDBOX_HARNESS_ARGS:-}
SANDBOX_HARNESS_SETTINGS: ${SANDBOX_HARNESS_SETTINGS:-}
# Opt-in escape hatch from the default hard GitHub-push block - see
# init-firewall.sh and git-push-guard.sh. Off unless explicitly set.
ALLOW_GITHUB_PUSH: ${ALLOW_GITHUB_PUSH:-0}
@@ -82,7 +91,7 @@ services:
# Opt-in (default off): also ship session prompts, assistant
# responses, and tool invocations to Loki as the
# dejima-conversation job. Content only leaves the container
# if LOKI_URL is also set. See README "Network request logging".
# if LOKI_URL is also set. See docs/security.md "Network request logging".
SANDBOX_LOG_CONVERSATION: ${SANDBOX_LOG_CONVERSATION:-0}
SSH_AUTH_SOCK: /ssh-agent
ENABLE_FIREWALL: ${ENABLE_FIREWALL:-1}
@@ -111,33 +120,24 @@ services:
# (based on cwd) lines up with the host, letting a session started
# here be resumed on the host with `claude --resume`.
- ${WORKSPACE_DIR:?set WORKSPACE_DIR to the project directory to mount}:${WORKSPACE_DIR}
# ~/.claude is deliberately not mounted as a whole. It used to be,
# read-write, with the config inside it layered back read-only on top.
# Every one of those overlays was a mount point nested in a
# host-writable mount, and a single-file bind mount is pinned to the
# dentry at that path: a host-side rename of the underlying file - a
# sync daemon, or Claude Code's own atomic writes - unlinks it, the
# mount silently detaches, and the path falls through to the real,
# writable host file (#47, #48). The same thing happened to the mask
# over .credentials.json, handing the sandbox the operator's real
# OAuth token on the first refresh (#50).
# ~/.claude is enumerated, never mounted whole. A single-file bind
# mount is pinned to the dentry at its path, so a host-side rename -
# a sync daemon, or Claude Code's own atomic writes - unlinks it, the
# mount detaches silently, and the path falls through to the real
# writable host file (#47, #48; #50 did exactly that to the
# .credentials.json mask). Anything not listed here is
# container-local, which is the safer default but means new state that
# should persist has to be added deliberately.
#
# So the contents are enumerated instead. Anything not listed below is
# container-local: writable during the session, gone with the
# container. That is the safer default - a config file a future Claude
# Code version adds simply doesn't persist, instead of sitting
# unprotected on a writable mount - but it does mean new state that
# *should* persist has to be added here deliberately.
# Read-write below, and all directories: a directory mount can't
# detach that way, because a rename replaces an entry within it rather
# than the mount point itself.
#
# Runtime state that has to survive the session, read-write. All
# directories, which is what makes them safe: a directory bind mount
# can't detach the way a file one does, because a rename replaces an
# entry *within* the mounted directory rather than the mount point.
# projects/ is what host-side `claude --resume` reads (and what
# log-shipper.sh tails); the rest is Claude Code's edit-undo record
# and the task/plan state skills write.
- ${HOME}/.claude/projects:${HOME}/.claude/projects
- ${HOME}/.claude/file-history:${HOME}/.claude/file-history
# projects/ and file-history/ are absent on purpose - the trees are
# every project's memory and every project's file contents, and a
# write into either reaches sessions elsewhere (#104). bin/dejima
# mounts this workspace's own entries, one -v each; see
# bin/dejima-slug-lib.sh for how that set is chosen.
- ${HOME}/.claude/backups:${HOME}/.claude/backups
- ${HOME}/.claude/tasks:${HOME}/.claude/tasks
- ${HOME}/.claude/plans:${HOME}/.claude/plans
@@ -170,7 +170,7 @@ services:
- ${HOME}/.claude/CLAUDE.md:/etc/dejima-claude/CLAUDE.md:ro
# The sandbox's own settings overrides (sandbox.enabled off, plus the
# remote-access notify hooks when that's on), generated per run by
# bin/dejima and reaching Claude Code as claude-wrapper.sh's
# bin/dejima and reaching the harness as harness-wrapper.sh's
# --settings argument. A container-local path, like the staged config
# above and for the same reason (#47) - and separate from that staging
# directory because this file is generated per run rather than copied
@@ -233,6 +233,18 @@ services:
volumes:
claude-code-bashhistory:
# Named explicitly so bin/dejima can give each project its own set. These
# persist across containers by design (a plugin install should not have to
# happen every launch), which without the split meant one project's session
# could leave a Lua file or a mason/bin binary that another project's
# session executes the next time nvim runs. The service still refers to the
# static keys; only the volume Docker actually creates varies.
#
# The defaults reproduce the previous names exactly, so a `docker compose`
# invocation that bypasses bin/dejima behaves as it did.
nvim-data:
name: ${SANDBOX_NVIM_DATA:-dejima_nvim-data}
nvim-state:
name: ${SANDBOX_NVIM_STATE:-dejima_nvim-state}
nvim-cache:
name: ${SANDBOX_NVIM_CACHE:-dejima_nvim-cache}
+54 -6
View File
@@ -103,6 +103,30 @@ if [ -s "$SANDBOX_SSH_KEY_SRC" ]; then
export SSH_AUTH_SOCK
fi
# Put the harness wrapper on PATH under the harness's own command name,
# ahead of the real binary, so every invocation gets the no-confirm flags -
# including the ones dejima never sees, an interactive shell or an editor
# plugin's terminal. Linked here rather than COPYed at build time because
# the image is shared across harnesses and the selection is per launch.
#
# Root-owned in a root-owned directory: the sandbox user must not be able to
# replace the wrapper with something that drops the flags, or reinstate the
# real binary's name ahead of it.
#
# Skipped when SANDBOX_HARNESS_COMMAND is unset, which is a container started
# without bin/dejima (test/runtime.bats). Refused when it is set to anything
# but a bare command name: it becomes a path under /usr/local/bin, and a
# value with a slash in it would write somewhere else entirely.
if [ -n "${SANDBOX_HARNESS_COMMAND:-}" ]; then
case "$SANDBOX_HARNESS_COMMAND" in
*/*|.*|'')
echo "entrypoint: refusing harness command name: $SANDBOX_HARNESS_COMMAND" >&2
exit 1 ;;
esac
ln -sf /usr/local/bin/dejima-harness-wrapper \
"/usr/local/bin/$SANDBOX_HARNESS_COMMAND"
fi
# ~/.claude config files arrive staged read-only under /etc/dejima-claude
# (see the mount inventory in docker-compose.yml) and get copied into place
# here rather than bind-mounted at their real paths. A copy can't be
@@ -252,6 +276,32 @@ fi
[ -n "${ANTHROPIC_BASE_URL:-}" ] || unset ANTHROPIC_BASE_URL
[ -n "${ANTHROPIC_AUTH_TOKEN:-}" ] || unset ANTHROPIC_AUTH_TOKEN
# Unset is the ordinary case (remote access is opt-in) and says nothing.
# Set-but-unusable is worth a startup line because of how it presents: the
# marker directory's source is created per launch and removed on exit, so
# restarting the container rather than relaunching `dejima` leaves it gone,
# and Docker answers a missing bind source by creating one root-owned. The
# hook's `[ -d ]` guard then passes and every notification fails EACCES,
# while the session is also missing from `dejima-attach` because its registry
# entry went with the same directory - three unrelated-looking symptoms.
#
# Reported, not repaired: chowning would restore notifications while the
# session stayed absent from the registry, looking fixed with half the
# feature broken. Relaunching is the remedy, and the message can say so.
if [ -n "${DEJIMA_NOTIFY_DIR:-}" ]; then
if [ ! -d "$DEJIMA_NOTIFY_DIR" ]; then
echo "WARNING: remote access is enabled, but $DEJIMA_NOTIFY_DIR is not a directory." >&2
echo "Notifications will not be delivered for this session." >&2
elif ! gosu "$SANDBOX_USER" test -w "$DEJIMA_NOTIFY_DIR"; then
echo "WARNING: remote access is enabled, but $DEJIMA_NOTIFY_DIR is not writable by" >&2
echo "$SANDBOX_USER - so notifications will fail, and this session will not appear in" >&2
echo "dejima-attach. This is what a container that was RESTARTED rather than" >&2
echo "relaunched looks like: bin/dejima creates the marker directory per launch and" >&2
echo "removes it on exit, and Docker recreated the missing bind source as root." >&2
echo "Stop this container and start a new session with 'dejima' to restore both." >&2
fi
fi
# Test harness hook (test/): everything above this point is the real
# startup sequence - DNS proxy, firewall, IPv6 block, logging,
# privilege-drop prep. Skip execing $SANDBOX_CMD so the suite can `docker
@@ -266,10 +316,8 @@ fi
# sudo/setuid path back to root after this, so it can't re-run
# init-firewall.sh itself to widen the allowlist mid-session.
#
# SANDBOX_CMD is what actually runs as that user - defaults to bare `claude`
# (the wrapped binary on PATH, see claude-wrapper.sh) so a fresh clone of
# this repo works with no editor setup at all. Override it (e.g. to `nvim`)
# for a custom editor-driven workflow; pair with SANDBOX_NVIM_CONFIG (see
# bin/dejima) to bring in a real config for it instead of running
# unconfigured.
# SANDBOX_CMD is what actually runs as that user - the selected harness's
# own command unless [core] command overrides it (e.g. to `nvim`) for an
# editor-driven workflow; pair that with SANDBOX_NVIM_CONFIG (see bin/dejima)
# to bring in a real config for it instead of running unconfigured.
exec gosu "$SANDBOX_USER" ${SANDBOX_CMD:-claude} "$@"
+25
View File
@@ -46,3 +46,28 @@ GITHUB_DNS_SUFFIXES=(
"githubusercontent.com"
"githubassets.com"
)
# Which ipset an allowlisted name's addresses belong in.
#
# GitHub's are restricted to port 443 by init-firewall.sh, and that
# restriction IS the read-only guarantee: no transport is left for a
# forwarded agent key to push with. allowed-domains has no port restriction,
# so a GitHub address landing there silently undoes the arrangement.
#
# dnsmasq applies the longest matching ipset= line, so dns-proxy.sh gets this
# right for free. Every other path has to ask, and two did not - the
# extra-domains loop and bin/dejima-allow's live add both appended
# unconditionally. A project declaring github.com therefore opened port 22 to
# whatever address was cached at container start, presenting as an
# intermittent startup failure because the self-check re-resolves and only
# sees the hole when the second answer matches the first.
ipset_for_domain() {
local domain="$1" suffix
for suffix in "${GITHUB_DNS_SUFFIXES[@]}"; do
if [ "$domain" = "$suffix" ] || [[ "$domain" == *".$suffix" ]]; then
printf 'github-domains'
return 0
fi
done
printf 'allowed-domains'
}
+51
View File
@@ -0,0 +1,51 @@
#!/bin/bash
# Shadows the selected harness's real binary on PATH, so every invocation -
# the entrypoint's, an interactive shell's, an editor plugin's terminal -
# runs with the flags dejima requires, without any caller needing to know
# that.
#
# Installed by entrypoint.sh under the harness's own command name rather than
# COPYed to a fixed one at build time: the image is shared across harnesses
# and the selection is per launch, so which name to shadow is only known once
# the container starts.
#
# Every value here comes from the selected harness's manifest, resolved on
# the host (see bin/dejima) and passed in as environment. Nothing about any
# one harness is written down in this file.
#
# SANDBOX_HARNESS_REAL the real binary this shadows
# SANDBOX_HARNESS_ARGS the no-confirm flags, without which the agent
# stops to ask and the arrangement becomes a
# worse way of running it on the host
# SANDBOX_HARNESS_SETTINGS how this harness is told to read dejima's own
# settings layer, if it reads one at all
#
# The settings layer arrives as an extra layer rather than as a mount over
# the harness's own config file (docker-compose.yml, #47). That only holds if
# every entry point passes it, which is what shadowing the binary buys.
# /etc/dejima-settings.json is absent when a container is started without
# bin/dejima, as test/runtime.bats does, so its flag stays conditional on the
# file rather than on the manifest key alone.
#
# Word-split deliberately: both carry flags, which have no spaces, and an
# array is what exec needs. A harness whose flags need quoting has outgrown
# this and should say so in its plugin.
set -u
if [ -z "${SANDBOX_HARNESS_REAL:-}" ] || [ ! -x "${SANDBOX_HARNESS_REAL:-}" ]; then
echo "dejima: no harness binary at '${SANDBOX_HARNESS_REAL:-<unset>}'." >&2
echo "dejima: command_path in the harness manifest is what names it." >&2
exit 127
fi
args=()
# shellcheck disable=SC2206
[ -n "${SANDBOX_HARNESS_ARGS:-}" ] && args=(${SANDBOX_HARNESS_ARGS})
settings=()
# shellcheck disable=SC2206
[ -n "${SANDBOX_HARNESS_SETTINGS:-}" ] && [ -f /etc/dejima-settings.json ] \
&& settings=(${SANDBOX_HARNESS_SETTINGS})
exec "$SANDBOX_HARNESS_REAL" \
${args[@]+"${args[@]}"} ${settings[@]+"${settings[@]}"} "$@"
+75 -21
View File
@@ -11,23 +11,17 @@
set -Eeuo pipefail
IFS=$'\n\t'
# Nothing in here may die quietly. Under `set -e` a failing command aborts the
# script with no output at all, and this script's failures are invisible in the
# worst possible way: the container simply never appears, which reads as the
# sandbox being broken rather than as one command in one loop having failed.
# Three CI failures were spent on two different instances of exactly that (#59)
# - one at the GitHub ranges fetch, one in the domain loop below - and neither
# left a single line to go on.
# Nothing here may die quietly. Under `set -e` a failure aborts with no output
# and the container simply never appears, which reads as the sandbox being
# broken rather than one command failing. Three CI failures went to two
# instances of exactly that (#59), neither leaving a line to go on.
#
# The explicit `exit 1` paths below say more than this can and are worth
# keeping; this is the floor under everything else. -E on the line above is
# what extends it into functions and subshells, where it would otherwise not
# fire at all.
# This is the floor; the explicit `exit 1` paths below say more. -E extends it
# into functions and subshells, where it would not fire at all.
#
# A failure inside a command substitution reports twice - once from the
# subshell, naming the command that actually failed, and once from the
# statement containing it. That is worth the noise: for `x=$(a | b)` the first
# line is the only thing that says whether it was `a` or `b`.
# A failure inside a command substitution reports twice, from the subshell and
# from the containing statement. Worth the noise: for `x=$(a | b)` the first
# line is the only thing saying whether it was `a` or `b`.
trap 'status=$?; echo "ERROR: init-firewall.sh aborted at line $LINENO (exit $status): $BASH_COMMAND" >&2' ERR
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -76,10 +70,13 @@ else
echo "No Docker DNS rules were present to restore"
fi
# Allow localhost. DNS resolution goes entirely through Docker's embedded
# resolver at 127.0.0.11 (see resolv.conf), which stays on loopback, so this
# also covers DNS - no need for a separate "UDP/53 to anywhere" rule, which
# would just be an unrestricted-destination exfil channel.
# Allow localhost. Every DNS query this container makes starts here: to
# dnsmasq on 127.0.0.1, and from there to Docker's embedded resolver on
# 127.0.0.11 (see resolv.conf). What this rule does NOT cover is the hop
# after that, where the embedded resolver forwards an external name to the
# host's nameservers - see the owner-matched rule further down, which is
# there because that hop can leave this namespace. Still no "UDP/53 to
# anywhere" rule, which would be an unrestricted-destination exfil channel.
iptables -A INPUT -i lo -j ACCEPT
iptables -A OUTPUT -o lo -j ACCEPT
@@ -269,13 +266,17 @@ if [ -n "${EXTRA_ALLOWED_DOMAINS:-}" ]; then
continue
fi
# Not always allowed-domains: that set carries no port restriction,
# so a GitHub address landing in it is reachable on 22 as well and the
# read-only guarantee is gone. See ipset_for_domain.
target_set=$(ipset_for_domain "$domain")
while read -r ip; do
if [[ ! "$ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
echo "WARNING: Invalid IP from DNS for $domain: $ip, skipping"
continue
fi
echo "Adding $ip for $domain"
ipset add allowed-domains "$ip" 2>/dev/null || true
echo "Adding $ip for $domain (-> $target_set)"
ipset add "$target_set" "$ip" 2>/dev/null || true
done < <(echo "$ips")
done
fi
@@ -305,6 +306,32 @@ iptables -P OUTPUT DROP
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
# Docker's embedded resolver forwards external names to the host's
# nameservers, and on some versions does it from inside THIS namespace. Those
# queries then traverse the chain below and match nothing - the host's
# resolver is neither in an ipset nor the bridge gateway - so they hit the
# catch-all REJECT and every lookup SERVFAILs. Only the first hop stays on
# loopback. Two hosts with byte-identical /etc/resolv.conf behaved
# differently, so this is version-dependent and cannot be assumed either way.
#
# --uid-owner 0 keeps this from being the hole the DNS allowlist closes: the
# forwarding socket is dockerd's (root), the session is `node` with no sudo,
# so it still cannot address a resolver directly. This newly permits DNS only
# from processes already root in a NET_ADMIN container, which could flush the
# ruleset anyway.
#
# Guarded, not asserted: xt_owner is a module, and a kernel without it should
# leave a container that says why (the DNS self-check below) rather than one
# that aborts here.
if iptables -A OUTPUT -p udp --dport 53 -m owner --uid-owner 0 -j ACCEPT 2>/dev/null; then
iptables -A OUTPUT -p tcp --dport 53 -m owner --uid-owner 0 -j ACCEPT 2>/dev/null \
|| echo "WARNING: could not add the TCP/53 rule for root-owned sockets - large answers may fail" >&2
else
echo "WARNING: the iptables owner match is unavailable, so Docker's embedded" >&2
echo "resolver cannot forward out of this namespace. If DNS works anyway this" >&2
echo "host forwards elsewhere and nothing is wrong; the self-check below says which." >&2
fi
# Then allow only specific outbound traffic to allowed domains. This isn't
# port-restricted, so it also covers git-over-ssh (port 22) to the
# configured forge's hosts, which are already in the ipset above - no
@@ -340,6 +367,33 @@ iptables -A OUTPUT -j NFLOG --nflog-group 2 --nflog-prefix "reject"
iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited
echo "Firewall rules applied. Self-checking..."
# DNS before either connectivity check, because both of those resolve a name
# before they connect and neither can tell you that resolution is what failed.
# The negative control is the trap: with DNS dead it fails for the wrong
# reason and prints "example.com is unreachable, as it should be", so a
# container with no working DNS at all reports a passing check and then dies
# on the positive control naming a host rather than the cause. Reading that
# output backwards took an afternoon; it should take one line.
if ! dns_probe=$(dig +short +time=3 +tries=2 A api.github.com 2>&1) || [ -z "$dns_probe" ]; then
echo "ERROR: self-check failed - DNS does not resolve inside the sandbox." >&2
echo >&2
echo "The path is: this container -> dnsmasq on 127.0.0.1 (dns-proxy.sh) ->" >&2
echo "Docker's embedded resolver on 127.0.0.11 -> the host's nameservers." >&2
echo "Only the first two hops are on loopback. If Docker forwards the last" >&2
echo "one from inside this namespace, it traverses the OUTPUT chain, matches" >&2
echo "nothing, and is rejected - which reads as SERVFAIL, not as a firewall." >&2
echo >&2
echo " dig said: ${dns_probe:-(empty answer)}" >&2
echo " packets taken by the catch-all OUTPUT reject so far:" >&2
iptables -L OUTPUT -vn 2>/dev/null \
| awk '/icmp-admin-prohibited/ {print " " $1 " packets, " $2 " bytes"}' >&2
echo " dnsmasq's last words:" >&2
tail -5 /var/log/dnsmasq.log 2>/dev/null | sed 's/^/ /' >&2
exit 1
fi
echo "self-check ok - DNS resolves an allowlisted name"
# --max-time as well as --connect-timeout on both self-checks: a connect that
# succeeds and then stalls is not covered by --connect-timeout, and while these
# two sit inside `if` (so set -e cannot take the script down the way it could
+90 -82
View File
@@ -6,20 +6,20 @@
CI runs.** That is the whole of it, and everything below is either why it
cannot be gated or what to do about it instead.
> **This document is the why.** For the how, [dejima-ci-hardener](https://gitbox.branderson.io/branderson/dejima-ci-hardener)
> is a self-contained tool that does it — installed on the CI box, never on a
> **This document is the why.** For the how, [dejima-ci-hardener](https://gitbox.branderson.io/dejima/dejima-ci-hardener)
> is a self-contained tool that does it, installed on the CI box, never on a
> dejima host, and it does not assume you have read this.
The chain needs no operator action at any step. A session writes code; the
push guard permits a feature branch, which is what it exists to allow; the
session opens a pull request, which its briefing tells it to do without asking
first; `on: pull_request` fires and the runner checks out that commit and runs
it. The code does not have to be a modified workflow file to be executed — a
it. The code does not have to be a modified workflow file to be executed: a
workflow that runs a test suite is already running every file in it, and a
plausible-looking helper in a test file is a much quieter carrier than an
edited workflow, which shows up in a diff.
The gate this sandbox otherwise relies on — you merge, the agent does not —
The gate this sandbox otherwise relies on (you merge, the agent does not)
sits strictly downstream of that. By the time anyone reads the diff, the code
has run.
@@ -30,7 +30,7 @@ elsewhere.
**And it applies to projects with no CI at all.** A branch can add the first
workflow, so what matters is not what a repository's workflows do today but
which runners its branches can reach — which is a property of the forge, not
which runners its branches can reach, which is a property of the forge, not
of the project. If you read one section of this document before pointing a
sandbox at something, make it
[what a repository can reach](#closing-the-rest-what-a-repository-can-reach);
@@ -43,11 +43,11 @@ The code CI executes is code the session already had. Relative to the sandbox,
a run adds three things, and every control in this document maps onto one of
them:
1. **Execution on a host you own** — outside the container, outside the
1. **Execution on a host you own**: outside the container, outside the
allowlist, with whatever that host can reach.
2. **Egress from a network position you own** — no dejima firewall, no DNS
2. **Egress from a network position you own**: no dejima firewall, no DNS
proxy, ordinary networking.
3. **Credentials the run is handed** — the secrets you configured, plus the
3. **Credentials the run is handed**: the secrets you configured, plus the
token the forge injects into every job whether the workflow asks for one or
not.
@@ -61,7 +61,7 @@ The obvious answer is to require a human to approve a run. Each version of
that turns out to be reachable from the branch, from a credential the session
already holds, or to be no gate at all:
- **Changing the trigger** — `workflow_dispatch` only, or `push` to `main`
- **Changing the trigger**: `workflow_dispatch` only, or `push` to `main`
only. The workflow file lives in the tree the session controls, and on a
`pull_request` run it is read from the head of the pull request.
- **Triggering by hand.** A forge token that can open a pull request generally
@@ -84,7 +84,7 @@ the run. Everything here bounds what the run can do.
## Before anything else: protect the branch
Every control below that is worth anything roots its trust in `main` — most
Every control below that is worth anything roots its trust in `main`: most
directly the egress allowlist, which is read from there. A job token with
write access to the repository can push to an unprotected branch, so without
this the untrusted party can edit the thing that is supposed to constrain it.
@@ -98,6 +98,20 @@ this the untrusted party can edit the thing that is supposed to constrain it.
This costs nothing and it is the single highest-value item in this document.
**Branch protection needs a second identity to protect anything.** By default
a session authenticates as you, so a merge whitelist, a push whitelist and an
approval requirement all admit the sandbox exactly as they admit you, and
required approvals are unusable outright, since a forge will not let a pull
request's author approve it and the session opens them as you. Giving the
sandbox its own forge account is what turns those settings from decoration
into enforcement, and it is configuration rather than a missing feature:
[docs/security.md](security.md#to-your-forge-the-sandbox-is-you).
Note that it does not help with the rest of this document. A write-capable
account still pushes feature branches, which is what makes CI run code, and
the token the forge injects into a job is a third principal subject to none of
those settings.
## The runner host
Treat it as what it is: a machine that exists to run untrusted code, because
@@ -116,13 +130,13 @@ that is what it does on every pull request.
an unrelated repository cannot schedule work onto a runner you hardened for
this one, *and* this repository cannot schedule work onto a runner nobody
hardened. An instance-level runner is reachable from every repository on the
instance — see "A label is not a boundary" below. Gitea's own FAQ describes
instance; see "A label is not a boundary" below. Gitea's own FAQ describes
gitea.com doing this for its public runners.
Two runners on one VM give you label tiering, not host separation: a job that
cannot get the Docker socket still shares a kernel and a network with a runner
that can. If you only have one machine, that is a real reduction and worth
doing — just do not read it as isolation between the tiers.
doing; just do not read it as isolation between the tiers.
## Job containers
@@ -135,7 +149,7 @@ configuration says so:
> but the docker host won't be mounted to the job containers and service
> containers.
Access to that socket is root-equivalent on the runner host — `docker run
Access to that socket is root-equivalent on the runner host: `docker run
-v /:/host` ends the argument. Nothing in a job's log mentions the socket
being there, so this is not something a passing look at a green run will
surface. Check it directly, per runner:
@@ -162,7 +176,7 @@ dejima-ci-hardener ships both configs, with the reasoning inline.
**Prefer a new runner over reconfiguring an existing one.** A general-purpose
runner often exists precisely to build images, and taking its socket away
breaks it. Register a second runner with a label of its own, and name that
label in the workflow — and if no runner carries the label yet, the job queues
label in the workflow, and if no runner carries the label yet, the job queues
rather than running somewhere unprepared, which is a better failure than a
green run on the wrong host.
@@ -174,10 +188,10 @@ matters enough to state on its own.
**`runs-on:` is in the workflow file, and the workflow file is in the branch.**
A branch can name any label it likes. So a dedicated label keeps *honest* jobs
on the runner you prepared; it does nothing against a branch that would rather
run somewhere else. A session that changes `runs-on: dejima-ci` to
run somewhere else. A session that changes `runs-on: dejima-confined` to
`runs-on: ubuntu-latest`, pushes, and opens a pull request has stepped outside
every control on this page — no allowlist, no socket restriction, no firewall
— provided some runner answering `ubuntu-latest` will take the job.
every control on this page (no allowlist, no socket restriction, no firewall),
provided some runner answering `ubuntu-latest` will take the job.
Whether one will is a property of **where your runners are registered**, which
is the actual boundary:
@@ -188,7 +202,7 @@ is the actual boundary:
| Organization | that organization's repositories |
| Repository | that repository |
Gitea has no per-repository runner ACL — its FAQ says so directly, and points
Gitea has no per-repository runner ACL; its FAQ says so directly, and points
at registration scope as the control instead: gitea.com registers its runners
for one organization "so our runners will not execute jobs from other
repositories."
@@ -207,23 +221,23 @@ Under `on: pull_request` the workflow is read from the head of the pull
request, so the branch decides what runs and where. Under
`on: pull_request_target` Gitea reads it from the **base branch**, and
explicitly discards any `pull_request_target` workflow found on the head
commit — a branch can neither edit the base copy nor introduce its own. What
commit: a branch can neither edit the base copy nor introduce its own. What
runs, and on which runner, is whatever `main` says.
**Adopting it takes two merges, not one.** The trigger only takes effect once
it is on the base branch, because that is where it is read from — and a
it is on the base branch, because that is where it is read from, and a
`pull_request_target` workflow on a pull request's head is explicitly ignored.
So the change that introduces it cannot be tested by it: with only
`pull_request_target` on the branch and only `pull_request` on `main`, the
head's copy is skipped, the base has nothing to contribute, and **no checks
run at all** — which looks like a broken workflow rather than a bootstrap
run at all**, which looks like a broken workflow rather than a bootstrap
problem.
Carry both triggers through the first merge, then remove `pull_request` in a
follow-up. The first change is tested by its own copy from the head, as
today; the follow-up is tested by the copy now on `main`. In between, pull
requests get both an untrusted run from the head and a trusted one from the
base — noisy, and for the untrusted half no worse than the status quo, so
base, noisy, and for the untrusted half no worse than the status quo, so
keep the window short.
Two more consequences to handle rather than discover:
@@ -244,7 +258,7 @@ This does not make the repository safe on its own. A branch can still add a
workflow of its own with `on: pull_request`, which runs from the head with
whatever `runs-on` it likes. What it buys is that the *intended* CI is
trustworthy, and that escaping it means adding a file rather than editing
one — a much louder diff.
one: a much louder diff.
### Closing the rest: what a repository can reach
@@ -253,50 +267,44 @@ The rule underneath everything here:
> **Every runner reachable from a repository that receives agent-authored
> branches must be one you would accept an attacker running code on.**
Note what that does *not* say. It says nothing about what the repository's
workflows currently do, because a branch can add the first one. **A project
with no CI at all is still exposed**, on a forge where its branches can reach
a runner — and that is the case people do not expect, because there is nothing
in the repository to look at.
Note what that does *not* say: nothing about what the workflows currently do,
because a branch can add the first one. **A project with no CI at all is still
exposed** on a forge where its branches can reach a runner - the case people
do not expect, because there is nothing in the repository to look at.
So before pointing a sandbox at a project, ask one question: *what runners can
So ask one question before pointing a sandbox at a project: *what runners can
this repository's branches reach?*
**If the answer is "hosted runners only"** — GitHub-hosted and nothing
self-hosted attached — this is the mildest case, and it is worth saying so
rather than alarming people. The runner is disposable and is not your machine,
so "execution on a host you own" largely evaporates: there is no LAN behind
it and nothing of yours on it. What remains is the third item, credentials:
give pull-request runs no secrets they do not need and a read-only token
(`permissions: contents: read`), and do not attach a self-hosted runner to
that repository.
**"Hosted runners only"** is the mildest case. The runner is disposable and is
not your machine, so "execution on a host you own" largely evaporates - no LAN
behind it, nothing of yours on it. What remains is credentials: give
pull-request runs no secrets they do not need and a read-only token
(`permissions: contents: read`), and attach no self-hosted runner.
**If self-hosted runners are reachable**, that is the sharp case, and there are
three ways to hold the rule — none of which require moving or renaming
anything.
**If self-hosted runners are reachable**, that is the sharp case. Three ways
to hold the rule, none of which require moving or renaming anything.
**1. Turn CI off for that repository, if it does not use CI.** Disabling the
Actions unit means no workflow runs for that repository at all, so no runner is
reachable and a branch cannot add the first workflow. Changing it back needs
*admin* on the repository — Gitea's API puts repository settings behind
`reqAdmin()` — so a bot account with Write cannot undo it. For a project that
was not using CI anyway this costs nothing and closes the vector outright. It
is the first thing to reach for and the most commonly overlooked.
**1. Turn CI off for that repository, if it does not use CI.** No workflow
runs at all, so no runner is reachable and a branch cannot add the first
workflow. Turning it back on needs *admin* (Gitea puts repository settings
behind `reqAdmin()`), so a bot account with Write cannot undo it. For a
project not using CI this costs nothing, and it is the most commonly
overlooked option.
**2. Scope the dangerous runner down, rather than moving the repository out.**
The instinct is to move agent-developed repositories somewhere with only
hardened runners. For a public project that means breaking its name, its
links and its ownership, which nobody should be asked to do.
hardened runners, which for a public project breaks its name, links and
ownership.
Invert it. A runner can belong to an **owner** — a user as well as an
organization — and then serves that owner's repositories; Gitea's runner model
names the three cases: global (`OwnerID` and `RepoID` both zero), org/user
level (`OwnerID` set), repository level (`RepoID` set). So:
Invert it. A runner can belong to an **owner** - a user as well as an
organization - and then serves that owner's repositories. Gitea's model has
three cases: global (`OwnerID` and `RepoID` zero), org/user (`OwnerID` set),
repository (`RepoID` set). So:
- Make the *hardened* runner the owner-level default, so every repository gets
it.
- Register the *permissive* runner — the one with the Docker socket, or with
unrestricted egress — at **repository** scope, for only the repositories
- Register the *permissive* runner (the one with the Docker socket, or with
unrestricted egress) at **repository** scope, for only the repositories
that genuinely need it.
Most repositories need a runner that can run tests, not one that can build
@@ -307,18 +315,18 @@ handful of registrations, and nothing moves or is renamed.
`[core] workflow = local-only`. The session commits locally and stops; you
review the diff and push it yourself. No branch, no workflow, no runner, and
the vector is closed completely rather than bounded. The cost is one push
after the review you were going to do anyway — and it is the only option here
after the review you were going to do anyway, and it is the only option here
that does not depend on getting a forge's configuration right.
If none of the three is available for a given project, that is worth knowing
plainly: pointing a sandbox at it means accepting that a compromised session
can run code on whatever that repository can reach.
If none of the three is available for a given project, say so plainly:
pointing a sandbox at it means accepting that a compromised session can run
code on whatever that repository can reach.
## Egress
The goal is that a job cannot reach somewhere new — cannot fetch a next stage,
cannot post a repository to a paste service, cannot probe an internal address
— without a human having agreed to that destination first.
The goal is that a job cannot reach somewhere new (cannot fetch a next stage,
cannot post a repository to a paste service, cannot probe an internal address)
without a human having agreed to that destination first.
### Four network positions, only one of which is the job
@@ -334,7 +342,7 @@ protects nothing:
**Filtering only `DOCKER-USER` leaves the DNS allowlist decorative.** Traffic
to a resolver on the bridge gateway is addressed to the host, so it is an
`INPUT` packet and never passes through `DOCKER-USER` at all — and a job that
`INPUT` packet and never passes through `DOCKER-USER` at all, and a job that
can reach any resolver it likes can resolve anything it likes. `INPUT` needs
its own pair of rules: accept port 53 from the bridge, drop the rest.
@@ -354,7 +362,7 @@ bridge subnet is one that check silently does not cover.
With `runner.capacity: 1` one job occupies that network at a time. Above one,
concurrent jobs share a broadcast domain and can reach each other's service
containers, which is worth knowing before raising it.
containers. Check that before raising it.
The DNS and firewall shape is the same one dejima uses inside its own
container (`docker/dns-proxy.sh` and `docker/init-firewall.sh`), applied one
@@ -362,7 +370,7 @@ layer out: allowlist the *names* and let the answers populate the set of
allowed *addresses*.
`dnsmasq` binds the bridge gateway. With `no-resolv` and one `server=` line
per allowed name, every other name gets `REFUSED` — not resolved-then-blocked,
per allowed name, every other name gets `REFUSED`, not resolved-then-blocked,
refused, which closes off encoding data into query names to a domain you never
allowed. `ipset=/#/ci-allow` adds every answer it serves to the firewall's
set, so a destination that rotates its edge addresses stays reachable without
@@ -376,19 +384,20 @@ own and no route to it. Then two chains filter: `DOCKER-USER` for everything
the job forwards off the bridge, and `INPUT` for the resolver itself, because
the gateway address belongs to the host.
Block IPv6 outright unless you have built the same allowlist for it — an
Block IPv6 outright unless you have built the same allowlist for it: an
address family with no allowlist is not a smaller hole than the one you just
closed.
**The working configuration for all of this is in [`ci/`](../ci/), and
[`ci/README.md`](../ci/README.md) is the step-by-step.** It is deliberately not
reproduced here: two copies of a firewall rule drift, and the one in `ci/` is
the one with the ordering constraints and the substitution notes attached.
**The working configuration for all of this is in
[dejima-ci-hardener](https://gitbox.branderson.io/dejima/dejima-ci-hardener),
with the ordering constraints and substitution notes attached.** It is
deliberately not reproduced here: two copies of a firewall rule drift, and
dejima-ci-hardener is the one that stays runnable.
## The allowlist comes from `main`, not from the checkout
A dejima project already carries a version-controlled statement of where it is
allowed to reach: `.dejima/allowed-domains`. Use it — read from
allowed to reach: `.dejima/allowed-domains`. Use it: read from
**`origin/main`**, over the forge's API, by the runner host.
Never from the checkout. The checkout is the branch's, and a branch that can
@@ -408,7 +417,7 @@ has to get that destination past you in a pull request first.
Refresh on a timer, render the `server=` lines, reload `dnsmasq`. Fail closed:
a fetch that errors should leave the previous allowlist in place, never fall
back to allowing everything — an allowlist builder is exactly the kind of
back to allowing everything: an allowlist builder is exactly the kind of
script whose failure mode is easy to get backwards, because the broken version
looks like a working one right up until it matters.
dejima-ci-hardener's `dejima-ci-allowlist` is that, including the distinction
@@ -439,7 +448,7 @@ later you can set the repository's default to restricted mode (Repository →
Settings → Actions → General): read-only on `code`, `releases` and
`packages`, nothing else. The property that makes it worth having is that
workflow-level `permissions:` is *clamped* by that maximum, so a branch
raising its own cannot exceed it — it is one of the very few controls here
raising its own cannot exceed it: it is one of the very few controls here
that the branch genuinely cannot touch.
Before 1.27, `permissions:` is ignored and the token is permissive. There is
@@ -449,7 +458,7 @@ upgrade.
**Secrets.** A secret exposed to a pull-request-triggered run is a secret
exposed to whatever that branch contains. Give a run only what it needs, and
prefer credentials that cannot do anything else — a token that raises an
prefer credentials that cannot do anything else: a token that raises an
anonymous API rate limit should be a fine-grained token with no permissions
and no repository access, not a general-purpose one.
@@ -460,7 +469,7 @@ earning its place.
## CI you do not administer
On hosted CI — GitHub Actions, a shared pool, someone else's infrastructure —
On hosted CI (GitHub Actions, a shared pool, someone else's infrastructure),
**there is no reliable pre-execution gate for a same-repository branch**, and
the egress section above is unavailable to you: every candidate gate lives in
a file the branch can edit, and the network the job runs on is not yours to
@@ -471,8 +480,8 @@ What still applies:
- **A hosted runner gives you the runner-host section for free.** It is
disposable and it is already off your network. The dangerous case is a
*self-hosted shared pool*, where a job can land on a machine nobody hardened
— and where the socket-mounting default above is nobody's decision, just the
*self-hosted shared pool*, where a job can land on a machine nobody hardened,
and where the socket-mounting default above is nobody's decision, just the
default.
- **The credentials section applies everywhere.** `permissions: contents:
read` where it is honoured, no secrets on pull-request runs, and no tunnel
@@ -484,10 +493,9 @@ What still applies:
session commits locally and stops. The push grant *is* the CI
code-execution grant; declining the first declines the second.
Gitea 1.27's **scoped workflows** are worth knowing about here: a workflow
maintained in a central repository the sandbox cannot push to, registerable
instance-wide, so the workflow file itself sits outside any agent branch's
reach. Use it — but it is not a gate. Scoped workflows run *alongside* a
Gitea 1.27's **scoped workflows** apply here: a workflow maintained in a
central repository the sandbox cannot push to, registerable instance-wide,
so the workflow file itself sits outside any agent branch's reach. Use it, but it is not a gate. Scoped workflows run *alongside* a
consuming repository's own workflows rather than replacing them, so a branch
can still add a workflow of its own.
@@ -501,7 +509,7 @@ can still add a workflow of its own.
not from what it currently runs. Disabling the Actions unit is what makes
"this project has no CI" into a property rather than a coincidence.
- **An allowed destination is still an exfiltration channel** through its own
request surface — a query string, a lookup, an issue body. Domain
request surface: a query string, a lookup, an issue body. Domain
allowlisting closes off reaching somewhere *new*; it cannot inspect what is
sent somewhere already trusted. That is a structural property of
allowlist-only filtering, and it is the same caveat
+524
View File
@@ -0,0 +1,524 @@
# Configuration
[← dejima README](../README.md)
Day-to-day configuration once dejima is installed and running: what a
project declares versus what you approve, config parity with your host agent
harness setup, a different editor, writable host directories, shared
scratch space, frontend testing, and what the sandbox knows about itself.
## Configuration reference
Every key dejima reads, its default, and what it is for. All of it lives in
one file, `~/.config/dejima/config`. `./install.sh` writes a starter copy with
every key present and commented out at its default, so nothing here has to be
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.
| 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 |
| `[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] 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] firewall` | `on` | egress firewall; `off` disables it (debugging only), also settable per launch with `--no-firewall` |
| `[core] share_dir` | `~/.local/share/dejima/shared` | host-visible read/write scratch dir, mounted at `~/shared` |
| `[core] ssh_key` | unset | dedicated deploy key for headless/unattended git push |
| `[core] known_hosts` | `~/.ssh/known_hosts` | known-hosts file used for git-over-ssh |
| `[harness.claude-code] projects_dir` | `~/.claude/projects` | where Claude Code session transcripts live |
| `[harness.claude-code] file_history_dir` | `~/.claude/file-history` | Claude Code's edit-undo record, mounted per session rather than whole |
| `[harness.claude-code] statusline_path` | `~/.claude/statusline.sh` | statusline script path |
| `[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) |
| `[forge.github] push_token_file` | unset | path to a **write-capable** PAT, used only when `[core] forge = github`: deliberately separate from `[github] token_file` below; see "GitHub push" in docs/security.md |
| `[github] token_file` | `~/.config/gh/sandbox-token` | path to a read-only PAT for private GitHub repos / `gh` |
| `[mounts] readonly` | unset | extra host paths (colon-separated) a personal config needs, mounted read-only at their own path |
| `[mounts] read_write` | unset | host paths (colon-separated) mounted **read-write** at their own path: for directories meant to outlive the session; refused if they overlap anything dejima needs out of a session's reach |
| `[mounts] nvim_config` | unset | nvim config dir, used when `[core] command = nvim` |
| `[credentials] proxy` | `on` | host-side credential proxy; `off` mounts the real OAuth credential into the container directly, as before this feature |
| `[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 |
| `[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 |
| `[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 |
| `[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] debounce` | `60` | seconds between repeat notifications for the same session |
**No forge is the default**: `[core] forge = none` with
`[core] workflow = local-only`, so the sandbox works locally and pushes
nowhere. Every other workflow needs forge credentials, which is what you are
opting into when you pick one.
## Global and per-project domains and dependencies
Allowed domains and dependencies each stack through their own layers. The
layers **add** to each other, so a narrower layer can only widen what a
broader one already permits: nothing at the project layer can withdraw a
built-in domain, and nothing at the container layer can withdraw a project
one. Ordering them least specific first is how they are listed below and how
they are applied, and it means the set a session gets is the union of every
layer that applies to it, never an override of one by another.
| Config | Layers, least specific first |
| --- | --- |
| Allowed domains | built-in → global → project → container (one session) |
| Dependencies | base image → global → project |
Dependencies have no container-only scope: installing one always needs an
image rebuild, so there's no `dejima-allow`-style live-session patch
for them.
A project can declare what it needs in two committed files:
```
.dejima/allowed-domains # one domain per line
.dejima/dependencies # <apt|npm|pip|cargo>:<name> per line, or a
# tarball:<name>@<sha256>:<url> release archive
```
### Tarball dependencies
For a toolchain no package ecosystem ships in a version you can use - a
current Go, say, where Debian's is several releases behind - a dependency can
name a release archive instead:
```sh
dejima-dep add --global tarball:go@<sha256>:https://go.dev/dl/go1.25.0.linux-amd64.tar.gz
```
Fetch the archive on the host and hash it. Do not take the digest from the
same page that serves the download unless you trust that page for both:
```sh
curl -fLO https://go.dev/dl/go1.25.0.linux-amd64.tar.gz
sha256sum go1.25.0.linux-amd64.tar.gz
```
Where a project publishes a checksums file or signs its releases, verifying
against that beats hashing whatever arrived: it says the archive is the one
the project published, not the one your connection received.
It unpacks with `--strip-components=1` into `/opt/dejima/<name>`, and
`/opt/dejima/<name>/bin` goes on `PATH` - one convention covering the usual
shapes without layout options, since a Go archive is under `go/` and an nvim
release under `nvim-linux-<arch>/`. Compression is detected, so `.tar.gz`,
`.tar.xz` and `.tar.zst` all work.
**The digest is mandatory** and is checked before anything is unpacked.
Without one you would approve a *name* whose contents the far end can change
afterwards. Two consequences:
- **A tarball dependency locks the project to one architecture**, because a
digest does. A package name resolves per-host and a digest cannot: it names
one archive, built for one architecture. There is no per-architecture
tarball spec today, so a project that declares one is a project that builds
on machines of that architecture and no others. Track that at
[#97](https://gitbox.branderson.io/dejima/dejima/issues/97).
- **Upgrading is an approval, not a version bump.** A new release is a new
digest, so it goes through `dejima-dep add` and `dejima-project approve`
again. That is the mechanism working, not friction to route around.
The URL's character set is deliberately narrower than URLs allow: the spec is
interpolated into a `RUN` line that runs as root at build time, so anything
shell-active is refused outright rather than quoted around, and only `https`
is accepted.
**Declaring grants nothing.** The project directory is bind-mounted
read-write, so a session could write these files; they are read only to
compare against reality and suggest a command. Approvals live outside the
container under `~/.config/dejima/` (`$DEJIMA_CONFIG_DIR` relocates the
tree) - global at its root, per project under `projects/<slug>/` - written
only by tools you run on the host:
```sh
dejima-project status ~/myproject # declared vs approved vs missing
dejima-project approve ~/myproject # review and approve the declared set
dejima-allow --project ~/myproject crates.io
dejima-allow --global crates.io # applies to every project
dejima-dep add --project ~/myproject cargo:cargo-nextest
dejima-dep add --global cargo:cargo-nextest
```
Two of those tools also write the declaration, so an approval you make by
hand does not stay invisible to everyone else on the project:
`dejima-allow --project` appends to `.dejima/allowed-domains`, and
`dejima-dep add --project` appends to `.dejima/dependencies`. Both write the
approval first - that is the part that grants - and report separately on the
declaration, so a project you can't write to still gets you the approval.
`--global` writes no declaration at all: there is no project to declare in.
The declaration those two write still grants nothing; it is the same
untrusted file the section above describes, and writing to it does not
promote it. That it is session-writable is exactly why both tools refuse to
write a `.dejima` directory or a `.dejima/<kind>` file that is a **symlink**.
The refusal is the `open` itself rather than a check before it, so it holds
even against a session that is still running while you type the command. See
[Declaration files are session-writable](security.md#declaration-files-are-session-writable)
for what that defends against and how. The same write
reads the file back to skip an already-declared entry, and that read is
capped - a session can make its own `.dejima/<kind>` arbitrarily large for
free, and an oversized one is refused rather than loaded into your memory.
Neither writer has an inverse for the declaration. `dejima-dep remove
--project` withdraws the approval and deliberately leaves the declaration
alone (it says so, since `dejima-project approve` will otherwise offer the
entry again); `dejima-allow --project` has no `remove` at all. Taking
something back from the project itself means editing `.dejima/` and
committing that.
`dejima-project status`/`approve` classify a declared entry against
the union of global and project approvals, so something already approved
globally shows as approved, not pending. Unlike `dejima-allow`,
`dejima-dep`'s `--global` and `--project` are mutually exclusive -
an entry belongs to exactly one scope. `dejima-dep` has no `--container`
scope either, and refuses the flag rather than ignoring it: a dependency
needs an image rebuild, so there is nothing to patch in a live session.
Approved domains join the allowlist at the next launch. `dejima-allow` can
also patch a running session immediately, but only when it is run bare or
with `--container` - approving here (`--project`/`--global`, with no
`--container`) never touches one; see
[Adding a domain to a running session](security.md#adding-a-domain-to-a-running-session).
Approved dependencies are baked into a derived image built `FROM` the shared
base - first `dejima:<username>-global`, if the global store has any
dependency approved, then `dejima:<username>-<slug>` on top of that,
if the project store does - so they need an image rebuild: they take effect
on the **next** launch.
Because of that rebuild, the usual moment to approve is *after* leaving the
sandbox - so persistence (`--global` or `--project`) never requires a running
container.
`dejima-project approve` never attempts to patch a running session: it only
ever calls `dejima-allow` without `--container`, so recording the approval
is the only thing it does. If an individual approval can't be recorded - an
unwritable store, a slug collision, a corrupt approval list - it reports
that entry as not approved and keeps going, rather than stopping at the
first failure; that holds for dependencies and domains alike, so one bad
entry can't cost you the rest of the set. It then exits non-zero if anything
failed, so `dejima-project approve ~/myproject && dejima` won't launch a
session missing the approvals you just confirmed.
A session whose project declares something it doesn't have is told so in its
startup context, with the exact command to hand you. Project approvals are
inherited by subdirectories, so a git worktree under the project gets its
parent's; global approvals apply everywhere regardless.
## Config parity with your host agent harness setup
What carries over from your host into the container, and what deliberately
does not. The mechanism is the same whichever harness `[core] harness`
selects; the specific paths below belong to the one harness shipped today.
### Claude Code
Global `CLAUDE.md`, settings, hooks, skills, and plugins all carry over
automatically, and your existing login stays effective too - no separate
first-run login, though the credential itself never enters the container
(see [security notes](security.md) for the host-side proxy that makes
that work).
`~/.claude` itself is *not* shared as a whole: its contents are enumerated
in `docker-compose.yml`, config files are copied in rather than mounted,
and anything not listed is container-local - writable during the session,
gone with the container. Session transcripts, plans, and the edit-undo
record are on the list, so `claude --resume` on the host still works; a
credential, cache, or newly-added config file is not. The
[security notes](security.md) explain the split and why single-file
mounts couldn't be trusted here. (`~/.claude.json` is handled differently
again - a throwaway per-session copy, not the real file.)
The project directory is mounted at the *same absolute path* inside the
container as on the host, so Claude Code's session-history keying lines up
between host and container: a session's transcript is visible on the host
afterward, and `claude --resume <session-id>` should find it there. (Whether
a bare `claude --resume` with no ID picks it as the "most recent" session
depends on `~/.claude.json`'s own bookkeeping, which - per the throwaway-copy
design above - doesn't carry over from a sandboxed session.)
The container's own user matches yours exactly - username, uid, gid, and
home directory (see `Dockerfile`) - so `$HOME`-relative paths (dotfiles,
Claude Code's plugin bookkeeping) resolve the same on both sides. Defaults to
`node`/1000/1000/`/home/node` if you build the image directly instead of via
`dejima`. Images are tagged per-user (`dejima:<username>`)
so concurrent builds on a shared machine don't clobber each other.
Your global `~/.gitconfig` also carries over (as a throwaway copy, so commits
made inside the sandbox get the right identity, but nothing written to it
during a session reaches your real file). Falls back to an empty config if
you don't have one.
Caveat: MCP servers or hooks that shell out to host-specific binaries (a
browser, a language runtime not installed here) will not work until that
tooling is installed in the image. Add it with `dejima-dep` rather than by
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.
## Headless git push without agent forwarding
**The reason to do this is identity, not uptime.** A forwarded agent
authenticates the session **as you**: every commit and every pull request it
produces is yours, and for as long as it runs it can reach whatever that key
can reach. A key of its own, belonging to an account that is not you, gives
the session an identity you can scope, revoke and read in a log separately
from your own. See
[To your forge, the sandbox is you](security.md#to-your-forge-the-sandbox-is-you)
for why that matters more than it first appears.
Running unattended is the second reason and the lesser one. Agent forwarding
ties SSH access to whichever client is currently connected, so it dies when
that client disconnects and takes git-over-ssh with it for anything still
running headless.
Either way, generate a dedicated, purpose-built keypair:
```sh
ssh-keygen -t ed25519 -N '' -f ~/.ssh/dejima_ed25519
```
Passphrase-less is deliberate - this is meant to be a low-privilege
credential living only inside the container, not your personal identity. Add
the public half as a Gitea deploy key scoped to just the repos that need
push access, then set it in the config:
```
[core]
ssh_key = ~/.ssh/dejima_ed25519
```
`entrypoint.sh` loads it into a container-local `ssh-agent` at startup and
deletes the plaintext copy immediately after - the sandbox user itself never
has a path to the raw key, only the agent socket (see
[security notes](security.md) for the full mount rationale). Setting
`[core] ssh_key` means a live forwarded agent is no longer required at all.
## Using a different editor/application
```sh
dejima --cmd nvim ~/repos/some-project
```
Runs unconfigured unless you also set `[mounts] nvim_config` in the config to
a real config directory on the host (bind-mounted read-only at
`~/.config/nvim` - lazy.nvim's lockfile is the one exception, layered
read-write on top so plugin syncs don't fail on a read-only filesystem;
nothing else in the config is writable):
```
[core]
command = nvim
[mounts]
nvim_config = ~/.config/nvim
```
Set both in `~/.config/dejima/config` if you use this mode regularly, or pass
`--cmd nvim` per launch once `nvim_config` is set.
The lockfile is layered on as a **copy**, not as your file: `bin/dejima`
mounts a `mktemp` duplicate and deletes it afterwards, so a plugin sync
inside a session cannot rewrite the host's `lazy-lock.json`.
`~/.local/share/nvim` (plugin source and `mason/` binaries), `~/.local/state/
nvim` and `~/.cache/nvim` are **Docker volumes, not host directories**. Your
own nvim data is never mounted, so nothing a session installs can reach the
editor you run outside the container. They do persist across containers,
which is what stops every launch from reinstalling plugins - so they are
named per project. Under one shared name, a session that wrote a Lua file
under `lazy/`, or replaced a binary under `mason/bin/`, would leave something
the next session in an *unrelated* project executed. Nothing there is
protection against a session tampering with its own project's plugins, which
it can do freely and which only ever runs inside the sandbox.
If your config references something outside that directory by absolute host
path (a helper script in a dotfiles `bin/`, say), bring it in with
`[mounts] readonly` (colon-separated, mounted read-only at the same path):
```
[mounts]
readonly = ~/dotfiles/bin
```
## Writable host directories
`[mounts] read_write` is the same thing without `:ro` - for a directory a
session is meant to write to and whose contents should outlive the container:
```
[mounts]
read_write = ~/specs
```
Both lists mount at the host path they name, so a convention that says
"specs live in `~/specs`" keeps working inside the container without being
rewritten. That is the difference from `[core] share_dir`, which is
mounted at `~/shared` regardless of where it lives on the host because its job
is to be a well-known handoff point.
An entry is **refused, not warned about**, if it overlaps anything dejima
relies on being out of a session's reach: the approval store, either audit
store, `~/.claude`, `~/.local/bin`, dejima's own checkout, or the project mount
(already read-write, so listing it again is a mistake worth surfacing). A
writable mount over any of those would let a session approve its own domains,
delete the audit findings naming it, or plant code that runs unsandboxed on the
host next time you start an agent. Overlap is checked in both directions after
resolving symlinks, so `read_write = ~` is caught too.
Sessions are told which directories these are, so "everything else is
disposable" stays true in the briefing rather than becoming a trap.
### One-off mounts for a single launch
`--extra-readonly PATH` and `--extra-read-write PATH` add a path for one run
without editing the config. `--ro` and `--rw` are the short forms. Both are
repeatable, one path per occurrence:
```sh
dejima --ro ~/other-project ~/myproject # read while migrating code out
dejima --rw ~/scratch --ro ~/reference ~/myproject
```
They **append** to the config's lists rather than replacing them, and they
are the same lists, so everything above applies unchanged: the existence
check, the refusal when a path overlaps something dejima keeps out of a
session's reach, the refusal when one path appears in both lists, and the
briefing sentence naming what a session can write. A flag says *where*, not
*whether*, and there is no flag that waives a refusal.
Two differences from the config form, both because a command line is not a
file. A relative path is accepted and made absolute against the working
directory, which a config file has no equivalent of. And a path containing a
colon is refused outright: both lists are colon-separated, so such a path
cannot be represented in one at all and would otherwise split into two
mounts that each fail the existence check.
## Shared scratch space
`~/shared` is a read-write bind mount of `[core] share_dir` (default
`~/.local/share/dejima/shared`, created automatically) - separate
from the project mount, for passing files (screenshots, temp scripts) in and
out of a session without touching the actual checkout. Persists across
sessions since it's a real directory, not a container volume.
Treat it as a handoff point, not as general-purpose storage. If your own
project instructions tell agents to write somewhere at a fixed host path -
design docs, scratch notes, anything meant to outlive the session but stay out
of the project checkout - mount that directory instead, with
`[mounts] read_write`. It arrives at its own absolute path, so a convention
that says "put specs in `~/specs`" keeps working inside the container without
being rewritten to point at `~/shared`.
`~/shared`, the project mount, and anything in `[mounts] read_write` are the
only writable host paths in the container. Everything else a session writes
goes away with it.
**Clear it out periodically.** It persists across sessions by design, which
means anything you left in it is readable by every session after the one you
left it for. A credential dropped in there to get one job done, and then
forgotten, is a credential you have handed to every session since.
## Testing frontend changes
Playwright (Chromium) is pre-installed at image build time, browser
included - `require("playwright")` works from any script, no network access
or per-project `npm install playwright` needed.
`localhost`/loopback traffic never goes through the egress firewall, so a
project's own dev server needs no firewall changes. A real external site
needs to be on the allowlist like any other network use
(`dejima-allow <domain>` for a running session, add
`--global`/`--project <path>` to persist it) - the firewall applies to the
browser's traffic exactly like `curl`/`git`.
If a project pins its own `playwright`/`@playwright/test` version, its
`npm install` may try to download a different Chromium build from
Playwright's CDN, which isn't allowlisted by default - allow it the same way
as any other one-off domain need.
Chromium's own process sandbox isn't actually active in this image (its
setuid helper isn't setuid here) - not a concern, since the container itself
is already the boundary that matters, and a compromised session already has
full shell access as the container's own user regardless.
## Sandbox self-awareness
### Project instructions: `.dejima.md`
`.dejima.md` at a project's root is that project's agent-instruction file -
the same role `CLAUDE.md` plays for Claude Code - under a name that doesn't
name a harness. dejima loads it into the session's opening context the way the
harness would load its own, so a project's instructions apply whichever agent
is running.
The reason for a second name: Claude Code finds a `CLAUDE.md` by itself, and
nothing else does. As dejima grows the ability to run agents that aren't Claude
Code, a project that wants to say "here is how to work on me" needs one file
that reaches all of them - so dejima delivers it rather than leaving each
harness to go looking for its own.
Loaded verbatim, capped at 16 KB with any truncation noted in the text. It
carries no authority over the sandbox itself: the file sits on the read-write
workspace mount, so nothing in it widens the firewall, relaxes a guard, or
authorises a push, and the text delivered around it says so.
This repo ships one, describing which half of its own test suite a sandboxed
session can run.
### The session briefing
A `SessionStart` hook (`~/.claude/hooks/sandbox-context.sh`) injects a
reminder when `DEJIMA` is set: the session is running in a
disposable, firewalled container and should stay within the task at hand -
if something genuinely needs to break out (broader network, host changes,
GUI/hardware), stop and ask rather than trying to work around the firewall,
since the same session can be resumed unrestricted on the host via
`claude --resume`. No-op outside the container. `deny-dangerous-bash.sh`
(`PreToolUse`) also applies here, since `~/.claude/hooks` is mounted in.
When GitHub is a push destination this session - `--allow-github-push`, or
`forge = github` under a pushing workflow - the briefing also tells the
session not to push branches or open pull requests on any project other than
the one it was pointed at. Lifting the default gives an agent the ability
without the reason it is normally off (see
[docs/security.md](security.md#github-push-blocked-by-default-optional-opt-in)),
so the reason is supplied where it will be read.
`git-push-guard.sh` (`PreToolUse`/`Bash`, also `DEJIMA`-gated) blocks
any `git push` whose resolved remote isn't the Gitea host, and any push to
`main`/`master` even there. Like the other hooks, this is a fast-fail guard
against an overeager agent, not a hard security boundary - a session has full
shell access and could in principle edit `.git/config` to route around it.
GitHub push is blocked by default too (see
[GitHub push](security.md#github-push-blocked-by-default-optional-opt-in) in
docs/security.md) unless `--allow-github-push` is given. For a real guarantee
against pushes to main/master, use branch protection on the remote itself.
`merge-guard.sh` (`PreToolUse`/`Bash`, `DEJIMA`-gated) blocks
`gitea-pr merge` inside the sandbox. It's the API-side counterpart to the rule
above: a merge puts a branch on `main` without ever performing a push, so
`git-push-guard.sh` never sees it. A hook is handed a command rather than a
conversation, so it can't tell an instructed merge from an unprompted one -
which is why it's scoped to the sandbox rather than applied everywhere. On the
host it does nothing, and you can still ask an agent to merge. The same
fast-fail caveat applies with more force than usual here: the session has the
Gitea token in its environment and could call the merge endpoint with `curl`
directly. Branch protection on the remote is the real guarantee.
`sandbox-context.sh` also tells the agent the actual workflow to follow: do
new work in a `git worktree` under the mounted project (e.g.
`.worktrees/<branch>`, never `../<branch>` or elsewhere under `$HOME` - only
the bind-mounted path is backed by real host storage, so a worktree created
outside it silently vanishes with the container), then push the feature
branch and run `gitea-pr create`.
+357
View File
@@ -0,0 +1,357 @@
# Development
[← dejima README](../README.md)
The file-by-file map of this repository, and the detail behind its test
suites: what each one covers, whether it needs a Docker daemon, and how to
run them.
## Files
| File | Purpose |
| --- | --- |
| `docker/Dockerfile` | Base image, dev tools, nvim, Claude Code + wrapper, `gitea-pr`/`gitea-issue` |
| `docker/fixed-domains.sh` | Shared domain list sourced by both `init-firewall.sh` and `dns-proxy.sh` |
| `docker/dns-proxy.sh` | Starts the DNS-allowlisting `dnsmasq`, repoints `resolv.conf` at it |
| `docker/init-firewall.sh` | IPv4/IPv6 egress allowlist firewall, run at container start |
| `docker/ulogd.conf` | `ulogd2` config: turns `init-firewall.sh`'s NFLOG-tagged packets into JSON |
| `docker/log-shipper.sh` | Tails the dnsmasq/ulogd2 logs and ships them to the configured Loki endpoint (`[logging] loki_url`); also ships session transcripts when conversation logging (`[logging] conversation`) is on |
| `docker/entrypoint.sh` | Runs as root: DNS proxy, firewall, logging, drops to the sandbox user via `gosu`, then execs the session's command |
| `docker/harness-wrapper.sh` | Shadows the selected harness's command on `PATH` to add its no-confirm flags and dejima's settings layer. Named by no harness; `entrypoint.sh` links it into place |
| `docker/docker-compose.yml` | Wires up mounts/env; invoked via `bin/dejima`, not directly |
| `hooks/sandbox-context.sh` | `SessionStart` hook: injects sandbox context/workflow reminders, and any `.dejima.md` the project ships |
| `.dejima.md` | Project-level agent instructions, harness-agnostic: `CLAUDE.md`'s role under a neutral name. dejima loads a project's into the session context; this one is dejima's own |
| `hooks/git-push-guard.sh` | `PreToolUse` hook: blocks pushes outside the allowed host(s)/branches |
| `hooks/deny-dangerous-bash.sh` | `PreToolUse` hook: blocks obviously destructive Bash commands |
| `hooks/merge-guard.sh` | `PreToolUse` hook: blocks the configured forge's merge command (`DEJIMA_MERGE_BLOCK`) in the sandbox: merging is a human decision |
| `hooks/dejima-notify.sh` | `Notification`/`Stop` hook: drops an empty `waiting`/`done` marker for a remote-enabled session - see [docs/remote-access.md](remote-access.md) |
| `bin/dejima-attach` | Lists live sessions and attaches to one; the shared core behind both remote front doors |
| `bin/dejima-audit` | Advisory exit-time diff of host-persistence surfaces (git config, `.claude/`, etc.). `dejima-audit show` / `dejima-audit ack` default to the enclosing repo |
| `bin/dejima-dep` | Review and approve project dependencies declared in `.dejima/dependencies` |
| `bin/dejima-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) |
| `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) |
| `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-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-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-slug-lib.sh` | The `~/.claude/projects` slug scheme and which slugs belong to a workspace; sourced by `bin/dejima` (what to mount) and `bin/dejima-epitaph` (whose transcripts to keep), so the two cannot disagree. Not on PATH |
| `bin/dejima-ttyd` | Launches `ttyd` on loopback in front of `dejima-attach` |
| `systemd/*.service` | User units for `dejima-ttyd` and `dejima-notify-watch`; ship disabled, installed by hand |
| `docs/providers.md` | The manifest contract for the forge/workflow/harness axes, key by key, with the shipped manifests as worked examples |
| `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/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,
checks for a live `ssh-agent`, reads `GITEA_TOKEN`/`GH_TOKEN` from the token
files named in the config, discovers which host directories need mounting for symlinks under
`~/.claude` (`hooks`, `skills`, `commands`, `agents`, `output-styles`, and
top-level entries like `statusline.sh`) to resolve, and runs
`docker compose run`. `bin/dejima-allow`
adds a domain to a running session's firewall from the host - see
[Adding a domain to a running session](security.md#adding-a-domain-to-a-running-session).
`install.sh` installs the hooks into
`~/.claude/hooks` and registers them in `~/.claude/settings.json`.
## Tests
`test/` has a [bats](https://github.com/bats-core/bats-core) suite covering
the security invariants the README states, not a substitute for reading it,
but a way to actually catch a future change silently breaking one of them
instead of finding out the hard way.
`static.bats` greps the repo's own files - no sudo in the `Dockerfile`, every
`ipset add` is duplicate-safe, no `docker.sock`/host-credential mounts, the
`~/.claude` mount inventory is exactly what it should be, `dejima-allow`
rejects a malicious domain argument. `runtime.bats` builds the image, boots
a real container running the actual `entrypoint.sh` (a `SANDBOX_TEST_MODE`
env var makes it stop just short of execing the session's command, so the suite can attach
to a fully-initialized container instead of reimplementing its own copy of
the setup sequence), and asserts against it: IPv6 blocked, DNS/IP allowlist
enforcement in both directions, GitHub read-only, no working `sudo`/
`iptables`/`init-firewall.sh` as `node`, capabilities actually stripped
(not just absent from a `docker exec` that doesn't have them to begin
with), `ulogd2` unprivileged, and the dynamic-ipset CDN-rotation fix.
`project.bats`, `remote.bats` and `audit.bats` need no Docker either.
`project.bats` covers the
declaration/approval split - what `.dejima/` may ask for, what only the host
can grant, and that the approval store is never mounted. `remote.bats`
covers everything in [docs/remote-access.md](remote-access.md):
the `[remote-access]` config section, the session registry, and the properties the
feature's security rests on - that the
notification path carries no sandbox-controlled byte, and that no marker
name outside `waiting`/`done` is ever acted on. `audit.bats` covers
`bin/dejima-audit`'s own diffing.
`config.bats`, `providers.bats` and `proxy.bats` round out the suites that
need no Docker either: `config.bats` covers `~/.config/dejima/config`
parsing and `install.sh`'s legacy-variable warnings (stubbing `docker` itself,
since the fast CI job's container deliberately has no daemon), `providers.bats`
covers the forge/workflow/harness manifest contract documented in
[docs/providers.md](providers.md), and `proxy.bats` covers the credential
proxy's own request handling.
One assertion is worth calling out because it can only run for real on a
Docker host: `runtime.bats` proves that a service bound to the host's
loopback is unreachable from inside a container while one bound to the
bridge gateway *is* reachable. The second half is a positive control - it is
what stops the first from passing in an environment where the container
reaches nothing at all. That pair is the ttyd front door's whole containment
argument, so run it before trusting a front door you just set up.
Every mutation in `runtime.bats` (firewall rules, `ipset` entries) happens
inside the test container's own network namespace, same as any other
container - none of it touches the host. Container/network names are
derived from bats' own per-run temp directory, not anything guessable or
likely to collide with a real container you have running.
`ci-runner.bats` is different in kind from the others: it doesn't test
this repository at all, it asserts on the CI runner executing it (the Docker
socket withheld from the confined job, granted to the privileged job, and
the egress each is allowed) - see [docs/ci.md](ci.md). It skips outright
unless `DEJIMA_CI_TIER` is set, so it has nothing to say when run locally.
### Branches
Pull requests target `development`, which merges to `main` periodically, so
`main` stays releasable while work lands quickly. `gitea-pr` defaults its
base to the repository's default branch, so pass `--base development`.
CI needs no change for this: `.gitea/workflows/tests.yml` triggers on
`pull_request_target` with no branch filter, so it already runs whatever the
base is.
Nothing *enforces* the target. `git-push-guard.sh` blocks direct pushes to
the branches a workflow names, and that list is workflow-wide rather than
per-project - adding `development` would impose it on every dejima user,
most of whom have no such branch. So this is a convention with a check
attached: read what `gitea-pr` printed.
### Running the tests
```sh
bats test/ # everything
bats test/audit.bats # one suite - the suites are independent, see .dejima.md
SKIP_BUILD=1 bats test/runtime.bats # the one suite needing Docker; reuse the current image
```
`runtime.bats` is the only suite that needs a Docker daemon: it is the one
that builds the image and boots real containers to assert against.
Everything else, `audit.bats`, `ci-runner.bats`, `config.bats`,
`project.bats`, `providers.bats`, `proxy.bats`, `remote.bats` and
`static.bats`, runs anywhere a shell does, including inside a dejima sandbox
itself, which has no Docker daemon by design.
`.gitea/workflows/tests.yml` runs all of it on every pull request and on every
push to one, `runtime.bats` included. That job needs two things from the
runner that an ordinary one doesn't (the host's Docker socket and the host
network namespace), and it checks for both up front rather than failing
obscurely partway through; the file says why each is needed.
## Adding a new harness
`[core] harness` picks a directory under `harnesses/`, and a harness manifest
declares four things. Everything else dejima does for Claude Code is written
against Claude Code, and this is the inventory of it: what a second harness
has to supply, and where dejima has to stop assuming. Taken from a pass over
the tree, so each row names the file to change rather than describing a
sketch.
### What a harness must be able to do
Support is per harness and deliberate. A supported harness gets the same
depth of integration Claude Code has - the same mounts, the same guards, the
same audit surfaces - so a harness is not a manifest someone drops in. That
is only possible if the harness can host what those protections need.
These are the requirements. A harness that fails one of the first five
cannot be supported, and that is a decision about the harness rather than a
gap in dejima.
| | Requirement | Why it is load-bearing |
| --- | --- | --- |
| **R1** | **Unattended execution.** A documented mode that runs tool calls without confirming each one. | The premise the project rests on. A harness that stops to ask is one an operator sits in front of, and the argument for accepting that mode is that the container makes being unattended survivable. |
| **R2** | **Tool-call interception.** A supported mechanism to inspect a command before it runs and refuse it, without patching the harness. | The push guard, the merge guard and the dangerous-command denylist all live here. They are guards against an eager agent rather than a boundary against a hostile one, but a harness without them ships three protections short and the operator cannot add them back. |
| **R3** | **Host-controlled context at session start.** A way to place text into the session's context at start, from a file the operator controls and the session cannot rewrite for the run in progress. | How the briefing and the project's `.dejima.md` arrive. It is also how the agent learns it is sandboxed, which is what makes it ask for a domain instead of working around the firewall. |
| **R4** | **State dejima can locate or discard.** Per-project state must be computable on the host before launch, so it can be scoped per project - or safely discardable, so it can stay container-local. | #104. A store that is neither computable nor discardable cannot be scoped, and mounting it whole reintroduces cross-project context poisoning. |
| **R5** | **A withholdable credential.** The model credential must live somewhere dejima can decline to mount, and the harness must accept a substitute: a staged fake, or an environment variable pointed at the host-side proxy. | The credential never entering the container is a headline property, not a nicety. |
Three more are required, but may be declared absent in the manifest, in
which case dejima says at launch what is not in force:
| | Requirement | What is lost without it |
| --- | --- | --- |
| **D1** | Session lifecycle signals - something that fires when the agent wants attention and when it stops. | Remote access notifications. The session still runs and is still attachable. |
| **D2** | A parseable record of the session. | Epitaph, and conversation logging. |
| **D3** | An enumerable config directory whose extension points can be mounted read-only. | Config parity, and the read-only mounts that stop a session installing a persistent hook for the next one. |
Declaring one absent is a real decision with a visible cost, not a
formality. It is there so a harness can be supported honestly at a smaller
guard set rather than shipped with three protections quietly missing.
**The guard rails are declared in the manifest and reported at launch.**
`guards` lists which of `bash-deny`, `push-guard`, `merge-guard` and
`notify` the harness can host. A name outside that set is refused, because a
typo would otherwise turn a guard off in silence. The ones a harness does
not declare are named once at launch, with what each costs - and nothing is
printed at all when it hosts them all, so the message stays a signal rather
than a line to scroll past.
Core gates the work on the declaration rather than on whether a plugin
happens to define the hook: a harness that forgot to declare a guard but
implemented it would otherwise get a protection the launch message has just
told the operator it does not have.
### Worked example: why aider is not supported
aider clears R1 (`--yes-always`), R3 (`--read` places a file in context),
R4 (its state is `.aider.*` inside the workspace, which is already mounted)
and R5 (it takes an API key from the environment).
It fails **R2**. aider has no hook or event system; the only occurrence of
"hook" in its options is `--no-verify`, which controls git's own pre-commit
hooks. There is no supported point at which a command can be inspected and
refused, so `git-push-guard.sh`, `merge-guard.sh` and
`deny-dangerous-bash.sh` have nowhere to live and no equivalent to be
rebuilt from. It also fails D1.
The container would still bound an aider session. But three guards an
operator gets everywhere else would be missing with no way to restore them,
and "supported" would mean something different for that one harness than
for every other. So it is not supported, and this is the reason.
### Where a harness's support lives: three tiers
| Tier | What | Rule |
| --- | --- | --- |
| **Core** | the mechanism, and every enforcement point | harness-agnostic, always |
| **Manifest** | `harnesses/<name>/harness.conf` | data. Never sourced, never executed. Selects among named core mechanisms and supplies paths |
| **Plugin** | `bin/harness/<name>.sh` | the harness-specific remainder, as code, in this repository |
**A feature belongs in the manifest if it can be stated as a value.** It only
moves to a plugin when it cannot. `projects_dir` and `file_history_dir` are
both paths, so both are manifest keys; the rule that a *name* in the first
selects an *entry* in the second is Claude Code's own and could not be
written as a value, so that is the plugin. "Add a manifest key" is the
answer until it demonstrably is not.
**Plugins live under `bin/`, not beside the manifest.** `dejima-audit`'s
`tcb_snapshot` covers `install.sh`, `bin/`, `hooks/`, `docker/` and `test/`,
and not the provider directories. Host-side plugin code under `harnesses/`
would be code that runs on the host and that a session could edit in
dejima's own checkout without raising a CRITICAL - the exact class the audit
exists to catch. Under `bin/` it is covered for free, and "manifests are
data, never executed" stays true word for word.
**Plugins are sourced, not invoked**, and are part of the trusted computing
base. The risk worth designing against is not a careless plugin but three
plugins implementing the security model three ways, so a plugin uses core's
own primitives rather than re-deriving them. An invoked protocol would also
put a new text parser on the launch path, and every parsing bug this
repository has had is in that family.
**The manifest may carry values that are only used as paths or compared as
data. It must not carry text that becomes part of a parser's grammar, or the
identity of a security surface.** `dejima-audit`'s `ack` recovers a finding's
id by stripping a fixed label from the front of the finding text, anchored
deliberately (#45). Those labels are grammar. Provider directories are not in
`tcb_snapshot`'s list, so in the self-mount case a session can edit a
manifest without raising a CRITICAL - which is why host-execution surfaces
are a plugin hook (`harness_host_exec_surfaces`) rather than a manifest key.
The label stays a literal in code the audit watches; it just lives with the
harness that owns it.
**A plugin computes; core enforces.** A plugin declares through `emit_mount`
and `emit_env`, which validate at the point of declaration - `emit_mount`
runs the guarded-store refusal there, so a harness state directory that
overlapped the approval, audit or epitaph store is refused without each
plugin having to remember. A plugin never touches `extra_mounts` itself, and
a static test holds that. A plugin may *classify* an audit path; it may never
suppress a finding.
Every shipped harness has both a manifest and a plugin - a static test
refuses one without the other, because a manifest alone would present as a
supported harness while none of the work had been done.
### What the manifest already covers
`harnesses/<name>/harness.conf`, and the full contract is in
[providers.md](providers.md):
| Key | What it decides |
| --- | --- |
| `command` | the container's main process |
| `keys` | this harness's own `[harness.<name>]` config schema |
| `resume_command` | what the briefing tells a session to hand the operator |
| `briefing` | this harness's fragment of the session briefing |
### Reading a harness's own config
`harness_config_get <key>` resolves whichever harness `[core] harness`
names. Use it; do not spell a `[harness.<name>]` section out at a call site.
`keys` is declared per harness, so a literal section name makes it a
declaration only one harness can ever be read through - a second harness
gets selected, gets validated, and is then handed the first one's paths with
nothing erroring anywhere. Three call sites did exactly that, in
`bin/dejima-audit` (twice) and `bin/dejima-epitaph`. A static test now holds
the rule.
It returns empty when no harness is selected. `bin/dejima` refuses to start
in that state, so that is reachable only from the standalone tools, where a
harness-specific path resolving to nothing is the honest answer.
### The inventory
| Area | What is Claude Code specific | Where |
| --- | --- | --- |
| Install | `npm install -g @anthropic-ai/claude-code`, and a pinned `CLAUDE_CODE_VERSION` build arg | `docker/Dockerfile` |
| Permission bypass | the flag itself, now `no_confirm_args` in the manifest; the wrapper is generic | `harnesses/<name>/harness.conf` |
| Settings layering | that overrides arrive as an extra layer rather than a mount over the operator's file (#47) is core; the flag that carries them is `settings_args` in the manifest | `harnesses/<name>/harness.conf` |
| Telemetry | `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` | `docker/Dockerfile` |
| Egress allowlist | `platform.claude.com` always, `api.anthropic.com` only when no credential proxy is in front of it | `docker/fixed-domains.sh` |
| Credentials | the whole proxy exists because Claude Code authenticates from `~/.claude/.credentials.json` rather than an env var; the entrypoint seeds a well-formed fake so it has something to read | `bin/dejima-anthropic-proxy`, `docker/anthropic-proxy-forwarder.js`, `docker/entrypoint.sh` |
| Hook protocol | the event payload arrives on stdin; `SessionStart` returns `hookSpecificOutput.hookEventName`/`additionalContext` as JSON, while a `PreToolUse` hook blocks by exiting 2 with its reason on stderr | `hooks/*.sh` |
| Hook registration | four hooks written into `~/.claude/settings.json` in Claude Code's schema, one `SessionStart` with a `startup\|resume\|clear\|compact` matcher and three `PreToolUse` matching `Bash`. Now in the plugin, gated per declared guard | `bin/harness/claude-code.sh` |
| Briefing delivery | the briefing and the project's `.dejima.md` reach the session *through* the `SessionStart` hook. No hooks, no briefing | `hooks/sandbox-context.sh` |
| Notifications | `Notification`/`Stop` hooks, registered per session into the settings layer, are what drop the markers remote access watches | `hooks/dejima-notify.sh`, `bin/dejima` |
| Config parity | every `~/.claude` subdirectory is enumerated and mounted by name: `plugins`, `hooks`, `skills`, `commands`, `agents`, `output-styles` read-only; `backups`, `tasks`, `plans`, `sessions` read-write | `docker/docker-compose.yml` |
| Staged config | `settings.json`, `CLAUDE.md` and `statusline.sh` are staged read-only under `/etc/dejima-claude` and installed into place | `docker/entrypoint.sh`, `bin/dejima` |
| App state | `~/.claude.json` is copied rather than mounted, because it mixes live state with `mcpServers` config | `bin/dejima` |
| 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). Now in the plugin | `bin/harness/claude-code.sh` |
| Transcript format | conversation logging transforms Claude Code's JSONL line by line | `docker/log-shipper.sh` |
| Compaction format | epitaph keys on `isCompactSummary` records and the `compact_boundary` that precedes them | `bin/dejima-epitaph` |
| Audit surfaces | `.claude/settings.json` is CRITICAL and the rest of `.claude/` is a WARNING, because that is where this harness keeps things that run | `bin/dejima-audit` |
| Host code execution | `statusline.sh` is audited as its own CRITICAL surface because `settings.json`'s `statusLine.command` runs it **unsandboxed on the host**. Declared by the plugin, since a finding's label is a parser's grammar rather than data | `bin/harness/claude-code.sh` |
### Two things worth deciding early
**What runs on the host when the harness is configured.** `statusLine.command`
is the one that exists today, and it is not a quirk: a harness that will
execute an operator-configured command outside the container hands a session
that writes that file a way onto the host. Find that surface in the new
harness before supporting it, because it belongs in the audit from the first
release rather than after someone finds it.
**What the harness keeps, and who else can read it.** The per-project
scoping of `projects/` and `file-history/` exists because a session that can
write another project's memory can plant text a later session there reads as
established background (#104). A new harness's state directory needs the same
question asked of it, one directory at a time. Anything mounted host-wide
should be there because it is keyed by session rather than by project, and
because nothing loads it into a session's context at startup - which is the
reasoning that leaves `tasks`, `plans`, `sessions` and `backups` mounted
whole today.
### Testing a new harness
`providers.bats` covers the manifest contract itself and is where a new
`harness.conf` gets its parsing and validation tests. The suites that assert
on Claude Code paths by name - `config.bats`, `audit.bats`, `static.bats`,
`runtime.bats` - are the ones that will need a second set, and a green run
against the existing harness proves nothing about a new one. Nothing in
`test/` currently runs a second harness end to end.
+35 -32
View File
@@ -211,7 +211,11 @@ A workflow's `briefing.md` is plain Markdown plus two marker forms that
| Key | Type | Default | Meaning |
| --- | --- | --- | --- |
| `command` | `identifier` | `claude` | The container's main process for this harness. |
| `command` | `identifier` | `claude` | The container's main process for this harness, and the name the wrapper shadows on `PATH`. `[core] command` defaults to it. |
| `command_path` | `?path` | (empty) | Where the real binary lives, behind the shadow. |
| `guards` | `?list:identifier` | (empty) | Which of dejima's guard rails this harness can host: `bash-deny`, `push-guard`, `merge-guard`, `notify`. A name outside that set is refused rather than treated as an unknown guard, since a typo would otherwise turn a guard off silently. The ones a harness does not declare are named at launch - see "What a harness must be able to do" in [development.md](development.md). |
| `no_confirm_args` | `?template` | (empty) | The flags that make the harness run without confirming each tool call. Required in practice: dejima refuses to launch a harness that names none, because unattended operation is the premise the container is built on (R1 in [development.md](development.md)). |
| `settings_args` | `?template` | (empty) | How this harness is told to read dejima's own settings layer, mounted at `/etc/dejima-settings.json`. Empty for a harness that takes its overrides some other way. |
| `keys` | `?list:keyspec` | (empty) | This harness's own `[harness.<name>]` config schema - same grammar as a forge's `keys`. |
| `resume_command` | `?template` | (empty) | Prose template (may use `{{session_id}}`) shown at the end of the briefing for resuming this exact session on the host. |
| `briefing` | `?relpath` | (empty) | This harness's briefing fragment file, relative to its own directory. |
@@ -219,14 +223,22 @@ A workflow's `briefing.md` is plain Markdown plus two marker forms that
Worked example, `harnesses/claude-code/harness.conf`:
```
command = claude
keys = projects_dir:path=~/.claude/projects statusline_path:path=~/.claude/statusline.sh
resume_command = claude --resume {{session_id}}
briefing = briefing.md
command = claude
command_path = /usr/local/share/npm-global/bin/claude
guards = bash-deny push-guard merge-guard notify
no_confirm_args = --dangerously-skip-permissions
settings_args = --settings /etc/dejima-settings.json
keys = projects_dir:path=~/.claude/projects file_history_dir:path=~/.claude/file-history statusline_path:path=~/.claude/statusline.sh
resume_command = claude --resume {{session_id}}
briefing = briefing.md
```
Reading it: the container's main process is `claude`; `[harness.claude-code]`
gets `projects_dir` and `statusline_path`, both `path`-typed with defaults;
Reading it: the container's main process is `claude`, wrapped on `PATH` in
front of the real binary at `command_path` so that every invocation - not
only the one the entrypoint makes - gets `--dangerously-skip-permissions`
and dejima's settings layer; `[harness.claude-code]`
gets `projects_dir`, `file_history_dir` and `statusline_path`, all `path`-typed
with defaults;
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`.
@@ -318,30 +330,21 @@ every launch:
that has config keys but none of them resolve to a host is refused with
"configure it, or select `forge = github`, or `forge = none` with a
workflow that does not push".
- A forge whose `requires` keys are **partly** set - some configured, some
not (a key written but left empty, as `install.sh`'s commented-out starter
config makes easy, counts as not set: it resolves to nothing, and every
call the forge makes with it would fail inside the container) - is refused
as a likely typo, but only when the workflow actually
pushes: a non-pushing workflow never invokes the forge, so e.g. gitea's
`ssh_host` alone (set only so the firewall allows fetches) is a legitimate
configuration, not a typo. A wholly unconfigured forge is never flagged by
this rule - that's the case above.
- A pushing workflow whose forge has **none** of its required settings
configured is refused too, even when `fixed_hosts` means the "resolves to
no host" rule above never fires. This is what catches `github`: its single
`requires` key (`push_token_file`) means "partly configured" (the rule
above) can never trigger for it either - there's nothing to be partial
between - so without this rule a pushing workflow could select `github`
with no `push_token_file` set and launch anyway, `fixed_hosts` masking the
fact that nothing was actually configured.
Two qualifications worth stating explicitly for the "partly configured" rule:
it only applies when the workflow's `push = on` (a non-pushing workflow never
touches the forge, so an incomplete `[forge.<name>]` section is harmless),
and a required key with a non-empty schema default - gitea's `token_file`,
defaulting to `~/.config/gitea/token` - never counts as missing, since its
resolved value is already correct even when the operator never wrote it.
- A forge whose `requires` keys are **partly** set is refused as a likely
typo, but only when the workflow pushes. A key written but left empty
counts as not set - it resolves to nothing, and every call made with it
would fail inside the container. A non-pushing workflow never invokes the
forge, so gitea's `ssh_host` alone (set only so the firewall allows
fetches) is legitimate. A required key with a non-empty schema default -
gitea's `token_file` - never counts as missing, since its resolved value is
correct even unwritten. A wholly unconfigured forge is the case above, not
this one.
- A pushing workflow whose forge has **none** of its required settings is
refused too, even where `fixed_hosts` stops the "resolves to no host" rule
firing. This catches `github`: with a single `requires` key there is
nothing to be partial between, so without this rule a pushing workflow
could select it with no `push_token_file` and launch anyway, `fixed_hosts`
masking that nothing was configured.
## Shipped providers
@@ -353,7 +356,7 @@ resolved value is already correct even when the operator never wrote it.
| workflow | `worktree-pr` | worktree → push → PR, stops at the PR |
| workflow | `branch-push` | worktree → push, stops at the branch |
| workflow | `local-only` | **default** - commits locally, pushes nothing |
| harness | `claude-code` | **default**, and the only harness today |
| harness | `claude-code` | the only harness today, and **not a default** - `[core] harness` must be set |
**The shipped defaults are deliberately the non-pushing pair**
(`forge = none`, `workflow = local-only`). A fresh install has no forge host
+34 -40
View File
@@ -44,7 +44,7 @@ What it does **not** add is a way *out* of a sandbox. Configured as
described below, a compromised session cannot reach `ttyd`, cannot reach
your proxy, and gains nothing from the fact that your phone can: it has a
route to exactly one host address, and neither front door is on it. That
property is worth stating precisely because it is the one a plausible-looking
property needs stating exactly, because it is the one a plausible-looking
mistake inverts - a proxy bound to `0.0.0.0` or to a bridge address is a
front door opening *inward*, and that is a full sandbox escape rather than a
degradation. [Verifying it](#verifying-it) is two commands, and worth
@@ -69,7 +69,7 @@ approvals):
scheme; a value without one is refused rather than guessed at, because
`curl` reads a leading `-K/path` as an *option* that takes the request's URL
from that file instead. Values are restricted to
`A-Za-z0-9:/._~?=%@+-` — no whitespace and nothing shell-active, since these
`A-Za-z0-9:/._~?=%@+-`: no whitespace and nothing shell-active, since these
end up in an HTTP header and in a URL handed to a phone.
**A malformed config is a hard failure, not a warning.** The whole file is
@@ -304,7 +304,7 @@ also what catches a distro `ttyd.service` still running on that port, which
is why this is worth doing even if you are confident about the proxy. `test/runtime.bats`
asserts this same containment property against a live container (with a
positive control on the same path, so a passing result can't come from the
container simply reaching nothing), and it passes on a real Docker host.
container reaching nothing), and it passes on a real Docker host.
## SSH front door
@@ -442,7 +442,7 @@ and forgotten. What matters in it:
the one you have - just make sure it doesn't fall through to this vhost.
- **TLS.** HTTP Basic sends the password on every request, and the terminal
itself carries whatever the session is working on.
- **`proxy_set_header Host $http_host;`** — `$http_host`, not `$host`.
- **`proxy_set_header Host $http_host;`**: `$http_host`, not `$host`.
`--check-origin` compares the client's `Origin` against the `Host` header,
and nginx's `$host` has the port stripped while `ttyd` keeps the port in
what it compares unless it is 80 or 443. On a vhost listening anywhere
@@ -700,16 +700,14 @@ what the outer proxy sends.
## Notifications
`dejima` registers `hooks/dejima-notify.sh` as a `Notification` and
`Stop` hook for a remote-enabled session - into the throwaway settings
override layer it already generates for that session, never into your real
`~/.claude/settings.json`, which every sandbox and your host's own Claude
Code read directly. Your own `Notification` and `Stop` hooks keep running
alongside it: Claude Code merges hooks across settings layers rather than
letting the higher one replace the lower. That layer is mounted read-only, so a
session cannot unregister its own hook. The hook writes one empty file, named `waiting` or
`done`, into this run's own bind-mounted marker directory. No content, no
stdin, no network.
`dejima` registers `hooks/dejima-notify.sh` as a `Notification` and `Stop`
hook for a remote-enabled session, into the throwaway settings override layer
it already generates - never into your real `~/.claude/settings.json`. Your
own hooks keep running alongside it, since Claude Code merges hooks across
layers rather than replacing. The layer is mounted read-only, so a session
cannot unregister its own hook. The hook writes one empty file named
`waiting` or `done` into this run's marker directory: no content, no stdin,
no network.
On the host, `dejima-notify-watch` turns those markers into ntfy pushes.
Same installation shape as above:
@@ -726,14 +724,13 @@ loop plus a five-second sleep). Repeat markers for the same session and
event inside 60 seconds are debounced (`[remote-access] debounce` overrides
that).
One environment gotcha, because it fails silently: the watcher and
`dejima` both resolve the marker root as `${XDG_RUNTIME_DIR:-/tmp}/dejima`,
so they only meet when they agree on `XDG_RUNTIME_DIR`. systemd sets it for
user units and a login shell gets it from pam_systemd, but a `dejima`
run from a context that has neither - a bare `su`, a cron job, a tmux server
started before login - writes its markers under `/tmp/dejima` while the unit
watches `/run/user/<uid>/dejima`, and no push is ever sent, with nothing
logged to say so. `systemctl --user show-environment` is where to check.
One gotcha, because it fails silently: both sides resolve the marker root as
`${XDG_RUNTIME_DIR:-/tmp}/dejima`, so they only meet when they agree on
`XDG_RUNTIME_DIR`. systemd sets it for user units and a login shell gets it
from pam_systemd, but a run from a context with neither - a bare `su`, cron,
a tmux server started before login - writes to `/tmp/dejima` while the unit
watches `/run/user/<uid>/dejima`. No push is sent and nothing is logged.
Check with `systemctl --user show-environment`.
The same directory is why you probably want lingering on:
@@ -741,23 +738,19 @@ The same directory is why you probably want lingering on:
loginctl enable-linger "$USER"
```
Both units here are *user* units. Without lingering they are stopped at
logout and `/run/user/<uid>` is destroyed with the session, taking the
session registry and every marker with it - so a sandbox left running in a
detached tmux session has no front door and sends no notification until you
log in again. It fails closed rather than open (nothing is left listening
unguarded), but it fails invisibly, and it is the same `XDG_RUNTIME_DIR`
that the gotcha above is about.
Both are *user* units. Without lingering they stop at logout and
`/run/user/<uid>` is destroyed with the session, taking the registry and
every marker with it - so a sandbox left in a detached tmux session has no
front door and sends nothing until you log in again. It fails closed, but
invisibly, and on the same `XDG_RUNTIME_DIR` as the gotcha above.
**The marker directory is a write channel into your runtime tmpfs**, and
nothing bounds it. `/run/user/<uid>` is RAM-backed and sized at a fraction
of physical memory, and each session gets its own directory under it
bind-mounted read-write, so a session that writes into `/run/dejima-notify`
until it is full degrades your whole login session - D-Bus, the systemd user
manager - and not just dejima. A session can already fill the workspace
mount, so this is an increment rather than a new kind of exposure, but a
RAM-backed filesystem fails harder and sooner than a disk. There is
deliberately no quota here; `rm -rf` on that run's marker directory (or
nothing bounds it. `/run/user/<uid>` is RAM-backed and sized at a fraction of
physical memory, so a session filling `/run/dejima-notify` degrades your
whole login session - D-Bus, the systemd user manager - not just dejima. A
session can already fill the workspace mount, so this is an increment rather
than a new exposure, but a RAM-backed filesystem fails harder and sooner.
There is deliberately no quota here; `rm -rf` on that run's marker directory (or
logging out, which discards the tmpfs) is the recovery.
**What a notification contains.** A title of `dejima: <project> needs input`
@@ -799,10 +792,11 @@ the next one is worth checking.
2. **Is the hook registered?** The `Notification` and `Stop` entries live in
the throwaway override layer, not in your real `settings.json`:
`grep -c dejima-notify /etc/dejima-settings.json` from inside should be 2.
That file is passed to Claude Code as `--settings` by `claude-wrapper.sh`;
That file is passed to the harness by `harness-wrapper.sh`, as whatever
its manifest's `settings_args` names (`--settings` for Claude Code);
if it is missing entirely, this container was not started by `dejima`.
3. **Does the marker reach the host?** This is the one worth knowing, because
it splits the chain cleanly in half. From inside the sandbox:
3. **Does the marker reach the host?** This is the one to check first: it
splits the chain cleanly in half. From inside the sandbox:
```sh
: > "$DEJIMA_NOTIFY_DIR/waiting" && sleep 2 && ls "$DEJIMA_NOTIFY_DIR"
+215
View File
@@ -0,0 +1,215 @@
# Using this responsibly
[← dejima README](../README.md)
The README states each of these ten rules in a sentence or two. This is the
part that didn't fit: why each one matters, what goes wrong without it, and
the detail behind the three the README could only gesture at.
## Check whether they want it first.
"They didn't ask for this" is a consent problem, not a quality problem. A
project that has said it doesn't accept AI-assisted contributions has said so
for reasons that have nothing to do with whether a given patch is good: review
load, licensing questions, a maintainer who simply doesn't want to referee
that argument on every pull request. A well-written, well-tested change
doesn't override a stated no, any more than good manners override a locked
door. Read CONTRIBUTING (or the equivalent policy file, or the pinned issue,
wherever a project keeps it) before you start work, because finding out after
means the work is already done and now has nowhere to go.
## Don't point it at repositories you don't own.
The concrete workflow, step by step:
1. Fork the project on its forge.
2. Point `bin/dejima` at your fork: run it from a local clone whose `origin`
is your fork, not the upstream repository.
3. Let the session do its work there. With a pushing workflow configured, it
opens branches, and pull requests, against your fork, not upstream.
4. Review the agent's branch yourself, the same way you'd review anyone
else's diff before merging it.
5. Merge it to your fork's own `main`.
6. Open the upstream pull request by hand from there, once you would have
been glad to have written it yourself.
The reason for the extra hop is what happens without it. An auto-push
workflow aimed straight at someone else's repository puts a session's raw
output directly into that project's pull request queue, unreviewed by
anyone, the moment it opens. Maintainers already spend real time triaging a
flood of low-effort AI-generated pull requests; a Godot maintainer's account
of what that actually costs is worth reading in full:
<https://bsky.app/profile/akien.bsky.social/post/3meyerixvhs2p>. Forking and
reviewing first doesn't change what the session is capable of writing. It
changes who reads the output before anyone else has to: you, not a stranger
who owns the project.
## Don't point it at proprietary code.
dejima narrows the paths code has to leave through; it doesn't close them,
and "point it only at code you could survive losing" is the practical test
for that gap. Ask it plainly before you start a session, not after: if this
checkout's contents ended up somewhere they shouldn't, would that be a bad
week or the end of the business? Code you could survive losing may be worth
the convenience of unattended agentic work. Code you couldn't shouldn't go in
at all.
That second one is not a judgement about how good the containment is, and it
does not get better with a version bump. There is no containment that fully
prevents exfiltration: a determined attacker with code execution and any
outbound path will find a way. So the question is not whether you trust
dejima enough, but whether you can accept the risk of the contents leaving.
If you cannot, do not hand them to an agent that could send them.
## Look at what else is in the directory.
The bind mount doesn't know which files you meant to hand over. It exposes
the whole checkout, and everything a working tree accumulates over its life
sits there with the same reachability as the source files you actually care
about: a `.env` a teammate left uncommitted, a database dump pulled down for
local debugging and never deleted, a credential baked into a fixture file,
an old key still sitting in the history of a branch that got checked out.
None of that is code in any sense that matters to the agent; all of it is
just bytes in a directory the session can read and any tool call it makes
can touch.
Auditing a working tree by eye is not a minute's work, and it is the kind of
check that succeeds every time until the once it matters. **Point dejima at a
fresh clone instead.** A clone contains what is in version control and
nothing else, so the whole class goes away rather than being searched for. If
a directory might have ignored files in it worth protecting, that is the
directory not to hand over.
## An allowlist entry is a grant, not a convenience.
For every allowlist entry you give a dejima agent, consider what an attacker
could do with it. The answer varies enormously between entries that look
similar, and what it turns on is what happens if the call is wrong, whether
wrong means a prompt-injected instruction or an ordinary agent mistake.
A package registry or a docs site can be wrong without being expensive. A
session that fetches the wrong package version, or scrapes a stale or
misleading page, produces bad input to more code, code that still has to
pass through your own review before any of it ships. The mistake is
contained by the same diff you were always going to read.
A deploy webhook, a production database API, and a payment provider are a
different shape entirely. A single call against any of them is the action,
not a step toward one you'll review later: the deployment already ran, the
row is already written or gone, the charge already happened. There is no
diff downstream that catches it, because nothing about it passes through
your repository at all. Allowlisting one of these gives an unattended process
standing authority to act on the world, the same authority you would think
twice about handing a person.
Ask, before adding an entry: if this call happened by mistake, is the result
something I fix in the next commit, or something I have to explain to
someone?
## Safe and responsible are not the same thing.
"Safe" is a property of the sandbox: what a session inside it can reach.
"Responsible" is a property of what you let out of it. A session can be
contained perfectly and still produce something that should not be merged,
and the sandbox has no opinion either way.
This point is about the merge, not about code quality in general. What makes
`main` (or `master`, or whatever your default branch is called) different
from any other branch is that other things read it and believe it. It is
worth being precise about why, rather than saying "the released version" in
some vaguer sense. In this repository, dejima-ci-hardener resolves the
CI-side egress
allowlist by reading `.dejima/allowed-domains` from `origin/main`, never
from a branch or a local checkout, and treats whatever it finds there as
already reviewed. That trust is load-bearing: the only way content lands on
`main` is through a pull request you merged, so reading from `main` is how
the tool avoids asking a branch what it's allowed to do. So merging onto
`main` ships it to users and feeds a trust anchor that other tooling, this
one and potentially future ones, is entitled to rely on without re-checking.
Keeping insecure or broken code off `main` is what makes that reliance
justified, and that job is yours; nothing about running the agent in a
sandbox does it for you.
## Give it its own identity, then own what you merge.
The reason to separate identities is that forge-level controls key on who
authenticated, not on what changed. By default the sandbox authenticates as
you, so every control your forge offers that answers "who did this" (a merge
whitelist, a push whitelist, required approvals) admits the session exactly
as it admits you; a required approval becomes unusable outright, since a
forge won't let a pull request's own author approve it, and the session
opens pull requests as you. Giving the sandbox a dedicated forge account
(see [docs/security.md](security.md#to-your-forge-the-sandbox-is-you) for
how) is what turns those settings from decoration back into something that
actually holds a session to a smaller set of permissions than yours.
Once the identity is separate, every commit and pull request the session
produces is visibly labeled as the agent's own work, not yours, in your
project's history. That visibility makes your responsibility legible without
shrinking it. Anyone reading your `main` later sees your name on the merge
commit regardless of whose name is on the authored one, and will hold you to
it accordingly.
## Understand what you ship.
The risk here is habituation, not a single lapse. dejima makes iterating
cheap enough that reading every diff starts to feel like the slow part of
the loop, and skipping it works fine, repeatedly, until the one time it
doesn't. A reviewer who reads every change catches most of what's wrong with
it. A reviewer who only reads the ones that felt risky catches none of the
ones that didn't feel risky, which is the shape a subtle bug, or a
prompt-injected change dressed up as something ordinary, takes. Nothing
about a faster loop changes what engineering judgment is for.
## This is not a blastproof box.
Read this rule as a statement about what kind of failure to plan for.
dejima's containment is Linux namespaces and a small capability set around
the session, the same kind of isolation a container gives any process, not a
virtual machine boundary; it shares the host kernel with everything else on
the machine.
That buys real things (see [docs/security.md](security.md) for the specific
list: no working `sudo`, a deny-by-default firewall, read-only mounts over
the surfaces that matter most), and every one of them assumes the container
itself stays intact. A determined attacker, or a single container-escape
vulnerability, doesn't erode that protection gradually; it removes every
layer described in that document at once, because they all sit on the same
kernel. So the assumption to hold is not that the walls will probably hold.
It is that if this container is ever compromised at the kernel level,
everything reachable from this host account should be treated as compromised
with it, which is a very different thing to plan around than an ordinary bug.
## dejima's security says nothing about your software's.
These are different axes, not different degrees of the same thing. dejima's
threat model is about the session: what a process running inside the
sandbox can reach, exfiltrate, or execute while it runs. None of that has
anything to say about the software that session writes once it's on disk.
dejima can contain a session perfectly and that session can still write a
SQL injection, hand-roll a broken authentication check, hardcode a secret,
or pull in a compromised dependency, because those are properties of the
code itself, decided at the moment it's written, not something a sandbox
sitting around the process that wrote it can retrofit afterward. If what got
built inside dejima turns out to be insecure, containment was never the
control that was supposed to catch it.
**It is not something a future agent can retrofit either.** "We will do a
security pass later" was already a weak plan before agents made it cheap to
say. Early architectural decisions
close off the fixes for whole classes of problem: where trust boundaries sit,
what is authenticated and where, what data crosses which process, what a
single compromised component reaches. A later pass can find a missing bounds
check. It cannot move a boundary you built a system on top of. And insecure
implementation decisions sit in software for years without announcing
themselves, which is exactly why they are not found by a pass that goes
looking once.
So security has to be considered at every point in the work, not scheduled
as a phase, and dejima will not help you do that. It makes writing a lot of
code fast, which makes writing a lot of insecure code fast if nobody is
holding that concern. That responsibility is yours at every step, and it is
the one thing here no tooling in this repository is aiming at.
The same practices you'd apply to code from any other source, review,
linting, dependency scanning, tests aimed at the failure modes you care
about, still have to happen here. dejima does not stand in for any of them.
+676 -177
View File
@@ -10,6 +10,12 @@ at repositories you already trust. Everything below is about how bad a
compromised session *inside* that trust boundary can get, not a claim that
one can't happen.
**And none of it has been reviewed by anyone else.** This is early
development: the reasoning here is one person's, the tests assert what that
person thought to check, and no outside audit or adversarial testing has
happened. Read what follows as claims awaiting scrutiny rather than claims
that have survived it. See [SECURITY.md](../SECURITY.md).
## CI: push access is code execution elsewhere
The controls in this document bound what a session can reach *directly*. They
@@ -19,7 +25,7 @@ whatever host that CI runs on.** No operator action is needed anywhere in the
chain: the push guard permits a feature branch, the briefing tells the agent
to open a pull request without asking, `on: pull_request` fires, and the
runner checks out that commit and executes it. The you-merge-not-the-agent
gate sits strictly downstream — by the time anyone reads the diff, it has
gate sits strictly downstream: by the time anyone reads the diff, it has
already run.
Nor does the code have to be a modified workflow file. A workflow that runs a
@@ -34,19 +40,110 @@ and **whatever credentials the run is handed**, including the token the forge
injects into every job whether the workflow asks for one or not.
There is deliberately no approval gate offered for this. Every version of one
— changing the trigger, dispatching by hand, leaving the runner stopped, the
forge's own fork-PR approvals, filtering egress from inside the workflow — is
(changing the trigger, dispatching by hand, leaving the runner stopped, the
forge's own fork-PR approvals, filtering egress from inside the workflow) is
reachable from the branch, covered by a credential the session already holds,
or not a gate at all. The mitigations are environmental instead: a runner host
you are willing to lose, job containers with no Docker socket, deny-by-default
egress whose allowlist is read from `main` rather than from the branch, and no
secret a run does not need. And where none of that is available, declining the
push grant declines the code execution with it — `[core] forge = none` with
push grant declines the code execution with it: `[core] forge = none` with
`[core] workflow = local-only`.
This applies to any project developed in a dejima sandbox, not only to this
repository. **→ [docs/ci.md](ci.md)** for the reasoning and the setup.
## To your forge, the sandbox is you
By default a session authenticates with your credentials: your SSH key through
the forwarded agent, and a token generated under your account. A push, a pull
request, a comment or a merge made from inside the sandbox is therefore
**indistinguishable at the forge from one you made yourself**, not similar to
it, identical.
The consequence is worth stating flatly, because it is easy to configure
around without noticing: **every control your forge offers that keys on
identity is a guard rather than a boundary.** Branch protection's merge
whitelist, its push whitelist, required approvals, and the audit log all
answer the question "who did this", and there is only one answer available.
Putting yourself on a merge whitelist puts the sandbox on it too.
Three specific consequences:
- **Required approvals are not merely weak here, they are unusable.** A forge
will not let a pull request's author approve it, and the sandbox opens pull
requests as you, so requiring one approval means nobody can give it.
- **The forge's audit trail cannot attribute.** "You pushed this" and "a
session pushed this" are the same recorded event. This is the same shape as
the exit-time audit's time-window caveat above: a record of what happened,
not of who caused it.
- **It compounds the SSH agent's reach.** The agent already signs for any repo
your key can reach (below); the identity is what makes the resulting action
land as yours everywhere it reaches.
### Give the sandbox its own identity
This is configuration, not a missing feature. Create a dedicated forge account
with **write** access to the repositories a session should work on, and point
the sandbox at it instead of at you:
- `[forge.gitea] token_file`: that account's token, rather than one issued
under yours.
- `[core] ssh_key`: a deploy key belonging to it, rather than your forwarded
agent. See "Headless git push without agent forwarding" in
[docs/configuration.md](configuration.md).
With the two identities separated, the same settings stop being decorative:
| Setting | What it becomes |
| --- | --- |
| Merge whitelist: you | The session genuinely cannot merge |
| Push whitelist on `main`: you, or nobody | The session pushes feature branches and nothing else |
| Require approvals ≥ 1 | Usable: the session authors, you approve |
That last row is the one that turns "the operator merges, the agent does not"
from a convention into something the forge enforces.
**Verify which credential a push actually uses.** Nothing here enforces one:
`[core] ssh_key` is a second, independent credential path (below), so if your
own agent is *also* forwarded, a push may still be signed by your personal
key and the separate identity becomes decorative for exactly the operation it
was meant to constrain. Make a test push and read what the forge attributes
it to, rather than assuming.
**Write access, and specifically not ownership.** Add the account as a
collaborator on the repositories it works on. Write is the ceiling here, not
a floor: an account that owns a repository, or holds admin on it, can merge
its own work and edit the settings in the table above, so every row of that
table goes back to being decorative. `write:repository` and `write:issue` are
the scopes its token needs (README, "Gitea/Forgejo PR workflow"), and nothing
about that changes for a collaborator.
One consequence presents as a misconfiguration, and the obvious fix is the
wrong one. Parts of Gitea's API are gated on *ownership* rather than on
permission: `GET
/repos/{owner}/{repo}/actions/runs` answers a non-owner with `"user should be
the owner of the repo"` no matter which scopes the token carries. Read that
way it looks like a scope error, and the reflex is to widen the token. That
cannot work, since no scope grants ownership, and its only real remedy is
handing the sandbox your own token, which would undo this whole section.
`gitea-actions` therefore reads CI through `actions/tasks`, the commit's
statuses and the run view's log route, none of which are owner-gated. If some
future need lands on an owner-only endpoint, leaving that capability
unavailable is the better trade than promoting the account.
Two things a separate identity does **not** do:
- It does not reduce what a compromised session can do inside the container,
or to the mounted project. It changes what that session can do *to your
forge*, and who the forge thinks did it.
- It does not touch CI. A write-capable account can still push a feature
branch, and pushing a branch is what makes CI execute code; see the
section above. It also has no bearing on the token the forge injects into a
CI job, which is a third principal again, subject to none of the settings in
that table. **→ [docs/ci.md](ci.md)**
## Provider trust
Forges, workflows and harnesses (`forges/`, `workflows/`, `harnesses/`) are
@@ -107,6 +204,8 @@ hands to whichever session starts against it. See
overlaid (they're config, not fixed paths a static mount can cover) and
remain fully writable; the exit-time audit below reports changes to
either, but only after the session ends - it doesn't stop them.
### The exit-time audit
- `bin/dejima-audit` snapshots host-persistence surfaces before each
run and diffs them after the container exits: `.git/config` (and worktree
`config.worktree`) keys that run a command on the next `git` -
@@ -119,26 +218,64 @@ hands to whichever session starts against it. See
appears in `.git/config` directly), `!`-shell aliases - plus content
changes under the workspace's `.claude/`, `.mcp.json`, `.envrc`,
`.vscode/`, and `.git/hooks/` (symlinks included - a repointed symlink is
hashed by its target, not just its content), plus cross-project writes
under `~/.claude/projects` (see below). When the mounted workspace *is*
this repo's own sandbox itself (see the trusted-computing-base note
further below), it also diffs content hashes of the sandbox's own trusted
computing base - `bin/`, `hooks/`, `docker/`, `install.sh`,
`test/` - under that workspace. It's **advisory only**: it fires *after*
the session has already ended, so a report of a planted `core.hooksPath`
or hook file is something to read and clean up *before* the next `git`
or `claude` in that repo, not something that was blocked. It's silent when nothing
changed, and never affects the session's own exit code even if it can't
complete. It doesn't cover `[core] share_dir` (see "Shared scratch
space") - that hand-off path stays unaudited - and its coverage is a
fixed list of known Claude Code config surfaces, not a general integrity
check, so a future config surface this list hasn't been taught about yet
would go unreported. **Its diff is time-windowed, not session-attributed:**
a finding means one of these paths changed while the session was running,
not that the session made the change. A concurrent dejima or Claude Code
session, another host process, or you editing the same files by hand
during the run all land in the same diff and get reported identically -
the tool has no way to tell them apart.
hashed by its target, not just its content). When the mounted workspace is
this repo itself, it also hashes the sandbox's own trusted computing base -
`bin/`, `hooks/`, `docker/`, `install.sh`, `test/`.
Worth knowing about that first surface, because the sandbox's own workflow
makes it easy to get wrong: **a git worktree isolates the working tree, not
`.git/config`.** Config is shared with the parent checkout, so a bare
`git config <key> <value>` inside a worktree writes the real repository's
config, including any exec key above. The briefing now says so, and gives
`git -C <path> config`. A guard against an unaware agent, not a boundary:
`.git/config` is in the read-write project mount and cannot be read-only
without breaking `git push -u`, which writes `branch.<name>.merge` on every
push. Detection covers the rest, and did - this was found by the audit
reporting a `.git/config` change.
**Advisory only.** It fires after the session ended, so a planted
`core.hooksPath` is something to clean up before the next `git` or
`claude` in that repo, not something that was blocked. Silent when nothing
changed, and never affects the session's exit code. It does not cover
`[core] share_dir` (see
[Shared scratch space](configuration.md#shared-scratch-space)), and its
coverage is a fixed list rather than a general integrity check, so a config
surface it has not been taught about goes unreported.
**Its diff is time-windowed, not session-attributed:** a finding means a
path changed while the session ran, not that the session changed it. A
concurrent session, another host process, or your own editing all land in
the same diff, reported identically.
- **Approving and ignoring are different decisions, and the difference is
what you are promising about next time.** Approving records the exact
content: `safe <id> <sha>`, so this file with these bytes is fine and any
other content re-fires. Ignoring records the surface: `spam <id>`, so this
path or key is fine whatever it later contains. Approve a hook you have
read; ignore a key some tool rewrites on every run and which you have
decided you will never care about. The review offers both, and says which
is which rather than leaving you to infer it:
```
[a] approve this exact content [i] ignore this key from now on [s] skip (ask again next time) [q] quit
```
A finding whose file has since been deleted is **cleared** rather than
approved: there is no content left to pin an approval to, and a surface
that does not exist does not run. Nothing is recorded, so if it comes back
it comes back as a new finding. Refusing instead - the old behaviour - left
the report unresolvable, and because `ack` opens CRITICAL reports first it
hid every other report behind that one.
Skipping asks again next session, the right default when you are unsure.
It matters for keys some tools rewrite on every run: with no decision
recorded, one re-fires every session and pushes real findings out of the
capped list.
Severity is not negotiable by either verb. An exec-capable key or shell
alias is CRITICAL whatever the baseline says, and an `ignore` entry is
refused against a CRITICAL surface when the baseline is read, not only when
written - so neither decision can quietly downgrade something that matters.
- `dejima-audit ack` shows what it is asking you to approve. For a surface
that changed during the session it prints a unified diff of the
pre-session content against the current content; for one created during
@@ -151,21 +288,19 @@ hands to whichever session starts against it. See
live on disk); a removed one stores nothing at all, because a deletion
diff *is* the whole former file.
Both the staging directory and the retained diffs live under
`$AUDIT_DIR` (`~/.local/share/dejima/audit` by default), never under
`/tmp`. That is the boundary the whole scheme rests on:
`docker-compose.yml` mounts nothing covering it, so the container cannot
read the retained content of earlier runs - `test/static.bats` pins that
as a regression guard. If the store is configured *inside* the mounted
workspace it becomes readable and deletable from within the sandbox, so
in that case no content is retained at all - only a note saying why. A
session killed with `SIGKILL` (no EXIT trap runs, so no `compare`)
leaves its staging directory behind; the next run against that
workspace reaps it, identifying dead owners by pid and process start
time recorded in an `.owner` file, so a concurrent session against the
same workspace is never mistaken for dead and disturbed. A directory
whose owner record is missing or unreadable falls back to a 24-hour age
check instead of guessing liveness.
Staging and retained diffs live under `$AUDIT_DIR`
(`~/.local/share/dejima/audit`), never `/tmp`. That is the boundary the
scheme rests on: nothing in `docker-compose.yml` covers it, so the
container cannot read earlier runs' retained content, and `static.bats`
pins that. A store configured *inside* the workspace is readable and
deletable from the sandbox, so nothing is retained in that case - only a
note saying why.
A `SIGKILL`ed session runs no EXIT trap and leaves its staging directory;
the next run against that workspace reaps it, identifying dead owners by
pid and process start time from an `.owner` file so a concurrent session is
never mistaken for dead. A missing or unreadable owner record falls back to
a 24-hour age check rather than guessing.
Content is withheld from the store in several further cases, and in
every one `ack` prints the reason in place of the diff - so an absent
@@ -180,101 +315,121 @@ hands to whichever session starts against it. See
existed before the session but was too large to have been staged (over
1 MiB) withholds with "no pre-session copy", not a false claim that it
is new. The *current* side is bounded by that same 1 MiB cap, and - like
the staged side - the size is checked *before* any copy is taken: a file
the session grew past it withholds with "change too large to retain"
rather than being copied through `$TMPDIR` first. That ordering is the
point, not the cap: `compare` runs from the exit trap on every session
exit and the size is the session's own choice, so copying first would
let a session turn "I filled my own workspace" into that many bytes
written to `$TMPDIR` - frequently a tmpfs, i.e. host RAM - at exit, and
into a whole copy of the file left sitting there if that exit was a
`SIGKILL`. A generated diff is withheld if it is binary, over 200 lines
the staged side - checked *before* any copy is taken: a file the session
grew past it withholds with "change too large to retain" rather than
being copied through `$TMPDIR` first. The ordering is the point, not the
cap. `compare` runs from the exit trap on every exit and the size is the
session's own choice, so copying first would turn "I filled my own
workspace" into that many bytes written to `$TMPDIR` - often a tmpfs, so
host RAM - and into a whole copy left there if the exit was a `SIGKILL`.
A generated diff is withheld if it is binary, over 200 lines
or 64 KiB (and is bounded to that as it is generated, for the same
reason), or matches a best-effort credential screen - vendor prefixes
including `sk-ant-`, `sk-proj-`, `ghp_`, `gho_`, `github_pat_`,
`glpat-`, Slack's `xox[baprs]-`, `AIza`, `AKIA`, `_authToken`,
`Authorization: Bearer`, and PEM private-key headers, matched
case-insensitively (deliberately: the one high-value surface this is
the only net for, `.claude/settings.json`'s `env` block, conventionally
holds its keys upper-case). That screen is still explicitly best-effort
and has real false negatives - the denylist above is the reliable half
of the defence, this is the second net. A git config key holding more
than one value on either side of the session is not compared at all:
git resolves such a key to its *last* value in *file* order, and the
audit does not resolve it - it parses the config files and sorts, so
lexicographically-last is not the value git will run, and the
pre-session side is every value the key had rather than one. Comparing
anyway produced a diff over values git does not use: with two
pre-session values it read as a deletion that never happened, while the
value the session actually appended - the one git now executes -
appeared nowhere in it. That case withholds with "this key has multiple
values and git uses the last", and must be compared by hand;
`include.path` is legitimately multi-valued, so this is an ordinary
shape, not only a planted one. A generated diff can also still come out
empty - before and after compare equal even though the finding was
reported as a change - when the config file's *text* changed while the
parsed value did not, e.g. a value that was re-quoted; that case
withholds too, saying plainly that the comparison came out empty rather
than silently showing nothing, since an empty diff for a reported
change would otherwise read as a bug.
case-insensitively, since `.claude/settings.json`'s `env` block - the one
high-value surface this is the only net for - conventionally holds its keys
upper-case. The screen is best-effort with real false negatives: the
denylist above is the reliable half.
A per-report **retention budget** (`RETAIN_BUDGET`, 1 MiB) also bounds
the total diff bytes one report may keep. Once it is spent, remaining
findings in that report withhold with a note that the budget ran out,
and both the report and the exit-time stderr summary add an alarm line:
that many changed host-persistence surfaces in one session is treated
as a signal in its own right, not merely a limit worth raising - an
ordinary session changes few enough of these surfaces that the budget
is never in practice a constraint. `diffs = off` in the config's
`[audit]` section disables retention (and staging) entirely.
A git config key with more than one value on either side is not compared.
Git resolves such a key to its last value in FILE order; the audit parses
and sorts, so lexicographically-last is not what git runs, and the
pre-session side is every value the key had. Comparing anyway produced a
diff over values git does not use - two pre-session values read as a
deletion that never happened, while the appended value git now executes
appeared nowhere. It withholds instead, and must be compared by hand.
`include.path` is legitimately multi-valued, so this is an ordinary shape.
Two residual limits worth knowing on top of the credential screen's
false negatives above. The prompt shows at most 40 lines of a diff or
of new content before truncating, naming the file so you can read the
rest - a large planted change is truncated exactly where you would most
want it whole. And the `SIGKILL` case above is a real exposure window,
not just a cleanup mechanic: until the next run against that workspace
reaps it, the staging directory sits on disk holding whole pre-session
file copies (not diffs) of every monitored surface, for as long as the
workspace goes unused.
- Report findings name a path or config key that a session may itself have
chosen, so those names reach your terminal as untrusted text. One that
contains an ASCII control character, a C1 control, or a Unicode bidi
override - which between them can scroll, blank, or visually reorder what
you read, and can split a name across the audit's own records - is
written hex-encoded behind a `{hex}` marker instead, and decodes with
`printf '%b' "$(sed 's/../\\x&/g' <<< HEX)"`. Ordinary names are printed
as-is, so the marker appearing at all is itself worth a second look.
The encoded form is what `dejima-audit ack` records in the baseline, so
what you acknowledge is the file that is actually on disk rather than a
A diff can also come out empty when the config file's TEXT changed but the
parsed value did not, e.g. a re-quoted value. That withholds too, saying so
rather than showing nothing - an empty diff for a reported change reads as
a bug.
A per-report **retention budget** (`RETAIN_BUDGET`, 1 MiB) bounds the total
diff bytes one report keeps. Once spent, the rest withhold and both the
report and the stderr summary add an alarm line: that many changed
host-persistence surfaces in one session is a signal in itself, not a limit
worth raising. `diffs = off` in `[audit]` disables retention and staging.
Two residual limits on top of the screen's false negatives. The prompt
truncates at 40 lines, naming the file so you can read the rest - a large
planted change is cut exactly where you would want it whole. And the
`SIGKILL` case is an exposure window, not just a cleanup mechanic: until
the next run reaps it, the staging directory holds whole pre-session file
copies of every monitored surface, for as long as the workspace goes
unused.
- Findings name a path or config key the session may have chosen, so they
reach your terminal as untrusted text. One containing an ASCII or C1
control or a Unicode bidi override - which can scroll, blank or reorder
what you read, and split a name across the audit's own records - is written
hex-encoded behind a `{hex}` marker and decodes with
`printf '%b' "$(sed 's/../\\x&/g' <<< HEX)"`. Ordinary names print as-is,
so the marker appearing is itself worth a look. The encoded form is what
`ack` records, so what you acknowledge is the file on disk rather than a
readable approximation of its name.
- `~/.claude/projects` (session transcripts and per-project memory) is
**not** mounted read-only, unlike the code-carrying `~/.claude`
directories - deliberately. Scoping a read-only mount to "the
current project's slug"
would break the sandbox's own worktree workflow: a session running in
`WORKSPACE/.worktrees/<branch>` writes to a *different* projects slug
than the parent repo, so a slug-scoped overlay would either block that
legitimate write or have to be widened until it stopped protecting
anything. Instead, cross-project writes are **detected, not prevented**:
the exit-time audit above flags any write during the session to another
project's `~/.claude/projects/<slug>` directory, excluding the current
project's own slug and its worktree children. That's after-the-fact
reporting of transcript/memory poisoning in *other* projects, not a
block on it happening. Residual even within that: the current project's
own transcripts/memory stay fully writable throughout, since Claude Code
needs that for host-side `claude --resume` to work; and detection keys
off each slug's max file mtime, so an edit to a file that isn't already
that project's newest doesn't advance the max and goes unflagged. A
read-only mount for `projects/` remains a possible future option if
Claude Code is confirmed to fail cleanly (not hang, as `~/.claude.json`
did) when a write to it is blocked. This is also the sharpest case of
the time-window caveat above: `~/.claude/projects` is a single
directory shared by *every* Claude Code session on the host, not scoped
to the one being audited, so an entirely innocent concurrent session
working on some unrelated project can produce a `CROSS-PROJECT` finding
here that reads like the audited session poisoned another project's
history - it didn't, it just happened to be running at the same time.
**not** mounted as a tree. A session gets the workspace's own slug
directory plus one for each live git worktree nested under it,
read-write, and nothing else. Every other project's slug is *absent*
from the container rather than present-but-unwritable. Agent memory is
loaded into context at session start, so a write into another project's
`memory/` is text a future session there reads as established background
rather than as input - and until #104 the mount alone was enough to do
that, with no escape and no credential involved.
The set is derived from real worktree paths
(`bin/dejima-slug-lib.sh`), not from the slug string, because a slug
cannot tell a child from a sibling: `/w/proj/wt1` and `/w/proj-wt1`
produce the same one. A `<slug>-*` glob therefore also matches any
project checked out beside this one whose directory name merely extends
it, which is an ordinary layout rather than a contrived one.
`projects/` itself stays container-local, so a slug that is not mounted
is still writable during the session and just does not survive it. That
is what lets Claude Code create a slug it does not find, which is the
first-run case it already handles, and it is why an absent sibling does
not risk the hang that a blocked write to `~/.claude.json` caused.
Residual: the workspace's own transcripts and memory stay fully writable
throughout, since Claude Code needs that for host-side `claude --resume`.
There is no longer a `CROSS-PROJECT` finding for this. It existed when the
whole tree was mounted and a session really could write another project's
slug. Now that it cannot, the only writes the audit could still see there
are other sessions doing their own work - and on a host running several at
once it reported all of them, on every report. Detection that cannot
observe what it was built for, and fires constantly on what it was not,
trains an operator to skim. The mount is the control, and `static.bats`
pins it.
- `~/.claude/file-history` (Claude Code's edit-undo record) is **not**
mounted as a tree either, and the reason is sharper than for `projects/`.
Each `<session>/<hash>@v1` is a **verbatim copy of an edited file**, so the
tree is other projects' source rather than a record about it. Read access
alone is the leak; it was read-write on top of that, and tampering with
another project's undo record puts chosen content into a later restore.
It is keyed by session id, so unlike `projects/` there is no slug in its
own paths to scope it by. The transcript names supply it: `projects/<slug>/
<id>.jsonl` and `file-history/<id>` name the same session, so the slugs the
workspace owns select the session ids it owns
(`workspace_session_ids` in `bin/dejima-slug-lib.sh`). A directory whose
session has no transcript belongs to no project dejima can identify, and is
left out.
The session about to start has an id nothing can know in advance, so its
own directory is created container-local and does not survive the run.
That costs undo history for that session if it is later resumed on the
host, and it is the reason this cannot be scoped as completely as
`projects/`, where the workspace's own slug is known at launch.
- Epitaphs - the summary a session leaves when its context is compacted away -
are kept host-side in `[epitaph] dir`, and that directory is never mounted. A session can neither read what earlier
sessions concluded nor edit what this one is about to record, which is the
same arrangement as the audit store and for the same reason. The transcript
they are extracted from *is* session-writable, so a summary records what was
there rather than vouching for it - and nothing loads one back into a later
session, deliberately: that would be the channel #104 closed, rebuilt.
- Allowing egress to a domain at all creates a channel for exfiltrating data
through *that service's own request surface* (query strings, lookups,
etc.), not just to a literal attacker-controlled destination. Domain-level
@@ -288,51 +443,45 @@ hands to whichever session starts against it. See
similar. It shares the host kernel, so a container-escape vulnerability
would defeat every other control described here at once.
- Most of what this document asserts is re-checked by the `test/` suite on
every change - over 300 bats tests across six files, and `runtime.bats`
builds the image and asserts against a real running container rather than
a mock: IPv6 blocked, a non-allowlisted domain refused by the DNS proxy
and a non-allowlisted destination unreachable, `node` unable to re-run
`init-firewall.sh` to widen the allowlist, no working `sudo`. Coverage is
not the same as completeness, though. The reasoning in this document is
judgement rather than assertions, nothing tests it, and a property nobody
thought to write a test for can still regress quietly. A missing `ipset`
dedup flag broke firewall startup entirely - twice, on two different
ipsets - before anyone caught it; there is a test asserting every
`ipset add` is duplicate-safe now, which is the pattern to expect. Each
of these tests exists because something was got wrong first.
every change, and `runtime.bats` asserts against a real running container
rather than a mock: IPv6 blocked, a non-allowlisted domain refused and a
non-allowlisted destination unreachable, `node` unable to re-run
`init-firewall.sh`, no working `sudo`. Coverage is not completeness: the
reasoning here is judgement, nothing tests it, and a property nobody wrote
a test for can regress quietly. A missing `ipset` dedup flag broke firewall
startup twice before anyone caught it; there is a test for that now, which
is the pattern to expect - each of these exists because something was got
wrong first.
- The Anthropic OAuth credential (`~/.claude/.credentials.json`) never
enters the container. It simply isn't mounted: `~/.claude` is not shared
as a whole (see the mount inventory below), so there is no path from
enters the container. It isn't mounted: `~/.claude` is not shared as a
whole (see the mount inventory below), so there is no path from
inside the container to that file. `dejima` starts a host-side proxy
(`bin/dejima-anthropic-proxy`) on a per-session unix socket and mounts a
fake credential in place of the real one - the two secrets are
placeholders, while `scopes`/`subscriptionType`/`rateLimitTier`/`clientId`
are copied from the real file so the session's own UI stays accurate. Its
expiry is set a year out rather than mirrored, deliberately: a near-term
one would make Claude Code refresh on its own, with a refresh token that
isn't real, and log the session out. Inside the container,
fake credential in place of the real one - the secrets are placeholders,
while `scopes`/`subscriptionType`/`rateLimitTier`/`clientId` are copied so
the session's UI stays accurate. Its expiry is a year out rather than
mirrored: a near-term one would make Claude Code refresh on its own with a
refresh token that isn't real, logging the session out. Inside the
container,
`entrypoint.sh` points `api.anthropic.com` at a loopback TLS terminator
(`anthropic-proxy-forwarder.js`) whose certificate is generated at startup
and trusted only there, and which pipes raw bytes into the proxy's socket.
The proxy injects the real access token per request and owns OAuth refresh
- rotated tokens are written back to the real host file atomically, so
host-side sessions stay logged in. Claude Code itself is configured
exactly as it would be on the host: no `ANTHROPIC_BASE_URL`, no
`ANTHROPIC_AUTH_TOKEN`, nothing that marks the session as unusual to the
client. The proxy then forwards the client's header block verbatim -
same names, same casing, same order, repeats intact, and no
`Accept-Encoding` it didn't send - changing only the `Authorization`
value, and `anthropic-beta` if the OAuth value isn't already there. Left
to itself, Python's `http.client` would have rewritten all of that into
its own house style and moved the fingerprint from the client to the
wire. What it cannot disguise is that the connection is made by CPython's
`ssl` rather than by Node: the TLS handshake (JA3/JA4) and the absence of
connection reuse still distinguish it at that layer. What remains is
session-scoped: a
compromised session can still *use* the model API as you while it runs
(that's inherent to the sandbox working at all), but it can no longer read
a durable credential and exfiltrate it for offline use via the other
allowed egress destinations (npm, GitHub, your Gitea host). Caveats:
exactly as on the host: no `ANTHROPIC_BASE_URL`, no
`ANTHROPIC_AUTH_TOKEN`, nothing marking the session as unusual. The proxy
forwards the client's header block verbatim - same names, casing, order and
repeats, and no `Accept-Encoding` it didn't send - changing only
`Authorization`, and `anthropic-beta` if the OAuth value isn't there. Left
alone, Python's `http.client` would rewrite all of that into its own style
and move the fingerprint from the client to the wire. It cannot disguise
that the connection is CPython's `ssl` rather than Node: the TLS handshake
(JA3/JA4) and the absence of connection reuse still distinguish it.
What remains is session-scoped: a compromised session can still *use* the
API as you while it runs, which is inherent to the sandbox working at all,
but it cannot read a durable credential and exfiltrate it for offline use.
Caveats:
`/login` inside the container doesn't work while the proxy is on; the
refresh flow mirrors a Claude Code internal and may break if upstream
changes it, in which case the proxy fails loudly (launch abort, 502s on
@@ -386,13 +535,21 @@ hands to whichever session starts against it. See
are enumerated in `docker-compose.yml`, in three classes, and anything not
listed is container-local: writable during the session and gone with the
container.
- **Read-write, because it has to survive the session**: `projects/`,
`file-history/`, `backups/`, `tasks/`, `plans/`, `sessions/`. All
directories, which is what makes them safe (see the detachment note
below).
- **Read-write, because it has to survive the session**: `backups/`,
`tasks/`, `plans/`, `sessions/`, plus the entries of `projects/` and
`file-history/` that belong to this workspace. All directories, which is
what makes them safe (see the detachment note below). The two trees are
not mounted whole: see the `projects/` note further down, and the
`file-history/` note below it.
- **Read-only**: `plugins/`, `hooks/`, `skills/`, `commands/`, `agents/`,
`output-styles/`, plus the symlink-target directories `dejima`
discovers at launch. Hooks execute automatically with no separate
discovers at launch. Those targets are discovered rather than
configured, so they are checked against the approval store and the
audit stores and refused if they overlap either - a hook symlinked into
the approval store used to mount it, read-only and silently, and
read-only is no protection when the content is every project's
approvals. The same check now covers `[mounts] readonly`, which had
none at all. Hooks execute automatically with no separate
approval step, skills can ship executable scripts, and
commands/agents/output-styles are prompt text loaded into a later host
session, so without this a compromised session could plant a backdoored
@@ -411,8 +568,7 @@ hands to whichever session starts against it. See
`statusline.sh` are mounted read-only at `/etc/dejima-claude/` and
installed into `~/.claude` by `entrypoint.sh` as root-owned, unwritable
files. A session can still unlink and replace them - `~/.claude` belongs
to it - but only its own copy, which was the whole point of making them
read-only.
to it - but only its own copy, which is why they are read-only.
The reason for the split is that a **single-file bind mount silently
detaches**. It is pinned to the dentry at its mount point, so when the
@@ -501,8 +657,7 @@ hands to whichever session starts against it. See
sandboxed session (e.g. a token refresh) doesn't carry back out. Worth
re-checking if a future Claude Code version starts relying on that for
normal operation.
- DNS resolution itself is allowlisted (see "DNS is allowlisted too" in the
README),
- DNS resolution itself is allowlisted (see "DNS is allowlisted too" below),
not just the IP connections made afterward, closing off exfiltration via
encoding data into query names to an attacker-controlled domain - verified
a non-allowlisted domain gets refused outright (`status: REFUSED`) rather
@@ -518,9 +673,10 @@ hands to whichever session starts against it. See
is given, or `[core] forge = github` is selected with a workflow that
pushes (which opens the same grant), so the forwarded agent otherwise has
no transport to use for it at all (see "GitHub push: blocked by default,
optional opt-in" above).
- `[core] ssh_key` (see "Headless git push without agent forwarding") is a
second, independent credential-loading path with the same "signs for any
optional opt-in" below).
- `[core] ssh_key` (see [Headless git push without agent
forwarding](configuration.md#headless-git-push-without-agent-forwarding))
is a second, independent credential-loading path with the same "signs for any
Gitea repo it has access to" shape as the forwarded agent above - the
mitigation is that it's meant to be a dedicated deploy key scoped to only
the repos that actually need push access, not your personal identity.
@@ -542,7 +698,7 @@ hands to whichever session starts against it. See
SSH-agent/token story documented above. Keep credentials out of your
global gitconfig, or expect them to be reachable from inside the
sandbox.
- The sandbox's own user (matching your host user — see "Config parity" in
- The sandbox's own user (matching your host user; see "Config parity" in
the README) has no `sudo`/setuid path back to root once the container is up:
`entrypoint.sh` runs as root, sets up the firewall directly, then drops to
that user via `gosu` before execing the configured command (`[core] command`,
@@ -552,7 +708,9 @@ hands to whichever session starts against it. See
cleared from the effective/permitted sets on the `gosu` drop, and that
user gets a permission error running `iptables` directly.
- The approval store (`${DEJIMA_CONFIG_DIR:-$HOME/.config/dejima}`,
see "Global and per-project domains and dependencies" above) lives outside
see
[Global and per-project domains and dependencies](../README.md#global-and-per-project-domains-and-dependencies)
in the README) lives outside
this repo, and outside every project mount, by default - unlike a
project's own `.dejima/` declaration files, it's never
bind-mounted into the container, so a compromised
@@ -562,8 +720,8 @@ hands to whichever session starts against it. See
`bin/dejima-project-lib.sh`), never sourced or executed, and are
written only by the CLIs (`dejima-allow`, `dejima-dep`,
`dejima-project`) a human runs on the host. `dejima`
warns at launch whenever the resolved store path — default or overridden
via `DEJIMA_CONFIG_DIR` — lands under the mounted workspace,
warns at launch whenever the resolved store path (default or overridden
via `DEJIMA_CONFIG_DIR`) lands under the mounted workspace,
rather than silently trusting it.
- When the mounted workspace *is* this repo itself, `dejima`
warns at launch that the repo's own trusted computing base - `bin/`,
@@ -575,3 +733,344 @@ hands to whichever session starts against it. See
session can still edit those files during the run, and whatever it
changes takes effect the *next* time `dejima` itself runs,
not the current one.
The report splits on whether git can see the change. tcb_snapshot only
populates when the workspace IS dejima's own repo, so an ordinary change
there is already in the working tree you are about to read in `git status`
and in the pull request - reporting each one as a CRITICAL to acknowledge
duplicated a stronger control and made developing dejima itself unusable.
Those are counted in one INFO line instead.
A change git *cannot* see stays a CRITICAL finding, because nothing else
would catch it: a file in `.gitignore` or `.git/info/exclude`, or one
marked `assume-unchanged` or `skip-worktree`, is live code that runs on the
next launch and appears nowhere in `git status`.
### Declaration files are session-writable
A project's `.dejima/` declaration files sit inside the project mount, which
is bind-mounted read-write. A compromised session can write them, and that is
fine by design: declaring grants nothing, and the approval store the session
cannot reach is what a declaration has to pass through.
What a session *could* try is to make the host-side write land somewhere else.
Plant `.dejima`, or `.dejima/<kind>`, as a symlink pointing at your approval
store or any other file you can write, then get you to run the
`dejima-allow --project` or `dejima-dep add` that appends to it. The append
happens on the host, as you, so it inherits your write access rather than the
container's.
Both writers refuse a symlink at either of those two leaves. The refusal is
the `open` itself and not a check before it: `O_NOFOLLOW` on both, with the
file opened relative to the already-opened directory, so a session still
running while you type the command cannot swap either one out from under the
write. Those two leaves are what a session controls. A symlink further up a
`--project` path you supplied yourself is still resolved through, because
that part of the path is your claim about where the project is, not the
session's.
Expect the session to be live when you run these: the agent briefing tells it
to ask you to run them from a terminal outside the sandbox, which is exactly
the window this closes.
## Network egress firewall
On start, the container runs an allowlist-only `iptables`/`ipset` firewall
(`init-firewall.sh`), matching the reference dev container. Allowed by
default:
- DNS resolution via a local proxy (see "DNS is allowlisted too")
- the Docker host itself (not its whole subnet)
- GitHub's published IP ranges, port 443 only (see "GitHub push" below)
- `registry.npmjs.org` and `platform.claude.com` (the harness and its
updates), `release-assets.githubusercontent.com` (GitHub release assets, a
separate CDN from github.com's own IPs, and where a good deal of tooling
fetches its binaries); `api.anthropic.com` when the credential proxy is off
(kill switch or API-key auth)
That is the whole built-in list, and it is meant to stay that way: it covers
what the sandbox itself cannot run without, not what a project might want.
Language package hosts are approved rather than assumed: `pypi.org`,
`proxy.golang.org`, `crates.io` and the rest are one `dejima-allow --global`
or one reviewed `.dejima/allowed-domains` line away. The reason to keep the
built-in list short is that everything on it is reachable from every sandbox
on the host whether or not a project has any use for it, and a name granted
by being built in is a name nobody ever reviewed
- whatever the selected forge needs reachable (`[forge.gitea] ssh_host`/`api`
by default): not port-restricted, so git-over-ssh to the forge works
without a blanket "port 22 to anywhere" rule, and exempted from DNS-rebind
protection because a self-hosted forge host legitimately resolves to an
internal address. `[core] forge = github` is deliberately *not* added here:
its three hostnames are public names already covered by the GitHub entry
above, so they stay on that entry: 443 by default, plus port 22 when the
selected workflow pushes, exactly as `--allow-github-push` opens it per
launch, rather than picking up all-port egress and losing rebind
protection on the way
Everything else is rejected. Run `dejima-allow --global <domain>`
(or `--project <path>` for one project) to allow more permanently (e.g.
`crates.io` for a Rust project), or `dejima --no-firewall` to disable the
firewall entirely for one launch (debugging only).
IPv6 is blocked outright (`ip6tables` DROP on INPUT/OUTPUT/FORWARD, `dnsmasq`
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.
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.
### DNS is allowlisted too
Resolution itself is restricted, not just the IP connections made
afterward: `dns-proxy.sh` runs a local `dnsmasq` with no fallback resolver,
and `/etc/resolv.conf` is repointed at it. Only names on the same allowlist
above (plus `github.com`/`githubusercontent.com`/`githubassets.com`, needed
since GitHub itself is allowlisted by IP range; see `fixed-domains.sh`) get
forwarded to Docker's real resolver; anything else gets no upstream
configured at all and is refused outright. Without this, a compromised
session could exfiltrate data by encoding it into query names to an
attacker-controlled domain, even with no direct connection ever possible.
### Adding a domain to a running session
Approved domains only take effect at container start. For a domain a
*running* session needs right now, run from the host (not inside the
sandbox: it has no privilege to do this itself):
```sh
dejima-allow crates.io # this running session only
dejima-allow --container <id> crates.io # a specific session, if more than one is running
```
It first adds the domain to the running dnsmasq's allowlist (appends a
`server=` line to its servers-file and sends `SIGHUP` to reload just that
file - no restart), then resolves it through the now-unblocked container
resolver and adds the IP(s) to the running firewall's `ipset`, all via
`docker exec -u root`. It finishes by checking the domain is actually
reachable from inside that same container and prints a definitive verdict:
exit code 2 if it isn't.
`--global` and `--project` are the opposite of this: they record an approval
for *future* sessions and deliberately do not touch a running container, even
together - see
[Global and per-project domains and dependencies](configuration.md#global-and-per-project-domains-and-dependencies)
in docs/configuration.md, since the domain being approved may legitimately
not answer on 443 (an
internal service, an SSH-only host) and the natural moment to add an approval
is precisely when nothing is running to probe in the first place. Pass
`--container <id>` alongside a scope flag to do both in one call:
`dejima-allow --global --container <id> crates.io` records the approval and
patches that session immediately, in that order, so the approval is never
lost to a live-session failure. If more than one dejima container is
running, the bare form above refuses and asks you to disambiguate with
`--container <name>` - `docker ps` finds it, or use the
container's own ID, which `sandbox-context.sh` already hands the agent in
its startup context (and which is always available inside the sandbox as
`cat /etc/hostname`), so there's no need for it to guess one and risk
"successfully" updating someone else's session instead.
A domain allowed this way stays allowed for the rest of the session even
if its CDN later rotates edge IPs: `dns-proxy.sh` tops up the firewall's
`ipset` from every DNS answer it serves, not just the one resolved at
allow-time, since only allowlisted names get an upstream to answer from in
the first place. GitHub's own domains sit outside this - their existing
port-443-only `ipset` entries still win by dnsmasq's longest-match rule -
so that restriction is unaffected.
One gap remains: allowing an *internal* hostname (one that resolves to an
RFC1918 address) mid-session doesn't work. dnsmasq's `stop-dns-rebind`
rejects private answers for any domain not exempted at startup, and that
exemption list can only be extended by restarting dnsmasq. Internal hosts
named before the container starts (`[forge.gitea] ssh_host`, `api`,
`[logging] loki_url`, and any globally or per-project approved domain) are
already exempted and unaffected by this.
The sandbox-context.sh `SessionStart` hook tells the agent to ask for this
by name when a task is blocked on a single missing domain, rather than
stopping the whole session over it.
## Network request logging
If `[logging] loki_url` is set (see Prerequisites in the README), every DNS query and every new
outbound connection attempt - allowed or rejected - is shipped to that Loki
endpoint. Two sources, both tailed and pushed by `log-shipper.sh`:
- `dns-proxy.sh`'s dnsmasq logs every query and whether it was forwarded
(allowed) or refused (not on the allowlist) - labeled `job=dejima-dns`.
- `init-firewall.sh` tags its terminal ACCEPT/REJECT rules with `NFLOG`
(group 1 for allowed, group 2 for rejected), which `ulogd2`
(`ulogd.conf`) turns into JSON lines with source/dest IP, port, and an
`action` field - labeled `job=dejima-net`. Only new connection
attempts are logged, not every packet: the firewall's existing
ESTABLISHED,RELATED rule short-circuits the rest of a connection before it
ever reaches the tagged rules.
This is visibility on top of the firewall/DNS-proxy enforcement above, not a
substitute for it - nothing about logging changes what's actually allowed to
resolve or connect.
### Conversation logging (opt-in)
Set `[logging] conversation = on` (with `loki_url` configured) to also ship
session content to Loki as the `dejima-conversation` job: your
prompts, the agent's text responses, and tool invocations (tool name +
input - never tool outputs). Lines are JSON with the session ID inside
(`{session, role, text}` / `{session, role: "tool", tool, input}`).
Off by default - conversation content is a different sensitivity class
than DNS names and firewall verdicts, so it never leaves the container
unless you opt in. Transcripts of every session under the mounted
workspace (including worktrees and subagent sessions) are shipped while
the sandbox runs.
### Viewing logs in Grafana
Nothing sandbox-specific needed on the Grafana side beyond a Loki data
source pointed at wherever `[logging] loki_url` resolves to (Connections → Data
sources → Loki, if one isn't already configured against that instance) -
`log-shipper.sh` pushes directly to Loki's HTTP API, there's no Prometheus
scrape target or exporter involved.
In Grafana's Explore view (or a dashboard panel with a Loki query), each
stream is selectable by its `job` label:
```logql
{job="dejima-dns"} # every DNS query + verdict
{job="dejima-net"} # every connection attempt, as JSON
{job="dejima-conversation"} # the session itself, if opted in
```
Useful filters on top of those:
```logql
# DNS lookups that got refused (not on the allowlist)
{job="dejima-dns"} |= "REFUSED"
# Rejected connections only (parses ulogd2's JSON, filters on its action field)
{job="dejima-net"} | json | action="blocked"
# All three together, e.g. to watch a live session across sources
{source="dejima"}
```
The conversation stream, when `[logging] conversation` is on, is one JSON
object per line carrying `session`, `role`, and either `text` or a `tool` name
with its `input`:
```logql
# Just what you asked for, without the replies
{job="dejima-conversation"} | json | role="user"
# Every shell command a session ran, and with what arguments
{job="dejima-conversation"} | json | tool="Bash"
# One session end to end, id taken from a line you already found
{job="dejima-conversation"} | json | session="0198e2c1-..."
# Anything a session said about a subject, across every session
{job="dejima-conversation"} |= "allowed-domains"
```
`session` is deliberately a field inside the line rather than a Loki label:
one label per session would multiply the stream cardinality by the number of
sessions you have ever run, which is how a Loki instance stops being usable.
Filter on it with `| json | session=` as above rather than expecting a
selector.
Two things are absent by design. **Tool results are never shipped**, only
invocations, so a query will show you that a session ran `Bash` with certain
arguments and never what came back. **Thinking blocks are never shipped either.** Both dominate the volume
and neither was what opting in asked for. Long values are clipped, so a very
large prompt or tool input ends in `...[truncated]` rather than being dropped.
`{job="dejima-net"}` lines are one full ulogd2 JSON object per
connection attempt (`| json` in a query unpacks fields like `dest_ip`,
`dest_port`, `action`) - Loki's Explore view shows these expandable/
pretty-printed by default, no extra parsing setup needed.
## GitHub push: blocked by default, optional opt-in
`dejima --allow-github-push` is a per-launch *addition* to whichever forge is
selected, not a substitute for selecting one. A pushing workflow is still
refused at launch unless its forge resolves to a host with its required keys
filled in, so the flag alone will not get an unconfigured `[forge.gitea]` past
validation.
This section is about `--allow-github-push`, a per-launch flag that adds
GitHub as a push destination on top of whichever forge is selected, without
changing that selection. It adds a destination; it does not stand in for a
forge, so the launch-time rules for the selected forge/workflow pair still
apply exactly as they would without it. If you
want GitHub push and PRs as a standing choice instead, set
`[core] forge = github` (see
[Shipped providers](providers.md#shipped-providers)): it also opens
`ssh.github.com:22`, but persistently rather than per launch, and uses its
own `[forge.github] push_token_file` rather than a loaded SSH key.
**Why the default is off.** The principle is narrower than CI and holds
whether or not CI exists anywhere: *a session that is not being used to push
to a forge should not be able to push to it.* Your configured forge is the
one you chose to push to. GitHub is where everything else lives - every
dependency a session reads, every repository it clones to understand an
error - so that is where the rule bites by default.
The case is an eager agent, not a compromised one: it finds a real bug in an
unrelated open-source project and pushes a fix branch to somebody else's
repository. That this is not an attack does not make it harmless. Maintainers
are already triaging a flood of unsolicited AI-generated pull requests, and
one more arriving in your name, unreviewed, is a cost imposed on someone who
did not ask for it. The block exists so that it cannot happen, not so that
you get to approve it first.
[Don't point it at repositories you don't own](responsible-use.md#dont-point-it-at-repositories-you-dont-own)
is the rule this enforces, and gives the fork-and-review workflow to use
instead: work against your own fork, review the result, and open the upstream
pull request by hand if you still want to.
Closing the CI chain follows from this - a push is code execution on whatever
runners that repository can reach, per the section above - but it is not the
reason for it. Reading it the other way round gets the order backwards, and
makes the default look like a CI workaround to be turned off once CI is
handled.
The sandbox can always read from GitHub (clone, fetch, `gh` API calls). By
**default** it can never push there: enforced at the network layer, not
just by `git-push-guard.sh`. GitHub's IP ranges are only allowlisted on port
443. Port 22 is unreachable, so SSH-based push has no transport at all,
regardless of what any hook does or doesn't catch.
**Opt-in, not hardcoded**: pass `--allow-github-push` to open port 22 to
GitHub and let `git-push-guard.sh` accept `github.com`/`ssh.github.com` as a
push destination (the no-direct-to-main rule still applies):
```sh
dejima --allow-github-push ~/repos/some-project
```
**Read this before turning it on.** Whether a push actually succeeds depends
entirely on what SSH key is loaded into the agent. A forwarded personal
agent with your normal GitHub key would let a compromised session push to
**any** repo that key can write to, not just the one you intended: agent
forwarding doesn't distinguish push from pull. Use a dedicated,
**repo-scoped GitHub deploy key** via `[core] ssh_key` instead whenever
`--allow-github-push` is set: that's what actually limits the blast radius.
`--allow-github-push` only changes the firewall/hook's *host* check; it adds
no credential-loading path of its own. The flag itself has no config-file
equivalent: an exported or saved value must not silently grant a push path,
so *this* opt-in stays a per-launch decision only. Selecting `[core] forge =
github` (see [Shipped providers](providers.md#shipped-providers)) is a
config-file path that opens the same port, but not
silently: `hosts_allowlisted_by_core` is what asks for it, explicitly, in a
manifest, and only when the selected workflow actually pushes. What it opens
is the same GitHub ranges the read-only path already allows on port 443, now
also on port 22, logged like every other allowed connection and verified by
the firewall's own startup self-check.
`[github] token_file` must point at a **read-only** fine-grained PAT
regardless of this setting. `gh auth setup-git` (run at container start if
that file exists) points git's github.com HTTPS credential helper at `gh`,
which only ever holds whatever token that file provides: a write-scoped
token there would make HTTPS push possible independent of
`--allow-github-push`, so don't use one.
+115 -52
View File
@@ -1,15 +1,29 @@
#!/bin/bash
# Read Gitea Actions runs and job logs from a shell - the CI-side counterpart
# to gitea-pr and gitea-issue, and deliberately their twin rather than their
# sibling: all three are copied into the sandbox image as single
# self-contained files, and gitea-pr also lives in a dotfiles checkout, so a
# shared library would be a third thing to keep in step across three places.
# The duplicated config/token/curl preamble is pinned in all three by
# test/static.bats instead.
# to gitea-pr and gitea-issue. Self-contained like both of them rather than
# sharing a library: all three are copied into the image as single files, so
# a library would be another thing to keep in step. test/static.bats pins the
# duplicated config/token/curl preamble instead.
#
# Its reason to exist is a sandboxed agent's: CI is where the tests that
# cannot run in the sandbox actually run, and without this the only way to
# see why one failed is for the operator to paste the log in by hand.
# It exists for the agent: CI is where the tests the sandbox cannot run
# actually run, and without this the only way to see a failure is for the
# operator to paste the log in.
#
# Nothing here touches /repos/{owner}/{repo}/actions/runs or the endpoints
# reached through it, however natural they look. Gitea gates that group
# behind reqOwner(), so a dedicated non-owner account - what a sandbox should
# have - is refused, and no scope grants ownership. The three sources used
# instead need only read access:
#
# actions/tasks one record per job, carrying its run number,
# head_sha, event and result
# commits/<sha>/statuses the same jobs as commit statuses, and the only
# place a job's index is visible
# the web log route the run view's own endpoint, which takes the
# same token header as the API
#
# The job index matters because it is what addresses a log, and it is not a
# field anywhere - only the tail of a status's target_url.
set -euo pipefail
GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.config/gitea/token}"
@@ -99,57 +113,81 @@ function gitea_owner_repo() {
echo "$owner_repo"
}
# The API addresses runs by an internal id; every URL a human has shows the
# per-repo run number instead, and the two are unrelated. Everything below
# takes the number and resolves it here, against one page of the run list.
#
# With no number, HEAD's commit: a run list is not what someone who just
# pushed is asking for. Matched on head_sha rather than on branch because a
# pull_request run reports no branch at all - its ref is refs/pull/N/head.
# Sets `tasks_json`. The list pages by TASK - one record per job - so a run
# costs as many records as it has jobs, and `limit` is ignored unless `page`
# comes with it. Taken as one page, sized for a few dozen runs of a ten-job
# workflow; a lookup that falls off the end says which it was.
function fetch_tasks() {
api_or_die "list runs" \
"${GITEA_API}/repos/${owner}/${repo}/actions/tasks?page=1&limit=$TASK_SCAN"
tasks_json="$json"
}
# Sets `run_number` and `run_sha`. With no number, HEAD's commit: a run list
# is not what someone who just pushed is asking for. Matched on head_sha
# rather than on branch because a pull_request run reports no branch at all -
# its ref is refs/pull/N/head.
function resolve_run() {
local want="${1:-}" sha
api_or_die "list runs" "${GITEA_API}/repos/${owner}/${repo}/actions/runs?page=1&limit=$RUN_SCAN"
fetch_tasks
if [ -n "$want" ]; then
run_id=$(echo "$json" | jq -r --argjson n "$want" \
'first(.workflow_runs[] | select(.run_number == $n) | .id) // empty')
if [ -z "$run_id" ]; then
echo "No run numbered $want in the last $RUN_SCAN runs (raise --limit, or see 'gitea-actions runs')" >&2
run_sha=$(echo "$tasks_json" | jq -r --argjson n "$want" \
'first(.workflow_runs[] | select(.run_number == $n) | .head_sha) // empty')
if [ -z "$run_sha" ]; then
echo "No run numbered $want in the last $TASK_SCAN job records (see 'gitea-actions runs')" >&2
exit 1
fi
run_number="$want"
else
sha=$(git rev-parse HEAD 2>/dev/null) || {
echo "Not in a git repository, so there is no HEAD to look up - pass a run number" >&2
exit 1
}
run_id=$(echo "$json" | jq -r --arg sha "$sha" \
'first(.workflow_runs[] | select(.head_sha == $sha) | .id) // empty')
if [ -z "$run_id" ]; then
echo "No run for HEAD (${sha:0:8}) in the last $RUN_SCAN runs - unpushed, or still queued?" >&2
run_number=$(echo "$tasks_json" | jq -r --arg sha "$sha" \
'first(.workflow_runs[] | select(.head_sha == $sha) | .run_number) // empty')
if [ -z "$run_number" ]; then
echo "No run for HEAD (${sha:0:8}) in the last $TASK_SCAN job records - unpushed, or still queued?" >&2
exit 1
fi
run_sha="$sha"
fi
run_number=$(echo "$json" | jq -r --argjson id "$run_id" \
'first(.workflow_runs[] | select(.id == $id) | .run_number)')
}
# Sets `jobs_json` for the resolved run. The job index a URL shows is the
# tail of the job's html_url, not a field of its own, and the list does not
# arrive in that order.
# Sets `jobs_json` for the resolved run, from the commit's statuses - the one
# place a job's index is visible to a token that does not own the repo.
#
# A status is written more than once per job (queued, then finished) and the
# list arrives newest first, so the newest per context wins. `context` is
# "<workflow> / <job> (<event>)"; the job name is pulled out of it for
# display, and matching keeps the whole string so either form works. Filtered
# by run number as well as by commit, since a re-run puts a second run's
# statuses on the same commit.
function fetch_jobs() {
api_or_die "list jobs for run $run_number" \
"${GITEA_API}/repos/${owner}/${repo}/actions/runs/${run_id}/jobs"
jobs_json=$(echo "$json" | jq '[.jobs[] | {
index: (.html_url | split("/") | last | tonumber),
id, name,
result: (if .status == "completed" then .conclusion else .status end)
}] | sort_by(.index)')
"${GITEA_API}/repos/${owner}/${repo}/commits/${run_sha}/statuses?page=1&limit=100"
jobs_json=$(echo "$json" | jq --arg rn "$run_number" '
[.[] | select(.target_url | test("/actions/runs/" + $rn + "/jobs/[0-9]+$"))]
| group_by(.context) | map(first)
| map({
index: (.target_url | split("/") | last | tonumber),
name: (.context | sub("^[^/]+/ *"; "") | sub(" *\\([^()]*\\)$"; "")),
context,
result: .status
})
| sort_by(.index)')
if [ "$(echo "$jobs_json" | jq 'length')" -eq 0 ]; then
echo "Run $run_number (${run_sha:0:8}) reports no jobs." >&2
echo "Gitea publishes a run's jobs as statuses on its commit, and this" >&2
echo "commit has none for that run - so there is nothing to address a log by." >&2
exit 1
fi
}
function print_job_log() {
local id="$1" name="$2" result="$3"
local index="$1" name="$2" result="$3"
echo "=== $name ($result) ==="
api_or_die "fetch logs for job $name" \
"${GITEA_API}/repos/${owner}/${repo}/actions/jobs/${id}/logs"
"${GITEA_WEB}/${owner}/${repo}/actions/runs/${run_number}/jobs/${index}/logs"
if [ "$KEEP_TIMESTAMPS" = 1 ]; then
echo "$json"
else
@@ -161,7 +199,7 @@ function print_job_log() {
echo
}
RUN_SCAN=50
TASK_SCAN=600
KEEP_TIMESTAMPS=0
if [ "$#" -lt 1 ]; then
@@ -179,6 +217,15 @@ case "$subcommand" in
owner_repo=$(gitea_owner_repo)
owner="${owner_repo%%/*}"
repo="${owner_repo##*/}"
# Logs come from the run view's own endpoint rather than the API, so
# the web root is needed as well. Derived from GITEA_API because that
# is the only address the caller configures.
GITEA_WEB="${GITEA_API%/api/v1}"
if [ "$GITEA_WEB" = "$GITEA_API" ]; then
echo "GITEA_API ($GITEA_API) does not end in /api/v1, so the web root" >&2
echo "cannot be derived from it - logs are read from a non-API route." >&2
exit 1
fi
;;
*)
usage
@@ -195,14 +242,28 @@ case "$subcommand" in
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
esac
done
api_or_die "list runs" "${GITEA_API}/repos/${owner}/${repo}/actions/runs?page=1&limit=$limit"
echo "$json" | jq -r '.workflow_runs[] | [
(.run_number | tostring),
(if .status == "completed" then .conclusion else .status end),
.event,
(.head_sha[0:8]),
.display_title
] | @tsv' | column -t -s $'\t'
fetch_tasks
# One record per job, so a run appears once for each job it has.
# Folded here into a row per run, whose result is the worst of them:
# a run with one failed job is a failed run, and reporting it as
# seven successes and a failure is not what the caller asked.
echo "$tasks_json" | jq -r --argjson limit "$limit" '
[.workflow_runs[]] | group_by(.run_number)
| map({
run: .[0].run_number,
event: .[0].event,
sha: (.[0].head_sha[0:8]),
title: .[0].display_title,
result: (if any(.[]; .status == "failure") then "failure"
elif any(.[]; .status == "cancelled") then "cancelled"
elif any(.[]; .status != "success") then "in_progress"
else "success" end)
})
| sort_by(.run) | reverse | .[0:$limit] | .[]
| [(.run | tostring), .result, .event, .sha, .title] | @tsv' |
while IFS=$'\t' read -r run result event sha title; do
printf '%-5s %-11s %-20s %-8s %s\n' "$run" "$result" "$event" "$sha" "$title"
done
;;
jobs)
run_arg=""
@@ -212,7 +273,9 @@ case "$subcommand" in
fetch_jobs
echo "run $run_number:"
echo "$jobs_json" | jq -r '.[] | [(.index | tostring), .result, .name] | @tsv' |
column -t -s $'\t'
while IFS=$'\t' read -r index result name; do
printf '%-3s %-11s %s\n' "$index" "$result" "$name"
done
;;
logs)
run_arg=""
@@ -237,7 +300,7 @@ case "$subcommand" in
if [[ "$job_want" =~ ^[0-9]+$ ]]; then
selected=$(echo "$jobs_json" | jq --argjson i "$job_want" '[.[] | select(.index == $i)]')
else
selected=$(echo "$jobs_json" | jq --arg n "$job_want" '[.[] | select(.name | contains($n))]')
selected=$(echo "$jobs_json" | jq --arg n "$job_want" '[.[] | select(.context | contains($n))]')
fi
if [ "$(echo "$selected" | jq 'length')" -eq 0 ]; then
echo "No job matching '$job_want' in run $run_number:" >&2
@@ -254,8 +317,8 @@ case "$subcommand" in
fi
fi
while IFS=$'\t' read -r id name result; do
print_job_log "$id" "$name" "$result"
done < <(echo "$selected" | jq -r '.[] | [(.id | tostring), .name, .result] | @tsv')
while IFS=$'\t' read -r index name result; do
print_job_log "$index" "$name" "$result"
done < <(echo "$selected" | jq -r '.[] | [(.index | tostring), .name, .result] | @tsv')
;;
esac
+31 -1
View File
@@ -13,6 +13,7 @@ function usage() {
echo "Usage: gitea-issue list [--state open|closed|all] [--limit N]"
echo " gitea-issue view NUMBER"
echo " gitea-issue create --title TITLE [--body BODY | --body-file FILE]"
echo " gitea-issue edit NUMBER [--title TITLE] [--body BODY | --body-file FILE]"
echo " gitea-issue comment NUMBER (--body BODY | --body-file FILE)"
echo " gitea-issue close NUMBER"
echo " gitea-issue reopen NUMBER"
@@ -153,7 +154,7 @@ subcommand="$1"
shift
case "$subcommand" in
list|view|create|comment|close|reopen)
list|view|create|edit|comment|close|reopen)
require_config
require_token
owner_repo=$(gitea_owner_repo)
@@ -223,6 +224,35 @@ ${body}"
'{title: $title, body: $body}')"
echo "$json" | jq -r '.html_url'
;;
edit)
require_number "${1:-}"
number="$1"
shift
parse_body_args "$@"
if [ -z "$parsed_title" ] && [ -z "$body" ]; then
echo "gitea-issue edit requires --title, --body or --body-file" >&2
exit 1
fi
# Only the fields actually given are sent: an omitted key leaves that
# field alone, so editing a title cannot blank a body by accident.
payload='{}'
if [ -n "$parsed_title" ]; then
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}')
fi
api_or_die "edit issue #${number}" -X PATCH "${issues_api}/${number}" \
-H "Content-Type: application/json" -d "$payload"
echo "$json" | jq -r '.html_url'
;;
comment)
require_number "${1:-}"
number="$1"
+164 -78
View File
@@ -5,12 +5,19 @@ GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.config/gitea/token}"
function usage() {
echo "Usage: gitea-pr create [--title TITLE] [--body BODY | --body-file FILE] [--base BRANCH]"
echo " gitea-pr comments PR_NUMBER"
echo " gitea-pr edit PR_NUMBER [--title TITLE] [--body BODY | --body-file FILE]"
echo " gitea-pr comments PR_NUMBER [--all]"
echo " gitea-pr merge PR_NUMBER"
echo ""
echo "'comments' shows unresolved review threads by default. --all includes"
echo "the ones already resolved, marked as such."
echo ""
echo "--body-file reads the body from FILE, or from stdin when FILE is '-'."
echo "Prefer it over --body for anything with quotes, backticks or newlines."
echo ""
echo "'edit' replaces the title and/or body of an existing PR. Whichever of"
echo "the two is not given is left as it is."
echo ""
echo "Requires GITEA_SSH_HOST and GITEA_API set in the environment (e.g. in" \
"your shell profile), and a token at $GITEA_TOKEN_FILE (or GITEA_TOKEN" \
"set for this invocation only)."
@@ -53,6 +60,23 @@ function gitea_api() {
curl -sS -w '\n%{http_code}' -K - "$@"
}
# Splits gitea_api's combined output into the globals `json` and `http_code`
# and fails with the body on any non-2xx. Copied from gitea-issue, which grew
# it first for the same reason: every call site here did this by hand, and a
# fifth hand-rolled copy is where one of them quietly stops checking.
function api_or_die() {
local what="$1"; shift
local response
response=$(gitea_api "$@")
http_code=$(echo "$response" | tail -1)
json=$(echo "$response" | sed '$d')
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
echo "Failed to $what (HTTP $http_code):" >&2
echo "$json" >&2
exit 1
fi
}
# Reads a body from a file, or from stdin for '-'. The point is to keep prose
# out of argv: a body composed on a command line has to survive shell quoting,
# and the things that break it - an apostrophe, a backtick, a $ - are exactly
@@ -86,6 +110,43 @@ function gitea_owner_repo() {
echo "$owner_repo"
}
function require_number() {
if ! [[ "${1:-}" =~ ^[0-9]+$ ]]; then
echo "Expected a PR number, got '${1:-}'" >&2
exit 1
fi
}
# Sets `body` from --body/--body-file, `parsed_title` from --title and
# `parsed_base` from --base, rejecting --body and --body-file together.
# Shared by create and edit so the two cannot drift into disagreeing about
# which one wins - the same reason gitea-issue shares one between its own
# two body-taking subcommands.
function parse_body_args() {
body=""
local body_file=""
parsed_title=""
parsed_base=""
while [ "$#" -gt 0 ]; do
case "$1" in
--title) parsed_title="$2"; shift 2 ;;
--body) body="$2"; shift 2 ;;
--body-file) body_file="$2"; shift 2 ;;
--base) parsed_base="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
esac
done
# Rejected rather than ranked: silently preferring one would make a caller
# that passed both ship a body it didn't mean to.
if [ -n "$body" ] && [ -n "$body_file" ]; then
echo "Pass --body or --body-file, not both" >&2
exit 1
fi
if [ -n "$body_file" ]; then
body="$(read_body_file "$body_file")"
fi
}
if [ "$#" -lt 1 ]; then
usage
exit 1
@@ -95,7 +156,7 @@ subcommand="$1"
shift
case "$subcommand" in
create|comments|merge)
create|edit|comments|merge)
require_config
require_token
owner_repo=$(gitea_owner_repo)
@@ -110,28 +171,9 @@ esac
case "$subcommand" in
create)
title=""
body=""
body_file=""
base=""
while [ "$#" -gt 0 ]; do
case "$1" in
--title) title="$2"; shift 2 ;;
--body) body="$2"; shift 2 ;;
--body-file) body_file="$2"; shift 2 ;;
--base) base="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
esac
done
# Rejected rather than ranked: silently preferring one would make a
# caller that passed both ship a body it didn't mean to.
if [ -n "$body" ] && [ -n "$body_file" ]; then
echo "Pass --body or --body-file, not both" >&2
exit 1
fi
if [ -n "$body_file" ]; then
body="$(read_body_file "$body_file")"
fi
parse_body_args "$@"
title="$parsed_title"
base="$parsed_base"
body="**Summary generated by LLM agent**
${body}"
@@ -147,14 +189,7 @@ ${body}"
base="${base:-main}"
fi
response=$(gitea_api "${GITEA_API}/repos/${owner}/${repo}/pulls?state=open")
http_code=$(echo "$response" | tail -1)
json=$(echo "$response" | sed '$d')
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
echo "Failed to check for an existing PR (HTTP $http_code):" >&2
echo "$json" >&2
exit 1
fi
api_or_die "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" \
'.[] | select(.head.ref == $head and .base.ref == $base) | .html_url' | head -1)
if [ -n "$existing_url" ]; then
@@ -165,63 +200,122 @@ ${body}"
title="$current_branch"
fi
response=$(gitea_api -X POST "${GITEA_API}/repos/${owner}/${repo}/pulls" \
api_or_die "create PR" -X POST "${GITEA_API}/repos/${owner}/${repo}/pulls" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg head "$current_branch" --arg base "$base" --arg title "$title" --arg body "$body" \
'{head: $head, base: $base, title: $title, body: $body}')")
http_code=$(echo "$response" | tail -1)
json=$(echo "$response" | sed '$d')
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
echo "Failed to create PR (HTTP $http_code):" >&2
echo "$json" >&2
'{head: $head, base: $base, title: $title, body: $body}')"
echo "$json" | jq -r '.html_url'
;;
edit)
require_number "${1:-}"
pr_number="$1"
shift
parse_body_args "$@"
if [ -n "$parsed_base" ]; then
echo "gitea-pr edit cannot change the base branch" >&2
exit 1
fi
if [ -z "$parsed_title" ] && [ -z "$body" ]; then
echo "gitea-pr edit requires --title, --body or --body-file" >&2
exit 1
fi
# Only the fields actually given are sent: an omitted key leaves that
# field alone, so editing a title cannot blank a body by accident.
payload='{}'
if [ -n "$parsed_title" ]; then
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}')
fi
api_or_die "edit PR #${pr_number}" -X PATCH \
"${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}" \
-H "Content-Type: application/json" -d "$payload"
echo "$json" | jq -r '.html_url'
;;
comments)
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: gitea-pr comments PR_NUMBER [--all]" >&2
exit 1 ;;
esac
shift
done
if [ -z "$pr_number" ]; then
echo "Usage: gitea-pr comments PR_NUMBER" >&2
echo "Usage: gitea-pr comments PR_NUMBER [--all]" >&2
exit 1
fi
echo "=== Issue comments ==="
response=$(gitea_api "${GITEA_API}/repos/${owner}/${repo}/issues/${pr_number}/comments")
http_code=$(echo "$response" | tail -1)
json=$(echo "$response" | sed '$d')
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
echo "Failed to fetch issue comments (HTTP $http_code):" >&2
echo "$json" >&2
exit 1
fi
api_or_die "fetch issue comments" \
"${GITEA_API}/repos/${owner}/${repo}/issues/${pr_number}/comments"
echo "$json" | jq -r '.[] | "[\(.user.login) @ \(.created_at)]\n\(.body)\n"'
echo "=== Reviews ==="
response=$(gitea_api "${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/reviews")
http_code=$(echo "$response" | tail -1)
reviews_json=$(echo "$response" | sed '$d')
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
echo "Failed to fetch reviews (HTTP $http_code):" >&2
echo "$reviews_json" >&2
exit 1
fi
api_or_die "fetch reviews" "${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/reviews"
reviews_json="$json"
echo "$reviews_json" | jq -r '.[] | select(.body != "") | "[\(.user.login) @ \(.submitted_at) — \(.state)]\n\(.body)\n"'
echo "=== Inline review comments ==="
review_ids=$(echo "$reviews_json" | jq -r '.[] | select(.comments_count > 0) | .id')
all_comments='[]'
for review_id in $review_ids; do
response=$(gitea_api \
"${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/reviews/${review_id}/comments")
http_code=$(echo "$response" | tail -1)
json=$(echo "$response" | sed '$d')
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
echo "Failed to fetch comments for review ${review_id} (HTTP $http_code):" >&2
echo "$json" >&2
exit 1
fi
echo "$json" | jq -r '.[] | "[\(.user.login) @ \(.path):\(.line // "?")]\n\(.body)\n"'
api_or_die "fetch comments for review ${review_id}" \
"${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/reviews/${review_id}/comments"
# The hunk, not just the path. Gitea's review-comment objects carry
# no `line` field at all - the old `.line // "?"` could only ever
# print `?` - but they do carry `diff_hunk`, which is the exact
# text the comment was left against. Without it a reviewer's
# "this is confusing" arrives with nothing to attach it to, and
# whoever acts on the review has to guess which passage was meant.
# `position` is the index into the diff rather than a file line, so
# it is printed as a diff offset and not as something to jump to.
all_comments=$(printf '%s\n%s' "$all_comments" "$json" | jq -s 'add')
done
# A thread is every comment sharing a path and a diff position, and it
# is resolved if ANY of them carries a resolver. Gitea stamps only a
# thread's root when you resolve it, so a reply inside a closed thread
# comes back with `resolver: null` and reads as open if each comment is
# judged alone. That is not hypothetical: it is how a closed-and-refiled
# thread on #96 presented as still needing an answer.
#
# Note what is NOT used here. A review's `state` (COMMENT vs
# REQUEST_CHANGES) and whether comments arrived grouped under one review
# or singly both describe how the reviewer submitted, not whether
# anything is outstanding. Reading either as a resolution signal works
# until a reviewer changes how they file, and then it silently stops.
threads=$(printf '%s' "$all_comments" | jq '
group_by(.path + "@" + (.position | tostring))
| map({resolved: any(.[]; .resolver != null), comments: .})')
resolved_count=$(printf '%s' "$threads" | jq '[.[] | select(.resolved)] | length')
if [ "$show_all" -eq 1 ]; then
visible="$threads"
else
visible=$(printf '%s' "$threads" | jq '[.[] | select(.resolved | not)]')
fi
printf '%s' "$visible" | jq -r '.[]
| .resolved as $r
| .comments[]
| "[\(.user.login) @ \(.path) @@\(.position // "?")\(if $r then " (resolved)" else "" end)]\n\(.diff_hunk // "(no hunk recorded)")\n--- comment ---\n\(.body)\n"'
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
;;
merge)
pr_number="${1:-}"
@@ -230,17 +324,9 @@ ${body}"
exit 1
fi
response=$(gitea_api -X POST "${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/merge" \
-H "Content-Type: application/json" \
-d '{"Do": "merge"}')
http_code=$(echo "$response" | tail -1)
json=$(echo "$response" | sed '$d')
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
echo "Failed to merge PR #${pr_number} (HTTP $http_code):" >&2
echo "$json" >&2
exit 1
fi
api_or_die "merge PR #${pr_number}" -X POST \
"${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/merge" \
-H "Content-Type: application/json" -d '{"Do": "merge"}'
echo "Merged PR #${pr_number}"
;;
esac
+8 -4
View File
@@ -1,4 +1,8 @@
command = claude
keys = projects_dir:path=~/.claude/projects statusline_path:path=~/.claude/statusline.sh
resume_command = claude --resume {{session_id}}
briefing = briefing.md
command = claude
command_path = /usr/local/share/npm-global/bin/claude
guards = bash-deny push-guard merge-guard notify
no_confirm_args = --dangerously-skip-permissions
settings_args = --settings /etc/dejima-settings.json
keys = projects_dir:path=~/.claude/projects file_history_dir:path=~/.claude/file-history statusline_path:path=~/.claude/statusline.sh
resume_command = claude --resume {{session_id}}
briefing = briefing.md
+15 -22
View File
@@ -1,30 +1,23 @@
#!/usr/bin/env bash
# PreToolUse hook: inside dejima, block a merge command matching
# DEJIMA_MERGE_BLOCK - a literal command prefix (the forge's PR command
# followed by its merge subcommand) resolved host-side by bin/dejima from
# the configured forge and workflow
# (see dejima-provider-lib.sh), not hardcoded to any one forge. The sandbox
# workflow is feature branch -> open a PR/MR -> a human merges; what lands
# on the default branch is an operator decision, exactly as it already is
# for `git push` (see git-push-guard.sh, whose no-direct-push-to-a-blocked-
# branch rule this is the API-side counterpart to - a merge reaches the
# branch without a push, so that guard never sees it).
# DEJIMA_MERGE_BLOCK - a literal command prefix resolved host-side by
# bin/dejima from the configured forge and workflow, never hardcoded to one
# forge. What lands on the default branch is an operator decision, as it
# already is for `git push`; this is the API-side counterpart to
# git-push-guard.sh, since a merge reaches the branch without a push.
#
# Deliberately not conditional on intent. A hook is handed a command, not a
# conversation, so it cannot tell an instructed merge from an unprompted one;
# the only honest place to draw the line is the context, and the context that
# needs the line is this one - headless unattended runs under
# --dangerously-skip-permissions, where nobody is watching the turn happen.
# On the host the hook is a no-op and an operator can still ask for a merge.
# Not conditional on intent: a hook is handed a command, not a conversation,
# so it cannot tell an instructed merge from an unprompted one. The line is
# drawn by context instead, and the context that needs it is this one -
# unattended runs under --dangerously-skip-permissions. On the host it is a
# no-op.
#
# Like its sibling this is a fast-fail guard against an overeager agent, not
# a hard security boundary: a sandbox session has full shell access and the
# forge token in its environment, so it could call the merge endpoint with
# curl directly. It stops the mistake, not the attack.
# A fast-fail guard against an overeager agent, not a boundary: a session has
# shell access and the forge token, so it could call the merge endpoint with
# curl. It stops the mistake, not the attack.
#
# DEJIMA_MERGE_BLOCK fails OPEN: empty means no command is blocked, so the
# hook is inert rather than conditionally installed - a workflow with no
# merge-block policy (or a forge with nothing to block) genuinely has none.
# Fails OPEN - empty means nothing is blocked, so a workflow with no
# merge-block policy genuinely has none rather than the hook guessing.
if [ -z "${DEJIMA:-}" ]; then
exit 0
+14 -19
View File
@@ -66,29 +66,24 @@ fi
requirements=""
decl_dir="$project_dir/.dejima"
# Everything below reads attacker-controllable text on its way into an
# agent's context window, so all three costs it can impose are bounded:
# Everything below reads attacker-controllable text on its way into a context
# window, so each cost it can impose is bounded:
#
# MAX_DECLARED entries considered - the same cap read_declared() applies
# host-side, so this reports on the same window
# `dejima-project approve` will act on.
# host-side, so this reports the window `approve` acts on.
# MAX_ENTRY_LEN characters per entry.
# MAX_LISTED entries named; the rest are counted only. Without it, 200
# entries of 200 chars is 40 KB of attacker-chosen tokens per
# section, and a large enough $requirements pushed the jq call
# past MAX_ARG_STRLEN and emitted nothing - costing every
# later session its whole briefing.
#
# Within that window the report is a strict subset of what `approve` accepts,
# never a superset: it never names something approve would reject, but it can
# stay silent about something approve would take - a dotless domain (the
# regex below requires a label separator, deliberately, so a bare
# IGNORE-PREVIOUS-INSTRUCTIONS can't be echoed into the context) and a valid
# domain longer than MAX_ENTRY_LEN are both approvable host-side and both
# silently skipped here. Fail-closed in the direction that matters: the worst
# case is a missing line of advice, not an unapproved entry presented as fine.
# MAX_LISTED entries actually named in the report; the rest are only
# counted. Without it 200 entries of 200 chars would be 40 KB
# of attacker-chosen tokens per section, and a big enough
# $requirements used to push the jq call at the end past
# Linux's 128 KiB MAX_ARG_STRLEN and emit nothing at all -
# silently costing every later session in the project its
# entire briefing.
# The report is a strict subset of what `approve` accepts, never a superset:
# it can stay silent about something approvable (a dotless domain, which the
# regex refuses so a bare IGNORE-PREVIOUS-INSTRUCTIONS cannot be echoed into
# context; or one longer than MAX_ENTRY_LEN) but never names something
# `approve` would reject. The worst case is a missing line of advice, not an
# unapproved entry presented as fine.
MAX_DECLARED=200
MAX_ENTRY_LEN=200
MAX_LISTED=20
+33 -112
View File
@@ -1,6 +1,6 @@
#!/bin/bash
# Idempotent installer for dejima. Symlinks this repo's hooks into
# ~/.claude/hooks, registers them in ~/.claude/settings.json, and puts
# Idempotent installer for dejima. Registers the selected harness's guard
# hooks wherever that harness reads them from, and puts
# bin/dejima, bin/dejima-allow, bin/dejima-dep, bin/dejima-project,
# bin/dejima-audit and the opt-in remote-access scripts on PATH. Safe to
# re-run - every step is a no-op if already done, and every change is
@@ -35,113 +35,6 @@ fi
echo "Found required tools: docker, docker compose v2, jq, python3"
echo
mkdir -p "$HOME/.claude/hooks"
link_hook() {
local name="$1" src="$SCRIPT_DIR/hooks/$1" dest="$HOME/.claude/hooks/$1"
if [ -L "$dest" ] && [ "$(readlink -f "$dest")" = "$(readlink -f "$src")" ]; then
echo "Skipping: $name already linked to this repo's copy"
return
fi
if [ -e "$dest" ] || [ -L "$dest" ]; then
mkdir -p "$BACKUP_DIR"
echo "Moving: existing ~/.claude/hooks/$name to $BACKUP_DIR/$name"
mv "$dest" "$BACKUP_DIR/$name"
fi
echo "Linking: $name ($src -> $dest)"
ln -s "$src" "$dest"
}
link_hook sandbox-context.sh
link_hook git-push-guard.sh
link_hook merge-guard.sh
# Symlink from before the gitea-merge-guard.sh -> merge-guard.sh rename
# points at a hooks/ path that no longer exists. Only remove it if it's
# actually dangling - the same check the claude-sandbox-* cleanup below
# uses for bin/, so an operator's own file at this name is left alone.
old_merge_guard="$HOME/.claude/hooks/gitea-merge-guard.sh"
if [ -L "$old_merge_guard" ] && [ ! -e "$old_merge_guard" ]; then
echo "Removing: dangling pre-rename symlink $old_merge_guard"
rm -f "$old_merge_guard"
fi
# Linked so it exists at a stable path for the read-only ~/.claude/hooks
# mount, but deliberately NOT passed to register_hook below: bin/dejima
# registers it per session, into a throwaway settings copy, and only when
# remote access is configured. Registering it here would fire it for every
# unsandboxed host session too, where it has no marker directory to write to.
link_hook dejima-notify.sh
# deny-dangerous-bash.sh is a general (non-sandbox-gated) hook that may
# already be owned by another install (e.g. dotfiles) - only claim it if
# nothing else already provides it, so this repo works fully standalone
# without clobbering an existing setup.
deny_dest="$HOME/.claude/hooks/deny-dangerous-bash.sh"
if [ -e "$deny_dest" ] || [ -L "$deny_dest" ]; then
echo "Skipping: deny-dangerous-bash.sh already present at $deny_dest (leaving it as-is)"
else
echo "Linking: deny-dangerous-bash.sh ($SCRIPT_DIR/hooks/deny-dangerous-bash.sh -> $deny_dest)"
ln -s "$SCRIPT_DIR/hooks/deny-dangerous-bash.sh" "$deny_dest"
fi
echo
SETTINGS="$HOME/.claude/settings.json"
if [ ! -f "$SETTINGS" ]; then
echo "Creating $SETTINGS"
echo '{}' > "$SETTINGS"
fi
# The registration that went with the symlink removed above. Two things
# depend on it going: Claude Code reports a missing hook command on every
# session start while it points at a path that no longer exists, and - until
# the dedupe below stopped matching it - it also stood in for the
# registration it was renamed from, so `register_hook merge-guard.sh`
# skipped and the merge guard was registered nowhere at all. Dropped only
# when the path it names is gone, so an operator's own file at that name
# keeps its registration, matching the symlink check above.
if [ ! -e "$old_merge_guard" ] && jq -e --arg s "/gitea-merge-guard.sh" \
'[.hooks.PreToolUse[]?.hooks[]?.command // empty] | any(endswith($s))' \
"$SETTINGS" >/dev/null 2>&1; then
echo "Removing: stale pre-rename gitea-merge-guard.sh registration from $SETTINGS"
settings_tmp=$(mktemp)
jq --arg s "/gitea-merge-guard.sh" \
'.hooks.PreToolUse = ((.hooks.PreToolUse // [])
| map(.hooks = ((.hooks // []) | map(select((.command // "") | endswith($s) | not))))
| map(select((.hooks | length) > 0)))' \
"$SETTINGS" > "$settings_tmp" && mv "$settings_tmp" "$SETTINGS"
fi
register_hook() {
local event="$1" matcher="$2" script_name="$3" description="$4"
# endswith on the leading slash, not test(): test() is an unanchored
# regex, so a hook whose name merely ends in another's - which is what a
# rename like gitea-merge-guard.sh -> merge-guard.sh produces - read as
# already registered, and the new hook was silently never added.
if jq -e --arg s "/$script_name" \
"[.hooks.${event}[]?.hooks[]?.command // empty] | any(endswith(\$s))" \
"$SETTINGS" >/dev/null 2>&1; then
echo "Skipping: $script_name already registered as a $event hook"
return
fi
echo "Registering $script_name as a $event hook in $SETTINGS ($description)"
local tmp
tmp=$(mktemp)
jq --arg matcher "$matcher" --arg cmd "\$HOME/.claude/hooks/$script_name" \
".hooks.${event} = ((.hooks.${event} // []) + [{\"matcher\": \$matcher, \"hooks\": [{\"type\": \"command\", \"command\": \$cmd}]}])" \
"$SETTINGS" > "$tmp" && mv "$tmp" "$SETTINGS"
}
register_hook SessionStart "startup|resume|clear|compact" sandbox-context.sh \
"injects sandbox context on session start"
register_hook PreToolUse Bash git-push-guard.sh \
"blocks pushes outside the allowed host(s)/branches"
register_hook PreToolUse Bash merge-guard.sh \
"blocks the configured forge's merge command inside the sandbox"
register_hook PreToolUse Bash deny-dangerous-bash.sh \
"blocks obviously destructive Bash commands"
echo
BIN_DIR="$HOME/.local/bin"
if [[ ":$PATH:" != *":$BIN_DIR:"* ]]; then
echo "Warning: $BIN_DIR is not on PATH - add it to your shell profile to run 'dejima' by name instead of the full path." >&2
@@ -182,6 +75,7 @@ link_bin dejima-project
# the startup warning about unacknowledged CRITICAL findings tells the
# operator to run `dejima-audit ack`, which is only true once it is on PATH.
link_bin dejima-audit
link_bin dejima-epitaph
# Remote access (opt-in, see docs/remote-access.md). All three are inert
# without [remote-access] enabled = on - they refuse to run at all - so
@@ -235,9 +129,9 @@ else
printf '# dejima configuration. See README.md.\n'
printf '# Every key below is shown at its default; uncomment to change one.\n'
printf '#\n'
printf '# [core] forge and [core] workflow are the exception: they are written\n'
printf '# out uncommented, so this file states what it selects rather than\n'
printf '# inheriting whatever a future release defaults to.\n'
printf '# [core] harness, forge and workflow are written out uncommented, so\n'
printf '# this file states what it selects rather than inheriting a future\n'
printf '# default. harness has none at all - dejima refuses to start unset.\n'
printf '#\n'
printf '# Those two decide whether this sandbox pushes at all. The shipped\n'
printf '# default is forge = none with workflow = local-only: the agent\n'
@@ -261,6 +155,7 @@ else
case "$_key" in
forge.gitea.ssh_host) _seed="${GITEA_SSH_HOST:-}" ;;
forge.gitea.api) _seed="${GITEA_API:-}" ;;
core.harness) _seed="claude-code" ;;
core.forge) _seed="$_starter_forge" ;;
core.workflow) _seed="$_starter_workflow" ;;
core.command) _seed="${SANDBOX_CMD:-}" ;;
@@ -293,6 +188,32 @@ else
chmod 600 "$CONFIG_FILE"
fi
# Guard registration, after the starter config above rather than before it:
# where dejima's guards go, and in what shape, is the selected harness's
# business, and on a fresh install that selection is the one just seeded.
# Registering before reading it would mean writing one harness's config for
# an operator who has chosen another.
#
# Delegated to the harness's plugin, which is also what gates each guard on
# 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
echo "No [core] harness is set in $CONFIG_FILE, so no guard hooks were"
echo "registered. Set one and re-run ./install.sh."
else
DEJIMA_HARNESS_NAME="$_harness"
harness_plugin_load "$_harness"
if declare -F harness_register_guards >/dev/null 2>&1; then
echo "Registering $_harness's guard hooks"
harness_register_guards "$SCRIPT_DIR" "$BACKUP_DIR"
else
echo "Harness '$_harness' installs no guard hooks."
echo
fi
fi
if [ -f "$(config_root)/remote.conf" ]; then
echo
echo "Note: $(config_root)/remote.conf is no longer read - its keys moved to the"
+295 -55
View File
@@ -23,6 +23,8 @@ setup() {
CONFIG_DIR="$(mktemp -d)"
export DEJIMA_CONFIG_DIR="$CONFIG_DIR"
cat > "$CONFIG_DIR/config" <<EOF
[core]
harness = claude-code
[harness.claude-code]
projects_dir = $PROJDIR
statusline_path = $DEJIMA_AUDIT_STATUSLINE
@@ -143,16 +145,24 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
[ "$count" -eq 1 ]
}
@test "flags a cross-project write under ~/.claude/projects" {
# The cross-project finding is gone, and these are its replacement: writes
# under ~/.claude/projects must produce NOTHING now, whichever slug they land
# in. It reported a poisoning route a session could once take - the whole
# tree was mounted - and since #104 a session gets its own slugs only, so
# every remaining write it could see belongs to some other session doing its
# own work. With several sessions running it reported all of them on every
# report.
@test "a write to another project's slug is not reported" {
mkdir -p "$PROJDIR/-some-other-project"
echo x > "$PROJDIR/-some-other-project/file1"
snap
echo y > "$PROJDIR/-some-other-project/file2"; touch -d '+1 hour' "$PROJDIR/-some-other-project/file2"
run cmp_
[[ "$output" == *"CROSS-PROJECT history/memory write: -some-other-project"* ]]
[[ "$output" != *"CROSS-PROJECT"* ]]
[[ "$output" != *"-some-other-project"* ]]
}
@test "does not flag the current project's own projects slug" {
@test "a write to this project's own slug is not reported either" {
mkdir -p "$PROJDIR/$CURRENT_SLUG"
echo x > "$PROJDIR/$CURRENT_SLUG/file1"
snap
@@ -161,15 +171,6 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
[[ "$output" != *"CROSS-PROJECT"* ]]
}
@test "does not flag a worktree child's projects slug" {
mkdir -p "$PROJDIR/${CURRENT_SLUG}--worktrees-featurex"
echo x > "$PROJDIR/${CURRENT_SLUG}--worktrees-featurex/file1"
snap
echo y > "$PROJDIR/${CURRENT_SLUG}--worktrees-featurex/file2"; touch -d '+1 hour' "$PROJDIR/${CURRENT_SLUG}--worktrees-featurex/file2"
run cmp_
[[ "$output" != *"CROSS-PROJECT"* ]]
}
@test "silent when no projects changed" {
mkdir -p "$PROJDIR/-some-other-project"
echo x > "$PROJDIR/-some-other-project/file1"
@@ -211,7 +212,11 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
[[ "$output" == *".claude/evil"* ]]
}
@test "TCB: flags a change under bin/ when workspace is the audit's own repo root" {
@test "TCB: a change git can see is reported as info, not as a CRITICAL to ack" {
# tcb_snapshot only populates when the workspace IS dejima's own repo, so
# every change here is also in the git working tree the operator is about
# to read. One CRITICAL per file duplicated `git status` and made
# developing dejima itself unusable.
mkdir -p "$WS/bin"
echo x > "$WS/bin/x"
echo x > "$WS/install.sh"
@@ -219,7 +224,27 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
snap
echo changed > "$WS/bin/x"
run cmp_
[[ "$output" == *"TCB (sandbox self) modified: bin/x"* ]]
[[ "$output" != *"TCB (sandbox self) modified"* ]] # nothing to acknowledge
# Recorded as INFO in the report rather than shouted on stderr: the
# startup warning already said the TCB is writable this session.
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
run bash -c "cat '$AUDITDIR/$slug'/*.txt"
[[ "$output" == *"this workspace IS dejima"* ]]
}
@test "TCB: a change git CANNOT see is still a CRITICAL finding" {
# The case that justifies keeping the check at all. A file listed in
# .git/info/exclude is live code that runs on the next launch and appears
# nowhere in `git status`, so nothing but this hash sees it.
mkdir -p "$WS/bin" "$WS/.git/info"
echo x > "$WS/bin/hidden"
echo x > "$WS/install.sh"
echo 'bin/hidden' > "$WS/.git/info/exclude"
export DEJIMA_AUDIT_REPO_ROOT="$(readlink -f "$WS")"
snap
echo changed > "$WS/bin/hidden"
run cmp_
[[ "$output" == *"TCB (sandbox self) modified, INVISIBLE to git: bin/hidden"* ]]
}
@test "TCB: does not flag changes when the workspace is not the audit's own repo root" {
@@ -241,6 +266,24 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
[ "$output" = "" ]
}
@test "says so when no harness is selected, rather than auditing a narrower surface in silence" {
# The statusline path comes from the selected harness, so with none
# selected there is nothing to watch and the compensating control for
# the symlink gap covers nothing. bin/dejima refuses to start without
# [core] harness, so this state means the audit was driven by hand - and
# a clean report over a surface that was never examined is worse than a
# noisy one.
printf '#!/bin/sh\necho original\n' > "$DEJIMA_AUDIT_STATUSLINE"
cat > "$CONFIG_DIR/config" <<EOF
[audit]
dir = $AUDITDIR
baselines_dir = $BASEDIR
EOF
run "$AUDIT" snapshot "$WS" "$STATE"
[[ "$output" == *"no [core] harness is set"* ]] || { echo "$output"; return 1; }
[[ "$output" == *"NOT checked"* ]] || { echo "$output"; return 1; }
}
@test "flags a plain regular-file statusline.sh replaced with different content" {
printf '#!/bin/sh\necho original\n' > "$DEJIMA_AUDIT_STATUSLINE"
snap
@@ -557,7 +600,10 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
printf 'keep = 10\n' >> "$CONFIG_DIR/config"
snap; git -C "$WS" config user.name changed
cmp_ >/dev/null 2>&1
run bash -c "ls '$AUDITDIR/$slug' | wc -l"
# *.txt, not every entry: a finding with retained content also leaves a
# <report>.d sidecar beside it, which rotation counts as part of the
# report it belongs to rather than as another report.
run bash -c "ls '$AUDITDIR/$slug'/*.txt | wc -l"
[ "$output" -le 10 ]
}
@@ -589,14 +635,56 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
[ "$result" -ne 124 ]
}
@test "show prints the only report there is, and names it" {
# One `ack` run used to walk one report and exit, saying nothing about the
# rest, so clearing a backlog took as many runs as there were reports and
# looked like the tool was stuck on the first one.
@test "ack walks every report in one run when told 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
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, continue, approve
run bash -c "printf 'a\ny\na\n' | '$AUDIT' ack '$WS'"
[ "$status" -eq 0 ]
[[ "$output" == *"2 reports waiting"* ]]
[[ "$output" == *".envrc"* ]]
[[ "$output" == *"settings.json"* ]] # the second report, same run
}
@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
snap; mkdir -p "$WS/.claude"; echo '{}' > "$WS/.claude/settings.json"; cmp_ >/dev/null 2>&1
run bash -c "printf 'a\nn\n' | '$AUDIT' ack '$WS'"
[ "$status" -eq 0 ]
[[ "$output" == *"still waiting"* ]] # said what is left behind
[[ "$output" != *"settings.json"* ]] # and did not open it
}
@test "show indexes the reports rather than dumping one" {
# It used to `cat` a single report: a wall of text that answered neither
# question an operator has - how much is waiting, and what still needs a
# decision - and included findings already acknowledged, so it did not
# even shrink as they worked through it.
snap
echo 'export X=1' > "$WS/.envrc"
cmp_ >/dev/null 2>&1
run bash -c "'$AUDIT' show '$WS' 2>&1"
[ "$status" -eq 0 ]
[[ "$output" == *"1 report(s)"* ]]
[[ "$output" == *"-CRITICAL.txt"* ]] # names it
[[ "$output" == *"ack starts here"* ]] # and which one ack will open
[[ "$output" != *".envrc"* ]] # without the contents
}
@test "show --full prints the report ack would open" {
snap
echo 'export X=1' > "$WS/.envrc"
cmp_ >/dev/null 2>&1
run bash -c "'$AUDIT' show '$WS' --full 2>&1"
[ "$status" -eq 0 ]
[[ "$output" == *".envrc"* ]]
[[ "$output" == *"-CRITICAL.txt"* ]] # said which report it printed
[[ "$output" == *"-CRITICAL.txt"* ]]
}
@test "show and ack agree on which report they mean" {
@@ -616,19 +704,20 @@ WARNING changed workspace file: .vscode/settings.json
REPORT
run "$AUDIT" show "$WS"
[ "$status" -eq 0 ]
[[ "$output" == *".envrc"* ]] # the stale CRITICAL report...
[[ "$output" == *"-CRITICAL.txt"* ]] # the stale CRITICAL report...
[[ "$output" != *"settings.json"* ]] # ...not the newer clean one
}
@test "show falls back to the newest report when no CRITICAL one is pending" {
@test "show marks the newest report as the one ack will open, absent a CRITICAL" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
report_dir="$AUDITDIR/$slug"; mkdir -p "$report_dir"
echo 'older' > "$report_dir/2020-01-01-000000.txt"
echo 'newer' > "$report_dir/2026-01-01-000001.txt"
run "$AUDIT" show "$WS"
[ "$status" -eq 0 ]
[[ "$output" == *"newer"* ]]
[[ "$output" != *"older"* ]]
[[ "$output" == *"2 report(s)"* ]] # both listed, not just one
[[ "$output" == *"2020-01-01-000000.txt"* ]]
[[ "$output" == *"2026-01-01-000001.txt"*"ack starts here"* ]]
}
@test "ack presents a stale unacknowledged CRITICAL report before a newer clean one" {
@@ -723,15 +812,118 @@ REPORT
echo 'export X=1' > "$WS/.envrc"
mkdir -p "$WS/.vscode"; echo '{}' > "$WS/.vscode/settings.json"
cmp_ >/dev/null 2>&1
# 's' on the CRITICAL .envrc finding, then 'a' on the WARNING one that
# 'i' on the CRITICAL .envrc finding, then 'a' on the WARNING one that
# follows it - so $BASEDIR/$slug is guaranteed to exist afterward and the
# negative grep below can't pass vacuously against a missing file.
run bash -c "printf 's\na\n' | '$AUDIT' ack '$WS'"
[[ "$output" == *"cannot 'always ignore' a CRITICAL surface - approve the exact content or skip."* ]]
run bash -c "printf 'i\na\n' | '$AUDIT' ack '$WS'"
[[ "$output" == *"cannot ignore a CRITICAL surface from now on"* ]]
[ -f "$BASEDIR/$slug" ]
! grep -q '^spam \.envrc' "$BASEDIR/$slug"
}
@test "a finding the baseline already covers is not presented again" {
# #65's headline bug. The approval was always written correctly; `ack`
# just never consulted the baseline when deciding what to SHOW, so every
# later run re-presented the same CRITICAL and appended a duplicate
# entry. Reported as "approvals are not being recorded", which is what it
# looks like from the outside.
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 'a\n' | '$AUDIT' ack '$WS'"
[ "$(grep -c '^safe .envrc' "$BASEDIR/$slug")" -eq 1 ]
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[[ "$output" != *".envrc"* ]]
[[ "$output" == *"already dealt with"* ]]
# And no second entry for the same decision.
[ "$(grep -c '^safe .envrc' "$BASEDIR/$slug")" -eq 1 ]
}
@test "a report whose CRITICALs were approved through an earlier report stops nagging" {
# Two reports can list the same finding. Approving it in the first left
# the second permanently -CRITICAL.txt, so bin/dejima warned about a
# report whose every finding had been dealt with, and `ack` re-presented
# it forever. The rename now runs on a pass that records nothing new.
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
# A second report listing the same, still-unapproved finding.
cp "$AUDITDIR/$slug"/*-CRITICAL.txt "$AUDITDIR/$slug/2020-01-01-000000-CRITICAL.txt"
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[[ "$output" == *"recorded 1 acknowledgement"* ]]
# The remaining report is walked with nothing left to decide, and sheds
# its suffix rather than staying unacknowledged forever.
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[[ "$output" == *"already dealt with"* ]]
run bash -c "ls '$AUDITDIR/$slug'"
[[ "$output" != *"-CRITICAL.txt"* ]]
}
@test "a cleared finding is presented once, not on every run" {
# There is nothing to record for a surface that no longer exists, so
# without a `reviewed` marker the report re-prompted forever and `ack`
# could never reach a state of having nothing to ask.
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
rm -f "$WS/.envrc"
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[[ "$output" == *".envrc"* ]]
grep -q '^reviewed ' "$BASEDIR/$slug"
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[[ "$output" != *".envrc"* ]]
}
@test "quitting a report does not mark it reviewed" {
# The marker means "walked to the end". An abort discards everything else,
# and must discard this too or one q would silence the report.
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
rm -f "$WS/.envrc"
run bash -c "printf 'q\n' | '$AUDIT' ack '$WS'"
! grep -q '^reviewed ' "$BASEDIR/$slug" 2>/dev/null
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[[ "$output" == *".envrc"* ]]
}
@test "a WARNING git config finding can be acknowledged, both ways (#45)" {
# Neither `ack` nor `compare` handled this tier: no arm derived an id for
# it, and the two branches that emit it never consulted the baseline. It
# was the one class of finding with no way to curate it at all.
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap; git -C "$WS" config user.name changed-in-session; cmp_ >/dev/null 2>&1
run bash -c "printf 'i\n' | '$AUDIT' ack '$WS'"
grep -q '^spam gitconfig:user.name' "$BASEDIR/$slug"
# ...and the surface stays quiet whatever it changes to next, which is
# the point for a key some tool rewrites on every run.
snap; git -C "$WS" config user.name changed-again
run cmp_
[[ "$output" != *"user.name"* ]]
}
@test "an approved WARNING git config value re-fires when the value changes" {
# The other verb: `safe` pins the exact value, so a different one is a
# new decision rather than covered by the old one.
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap; git -C "$WS" config user.name first; cmp_ >/dev/null 2>&1
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
grep -q '^safe gitconfig:user.name ' "$BASEDIR/$slug"
snap; git -C "$WS" config user.name second
run cmp_
[[ "$output" == *"changed git config: user.name=second"* ]]
}
@test "ack emits no colour when its output is not a terminal" {
# Reports and piped output are read back by machines - the audit's own
# next run, and every test here. An escape sequence in either is a bug,
# so the palette is decided from the stream rather than from a flag.
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[[ "$output" != *$'\033'* ]]
run cmp_
[[ "$output" != *$'\033'* ]]
}
@test "ack records a safe entry and clears the CRITICAL report marker" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
@@ -741,14 +933,6 @@ REPORT
[[ "$output" != *"-CRITICAL.txt"* ]]
}
@test "ack does not write an unmatchable entry for a non-path finding" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap; git -C "$WS" config user.name changed; cmp_ >/dev/null 2>&1
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[[ "$output" == *"cannot"* ]]
[ ! -f "$BASEDIR/$slug" ]
}
@test "an ack-approved finding is suppressed on the next compare (id round-trips through the baseline)" {
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
@@ -777,24 +961,37 @@ REPORT
# so the report must stay eviction-protected
}
@test "ack refuses to approve a finding whose path no longer exists, and the CRITICAL suffix survives" {
# A since-deleted path has no content left to pin a `safe` approval to -
# writing one anyway (the old behaviour) produced a baseline entry
# baseline_suppresses() can never match (its own empty-sha guard rejects
# it as malformed), so the surface stayed permanently unsuppressible -
# while `a` still advanced crit_acked and let the report shed its
# -CRITICAL.txt protection for good. Refusing outright keeps both the
# baseline and the report's protection honest.
@test "a finding whose path no longer exists is cleared, not refused forever" {
# Refusing outright kept the baseline honest - there is no content left to
# pin a `safe` entry to - but it also meant crit_acked never reached
# crit_seen, so the report kept -CRITICAL.txt. select_report prefers
# CRITICAL, so one deleted file pinned `ack` to a report that no decision
# could resolve and hid every other report behind it, permanently.
#
# Still nothing written to the baseline: an entry with an empty sha can
# never match. The finding is simply cleared, because a surface that does
# not exist does not run. If it comes back, it comes back as new.
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
rm -f "$WS/.envrc" # gone by the time ack runs
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[ "$status" -eq 0 ]
[[ "$output" == *"cannot approve: .envrc no longer exists"* ]]
[[ "$output" == *"nothing recorded"* ]]
[ ! -f "$BASEDIR/$slug" ] # nothing written to the baseline at all
[[ "$output" == *"cleared"* ]]
[[ "$output" == *".envrc"* ]]
! grep -qE '^(safe|spam) ' "$BASEDIR/$slug" 2>/dev/null # nothing unmatchable recorded
grep -q '^reviewed ' "$BASEDIR/$slug" # but the report is done with
run bash -c "ls '$AUDITDIR/$slug'"
[[ "$output" == *"-CRITICAL.txt"* ]] # unacknowledged CRITICAL keeps its protection
[[ "$output" != *"-CRITICAL.txt"* ]] # and no longer blocks the queue
}
# The other half: clearing is not a way to launder a finding whose surface is
# still there. Only an absent one can be cleared.
@test "a finding whose path still exists is approved against its content, not cleared" {
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 'a\n' | '$AUDIT' ack '$WS'"
[[ "$output" != *"cleared"* ]]
grep -q '^safe .envrc ' "$BASEDIR/$slug"
}
@test "a workspace path containing its own ': ' cannot hijack ack's id derivation" {
@@ -937,7 +1134,10 @@ REPORT
@test "ack records a safe tcb entry whose id round-trips and suppresses the next compare" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
mkdir -p "$WS/bin"; echo x > "$WS/bin/x"; echo x > "$WS/install.sh"
# Hidden from git, which is the only shape that is still a finding.
mkdir -p "$WS/bin" "$WS/.git/info"
echo x > "$WS/bin/x"; echo x > "$WS/install.sh"
echo 'bin/x' > "$WS/.git/info/exclude"
export DEJIMA_AUDIT_REPO_ROOT="$(readlink -f "$WS")"
snap
echo changed > "$WS/bin/x"
@@ -978,11 +1178,11 @@ REPORT
git -C "$WS" config core.hooksPath /tmp/evil
mkdir -p "$WS/.vscode"; echo '{}' > "$WS/.vscode/settings.json"
cmp_ >/dev/null 2>&1
# 's' on the CRITICAL gitconfig finding, then 'a' on the WARNING one that
# 'i' on the CRITICAL gitconfig finding, then 'a' on the WARNING one that
# follows it - so $BASEDIR/$slug is guaranteed to exist afterward and the
# negative grep below can't pass vacuously against a missing file.
run bash -c "printf 's\na\n' | '$AUDIT' ack '$WS'"
[[ "$output" == *"cannot 'always ignore' a CRITICAL surface - approve the exact content or skip."* ]]
run bash -c "printf 'i\na\n' | '$AUDIT' ack '$WS'"
[[ "$output" == *"cannot ignore a CRITICAL surface from now on"* ]]
[ -f "$BASEDIR/$slug" ]
! grep -q '^spam gitconfig:core.hooksPath' "$BASEDIR/$slug"
}
@@ -995,7 +1195,7 @@ REPORT
cmp_ >/dev/null
run bash -c "cd '$WS' && '$AUDIT' show 2>/dev/null"
[ "$status" -eq 0 ]
[[ "$output" == *".envrc"* ]]
[[ "$output" == *"report(s) for $WS"* ]]
}
@test "show finds the workspace when run from a subdirectory" {
@@ -1005,7 +1205,7 @@ REPORT
cmp_ >/dev/null
run bash -c "cd '$WS/src/deep' && '$AUDIT' show 2>/dev/null"
[ "$status" -eq 0 ]
[[ "$output" == *".envrc"* ]]
[[ "$output" == *"report(s) for $WS"* ]]
}
@test "show finds the parent repo when run from a linked worktree" {
@@ -1020,7 +1220,7 @@ REPORT
cmp_ >/dev/null
run bash -c "cd '$WS/.worktrees/wt' && '$AUDIT' show 2>/dev/null"
[ "$status" -eq 0 ]
[[ "$output" == *".envrc"* ]]
[[ "$output" == *"report(s) for $WS"* ]]
}
@test "show finds the parent repo's report even after the worktree itself was snapshotted" {
@@ -1035,7 +1235,7 @@ REPORT
"$AUDIT" snapshot "$WS/.worktrees/wt" "$(mktemp)"
run bash -c "cd '$WS/.worktrees/wt' && '$AUDIT' show 2>/dev/null"
[ "$status" -eq 0 ]
[[ "$output" == *".envrc"* ]]
[[ "$output" == *"report(s) for $WS"* ]]
}
@test "show names the path it resolved when that workspace has no reports" {
@@ -1056,7 +1256,7 @@ REPORT
other="$(mktemp -d)"; git -C "$other" init -q
run bash -c "cd '$other' && '$AUDIT' show '$WS' 2>/dev/null"
[ "$status" -eq 0 ]
[[ "$output" == *".envrc"* ]]
[[ "$output" == *"report(s) for $WS"* ]]
rm -rf "$other"
}
@@ -1201,9 +1401,9 @@ key_of() { printf '%s' "$1" | sha256sum | cut -d' ' -f1; }
snap
head -1 "$STATE" | grep -q '^### staging /'
# The existing section ranges are anchored at ### gitconfig and
# ### statusline, so putting staging first must leave them untouched.
# ### hostexec, so putting staging first must leave them untouched.
grep -q '^### gitconfig$' "$STATE"
grep -q '^### statusline$' "$STATE"
grep -q '^### hostexec$' "$STATE"
}
@test "denylisted surfaces are never staged, not even transiently" {
@@ -1730,3 +1930,43 @@ sidecar() { local r; r="$(find "$AUDITDIR" -name '*.txt' | sort | tail -1)"; r="
run "$AUDIT" compare "$WS" "$STATE"
[[ "$output" != *"writable"* ]]
}
@test "a host-execution finding still records the id existing baselines hold" {
# The id is what an approval is stored under. Generalising the mechanism
# must not rename it: every baseline an operator already has says
# "statusline:statusline.sh", and an id that no longer matches re-fires
# a CRITICAL they approved. The namespace and leaf come from the plugin
# for exactly this reason - they are the harness's to keep stable.
printf '#!/bin/sh\necho original\n' > "$DEJIMA_AUDIT_STATUSLINE"
snap
printf '#!/bin/sh\necho changed\n' > "$DEJIMA_AUDIT_STATUSLINE"
run cmp_
[[ "$output" == *"HOST-CODE-EXECUTION"* ]] || { echo "$output"; return 1; }
# Approve it, then confirm the baseline line is the historical id.
printf 'a\n' | "$AUDIT" ack "$WS" >/dev/null 2>&1 || true
run cat "$BASEDIR/$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
[[ "$output" == *"safe statusline:statusline.sh "* ]] \
|| { echo "baseline id changed: $output"; return 1; }
}
@test "a harness declaring no host-execution surface is watched for none" {
# Not every harness runs something on the host from its own config. The
# absence must mean nothing is checked, not that the previous harness's
# path is.
printf '#!/bin/sh\necho original\n' > "$DEJIMA_AUDIT_STATUSLINE"
snap
cat > "$CONFIG_DIR/config" <<EOF
[core]
harness = claude-code
[harness.claude-code]
projects_dir = $PROJDIR
statusline_path =
[audit]
dir = $AUDITDIR
baselines_dir = $BASEDIR
EOF
printf '#!/bin/sh\necho changed\n' > "$DEJIMA_AUDIT_STATUSLINE"
run cmp_
[[ "$output" != *"HOST-CODE-EXECUTION"* ]] \
|| { echo "watched a surface the harness declares no path for: $output"; return 1; }
}
+17 -16
View File
@@ -18,14 +18,15 @@
# a CI runner and there is nothing here to assert.
#
# Its values are the names of the jobs whose environment they verify -
# `sandbox` and `host-docker` - rather than a separate vocabulary. A reader
# looking at a red `runner-env (host-docker)` should not have to work out
# `confined` and `privileged` - rather than a separate vocabulary. Those are
# the same words the runner labels and dejima-ci-hardener's roles use, so a
# reader looking at a red `runner-env (privileged)` should not have to work out
# which job that corresponds to.
setup() {
[ -n "${DEJIMA_CI_TIER:-}" ] || skip "not on a CI runner (DEJIMA_CI_TIER unset)"
case "$DEJIMA_CI_TIER" in
sandbox|host-docker) ;;
confined|privileged) ;;
*) echo "unknown DEJIMA_CI_TIER: $DEJIMA_CI_TIER" >&2; return 1 ;;
esac
}
@@ -53,8 +54,8 @@ PY
# --- the grant that decides everything --------------------------------------
@test "the sandbox jobs are not handed the Docker daemon" {
[ "$DEJIMA_CI_TIER" = sandbox ] || skip "asserted for the sandbox runner"
@test "the confined jobs are not handed the Docker daemon" {
[ "$DEJIMA_CI_TIER" = confined ] || skip "asserted for the confined runner"
# act_runner mounts /var/run/docker.sock into every job container unless
# container.docker_host is "-". An unset value is the default, and the
# default mounts - so this is the assertion that a runner was actually
@@ -67,11 +68,11 @@ PY
}
}
@test "the host-docker job does have the daemon it is scheduled for" {
[ "$DEJIMA_CI_TIER" = host-docker ] || skip "asserted for the host-docker runner"
# The positive control for the label routing. Without it, a host-docker
@test "the privileged job does have the daemon it is scheduled for" {
[ "$DEJIMA_CI_TIER" = privileged ] || skip "asserted for the privileged runner"
# The positive control for the label routing. Without it, a privileged
# job that landed on a hardened runner would fail later and obscurely, and
# a sandbox job that landed on this one would be caught only by the test
# a confined job that landed on this one would be caught only by the test
# above - one direction of a two-direction problem.
[ -e /var/run/docker.sock ]
}
@@ -80,7 +81,7 @@ PY
# Whatever else is true, exactly one of them gets the daemon. If both jobs
# report the same answer here, the labels are not routing to two different
# runners and the tiering exists only on paper.
if [ "$DEJIMA_CI_TIER" = sandbox ]; then
if [ "$DEJIMA_CI_TIER" = confined ]; then
[ ! -e /var/run/docker.sock ]
else
[ -e /var/run/docker.sock ]
@@ -89,7 +90,7 @@ PY
# --- egress, for the tier the rules actually apply to ------------------------
#
# Deliberately scoped to the sandbox runner. host-docker's job runs in the
# Deliberately scoped to the confined runner. The privileged job runs in the
# HOST network namespace - that is the grant they exist for - so the bridge
# rules dejima-ci-hardener installs do not sit in front of them at all. Their
# egress is the host's own, which this repository's examples do not define.
@@ -97,7 +98,7 @@ PY
# the failure mode this whole suite is about.
@test "an allowlisted destination is reachable" {
[ "$DEJIMA_CI_TIER" = sandbox ] || skip "bridge rules do not apply in the host netns"
[ "$DEJIMA_CI_TIER" = confined ] || skip "bridge rules do not apply in the host netns"
# The positive control for everything below. Without it, a runner with no
# network at all passes every negative assertion in this file.
run getent hosts "$ALLOWED_HOST"
@@ -113,7 +114,7 @@ PY
}
@test "a name that is on no allowlist does not resolve" {
[ "$DEJIMA_CI_TIER" = sandbox ] || skip "bridge rules do not apply in the host netns"
[ "$DEJIMA_CI_TIER" = confined ] || skip "bridge rules do not apply in the host netns"
# .invalid is reserved by RFC 2606 precisely so that it never resolves
# anywhere - but here it has to be REFUSED by a resolver that answers for
# allowlisted names, which the positive control above establishes.
@@ -122,7 +123,7 @@ PY
}
@test "a job cannot reach a resolver of its own choosing" {
[ "$DEJIMA_CI_TIER" = sandbox ] || skip "bridge rules do not apply in the host netns"
[ "$DEJIMA_CI_TIER" = confined ] || skip "bridge rules do not apply in the host netns"
# The check most easily left out, and the one that decides whether the DNS
# allowlist means anything: a job that can query any resolver it likes can
# resolve anything it likes, whatever dnsmasq is configured with. Traffic
@@ -139,7 +140,7 @@ PY
}
@test "a destination that is on no allowlist is unreachable" {
[ "$DEJIMA_CI_TIER" = sandbox ] || skip "bridge rules do not apply in the host netns"
[ "$DEJIMA_CI_TIER" = confined ] || skip "bridge rules do not apply in the host netns"
# An IP literal rather than a name, so this tests the firewall rather than
# the resolver. Reaching it by name would fail at DNS first and prove
# nothing about DOCKER-USER.
@@ -160,7 +161,7 @@ PY
}
@test "IPv6 egress is not an unfiltered way around all of the above" {
[ "$DEJIMA_CI_TIER" = sandbox ] || skip "bridge rules do not apply in the host netns"
[ "$DEJIMA_CI_TIER" = confined ] || skip "bridge rules do not apply in the host netns"
# An address family with no allowlist is not a smaller hole than the one
# the rules above close; on a runner with IPv6 egress it is the same hole.
#
+530 -19
View File
@@ -188,6 +188,16 @@ EOF
write_config() {
printf '%s\n' "$@" > "$CONFIG_DIR/config"
# A launch is refused without a harness, so every fixture needs one. Its
# own [core] section, which is legal and resolves the same; a test that
# means to omit it writes the file directly.
# A key assignment, not the word anywhere in the arguments: a [mounts]
# path containing "harness" would otherwise suppress the seeding.
local _a _has=0
for _a in "$@"; do
[[ "$_a" =~ ^[[:space:]]*harness[[:space:]]*= ]] && _has=1
done
[ "$_has" -eq 1 ] || printf '[core]\nharness = claude-code\n' >> "$CONFIG_DIR/config"
}
@test "config_get returns the schema default when the key is absent" {
@@ -1045,24 +1055,16 @@ run_install() {
local name out hit=0
# docs/ and systemd/ are pure operator-facing prose - nothing there is
# live shell plumbing, so any mention of a retired name is either the
# deliberate "no longer read" notice (install.sh's remote.conf one lives
# outside these two directories, but the exclusion is kept here too in
# case a future doc adds one), a stale instruction, or one of the two
# cases below that are neither:
# docs/ and systemd/ are operator-facing prose, no live plumbing, so a
# retired name there is either the deliberate "no longer read" notice, a
# stale instruction, or one of two cases that are neither:
#
# - A manifest-field line (`env_export = GITEA_API=api ...`): this is
# docs/providers.md quoting a forge manifest's `env_export` value
# verbatim, which *declares* a container-side environment variable -
# the exact thing env_export is for - not an instruction to an operator
# to set one. `GITEA_API` and friends are retired only as host-side,
# operator-set variables (what _warn_legacy_env's table is about); they
# are not retired as the container-side names env_export produces, and
# docs/providers.md documenting that mapping is not a regression.
# - A backtick-quoted mention (`` `GITEA_API` ``): prose naming a
# variable - "the container gets `GITEA_API`" - reads a value, it
# doesn't tell anyone to set one.
# - A manifest-field line (`env_export = GITEA_API=api ...`), which is
# providers.md quoting a manifest verbatim. These names are retired as
# host-side operator-set variables, not as the container-side names
# env_export produces.
# - A backtick-quoted mention - prose naming a variable reads a value, it
# does not tell anyone to set one.
while IFS= read -r name; do
out="$(grep -rnE "\b${name}\b" "$REPO_ROOT/docs" "$REPO_ROOT/systemd" 2>/dev/null \
| grep -v 'no longer read' \
@@ -1134,11 +1136,273 @@ exit 0
EOF
chmod +x "$STUB_DIR/docker"
docker_log="$(mktemp)"
HOME="$RW_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
HOME="$RW_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK="${RW_AGENT_SOCK-}" \
SANDBOX_TEST_DOCKER_LOG="$docker_log" \
run "$REPO_ROOT/bin/dejima" "$1"
}
# A real agent, carrying nothing. `ssh-add -l` against it exits nonzero,
# which is the condition the "no identities loaded" warning keys on, and a
# handmade socket file would not reproduce it.
_empty_agent() {
eval "$(ssh-agent -s)" >/dev/null
RW_AGENT_SOCK="$SSH_AUTH_SOCK"
RW_AGENT_PID="$SSH_AGENT_PID"
}
_kill_agent() { [ -n "${RW_AGENT_PID:-}" ] && kill "$RW_AGENT_PID" 2>/dev/null; return 0; }
# The forwarded agent is the fallback; [core] ssh_key is the arrangement the
# docs recommend, because it is a dedicated identity and works unattended.
# So an empty agent is only worth mentioning when nothing else can push -
# otherwise the warning fires at exactly the operators who took the advice.
# ~/.claude/file-history is keyed by session id, so nothing in its own paths
# says which project a directory belongs to. The transcript names are what
# resolve it, and this is the assertion that the resolution is right: a
# session of this workspace is mounted, one belonging to another project is
# not, and neither is one whose slug merely extends this workspace's - the
# sibling case that #104 turned on.
@test "file-history is mounted for this workspace's sessions and no others" {
_rw_setup
workspace="$(mktemp -d)"
local slug sib own foreign sibling
slug="$(printf '%s' "$workspace" | sed 's/[^a-zA-Z0-9]/-/g')"
sib="$slug-extra"
own=11111111-1111-1111-1111-111111111111
foreign=22222222-2222-2222-2222-222222222222
sibling=33333333-3333-3333-3333-333333333333
mkdir -p "$RW_HOME/.claude/projects/$slug" \
"$RW_HOME/.claude/projects/-some-other-project" \
"$RW_HOME/.claude/projects/$sib"
: > "$RW_HOME/.claude/projects/$slug/$own.jsonl"
: > "$RW_HOME/.claude/projects/-some-other-project/$foreign.jsonl"
: > "$RW_HOME/.claude/projects/$sib/$sibling.jsonl"
mkdir -p "$RW_HOME/.claude/file-history/$own" \
"$RW_HOME/.claude/file-history/$foreign" \
"$RW_HOME/.claude/file-history/$sibling"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
_rw_launch "$workspace"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qF "file-history/$own" "$docker_log" \
|| { echo "own session not mounted:"; grep file-history "$docker_log"; return 1; }
run grep -F "file-history/$foreign" "$docker_log"
[ "$status" -ne 0 ] || { echo "another project's file-history was mounted"; return 1; }
run grep -F "file-history/$sibling" "$docker_log"
[ "$status" -ne 0 ] || { echo "a sibling project's file-history was mounted"; return 1; }
rm -rf "$workspace"
}
# No default on purpose: a release changing it would move an operator who
# never chose to a different harness on upgrade.
@test "a config with no [core] harness is refused, and says what to add" {
_rw_setup
workspace="$(mktemp -d)"
# Written directly, not through write_config, which seeds a harness for
# every other fixture precisely because a launch needs one.
printf '[core]\nssh_key = %s\nforge = none\nworkflow = local-only\n' \
"$RW_KEY" > "$CONFIG_DIR/config"
_rw_launch "$workspace"
[ "$status" -ne 0 ] || { echo "launched with no harness selected:"; echo "$output"; return 1; }
[[ "$output" == *"[core] harness is not set"* ]]
[[ "$output" == *"harness = claude-code"* ]] # the line to add
[[ "$output" == *"Available:"*"claude-code"* ]]
rm -rf "$workspace"
}
@test "the harness schema carries no default to fall back to" {
# Directly again: write_config seeds one, which is the whole point of it.
printf '[core]\nforge = none\nworkflow = local-only\n' > "$CONFIG_DIR/config"
run lib config_get core harness
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$output" = "" ]
}
# --print-config is what an operator reaches for to find out what is unset, so
# it has to survive the thing being unset.
@test "--print-config still works when no harness is selected" {
printf '[core]\nforge = none\nworkflow = local-only\n' > "$CONFIG_DIR/config"
run "$REPO_ROOT/bin/dejima" --print-config
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"core.harness="* ]]
[[ "$output" != *"invalid harness name"* ]]
}
@test "an empty ssh-agent is not warned about when [core] ssh_key is set" {
_rw_setup
workspace="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
_empty_agent
_rw_launch "$workspace"
_kill_agent
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" != *"no identities loaded"* ]] || { echo "$output"; return 1; }
rm -rf "$workspace"
}
# The positive control for the test above: with no key configured the
# forwarded agent really is the only way to push, and an empty one is worth
# saying so before it fails deep into a session.
# --extra-readonly / --extra-read-write append to the config's own lists
# rather than carrying their own mount code, so these assert the mount lands
# and then that every refusal the configured form gets applies to the flag
# too. If they ever stop sharing a path, the guard tests below pass while the
# flags quietly stop being covered by them.
_rw_launch_flags() {
local ws="$1"; shift
STUB_DIR="$(mktemp -d)"
cat > "$STUB_DIR/docker" <<'EOF'
#!/bin/sh
for a in "$@"; do printf '%s\n' "$a" >> "$SANDBOX_TEST_DOCKER_LOG"; done
exit 0
EOF
chmod +x "$STUB_DIR/docker"
docker_log="$(mktemp)"
HOME="$RW_HOME" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
SANDBOX_TEST_DOCKER_LOG="$docker_log" \
run "$REPO_ROOT/bin/dejima" "$@" "$ws"
}
@test "--extra-readonly and --extra-read-write mount for one launch" {
_rw_setup
workspace="$(mktemp -d)"; ro="$(mktemp -d)"; rw="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
_rw_launch_flags "$workspace" --extra-readonly "$ro" --extra-read-write "$rw"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qxF "$ro:$ro:ro" "$docker_log" || { cat "$docker_log"; return 1; }
grep -qxF "$rw:$rw" "$docker_log" || { cat "$docker_log"; return 1; }
rm -rf "$workspace" "$ro" "$rw"
}
@test "the short forms are the same flags, and both are repeatable" {
_rw_setup
workspace="$(mktemp -d)"; a="$(mktemp -d)"; b="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
_rw_launch_flags "$workspace" --ro "$a" --ro "$b"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qxF "$a:$a:ro" "$docker_log" || { cat "$docker_log"; return 1; }
grep -qxF "$b:$b:ro" "$docker_log" || { cat "$docker_log"; return 1; }
rm -rf "$workspace" "$a" "$b"
}
# The flags add to the configured lists; they do not replace them. A launch
# that dropped the config's own entries would still pass the two tests above.
@test "a flag adds to [mounts] readonly rather than replacing it" {
_rw_setup
workspace="$(mktemp -d)"; configured="$(mktemp -d)"; extra="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' "readonly = $configured"
_rw_launch_flags "$workspace" --ro "$extra"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qxF "$configured:$configured:ro" "$docker_log" || { cat "$docker_log"; return 1; }
grep -qxF "$extra:$extra:ro" "$docker_log" || { cat "$docker_log"; return 1; }
rm -rf "$workspace" "$configured" "$extra"
}
# The point of appending to the same list: a flag is a way to say where, not
# a way past the refusals. Both directions, since the two lists are guarded
# by different code.
@test "--extra-readonly is refused when it names the approval store" {
_rw_setup
workspace="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
_rw_launch_flags "$workspace" --ro "$CONFIG_DIR"
[ "$status" -ne 0 ] || { echo "launched anyway:"; echo "$output"; return 1; }
[[ "$output" == *"Refusing --extra-readonly path"* ]] || { echo "$output"; return 1; }
rm -rf "$workspace"
}
@test "--extra-read-write is refused when it names the approval store" {
_rw_setup
workspace="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
_rw_launch_flags "$workspace" --rw "$CONFIG_DIR"
[ "$status" -ne 0 ] || { echo "launched anyway:"; echo "$output"; return 1; }
[[ "$output" == *"Refusing --extra-read-write path"* ]] || { echo "$output"; return 1; }
rm -rf "$workspace"
}
# A refusal that named "[mounts] readonly" would send someone to a config
# file that does not mention the path.
@test "a rejected flag path is reported as a flag, not as a config entry" {
_rw_setup
workspace="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
_rw_launch_flags "$workspace" --ro /nonexistent-flag-path
[ "$status" -ne 0 ]
[[ "$output" == *"--extra-readonly path does not exist"* ]] || { echo "$output"; return 1; }
rm -rf "$workspace"
}
# Both lists are colon-separated, so such a path cannot be represented in one
# and would split into two mounts that each fail the existence check.
@test "a flag path containing a colon is refused" {
_rw_setup
workspace="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
_rw_launch_flags "$workspace" --ro /tmp/has:colon
[ "$status" -ne 0 ]
[[ "$output" == *"must not contain a colon"* ]] || { echo "$output"; return 1; }
rm -rf "$workspace"
}
# #15. Both of these reach `extra_mounts+=(-v ...)` with a path computed at
# run time, so the static grep for a literal $PROJECT_STORE / config_root
# cannot see either one - it passed throughout. [mounts] read_write has been
# guarded since it was added; these two were not.
@test "[mounts] readonly is refused when it names the approval store" {
_rw_setup
workspace="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' "readonly = $CONFIG_DIR"
_rw_launch "$workspace"
[ "$status" -ne 0 ] || { echo "launched anyway:"; echo "$output"; return 1; }
[[ "$output" == *"Refusing [mounts] readonly entry"* ]] || { echo "$output"; return 1; }
rm -rf "$workspace"
}
# The target is discovered from a symlink rather than configured, so nothing
# had vetted it: a hook pointing into the approval store mounted the store.
@test "a ~/.claude symlink is refused when its target sits in the approval store" {
_rw_setup
workspace="$(mktemp -d)"
mkdir -p "$RW_HOME/.claude/hooks" "$CONFIG_DIR/sub"
: > "$CONFIG_DIR/sub/hook.sh"
ln -s "$CONFIG_DIR/sub/hook.sh" "$RW_HOME/.claude/hooks/hook.sh"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
_rw_launch "$workspace"
[ "$status" -ne 0 ] || { echo "launched anyway:"; echo "$output"; return 1; }
[[ "$output" == *"Refusing ~/.claude symlink target"* ]] || { echo "$output"; return 1; }
rm -rf "$workspace"
}
# The ordinary case the symlink scan exists for: this repo's own hooks/ are
# symlinked into ~/.claude and must still be mounted. Without this, refusing
# everything would pass the two tests above.
@test "a ~/.claude symlink to an ordinary directory is still mounted" {
_rw_setup
workspace="$(mktemp -d)"
hookdir="$(mktemp -d)"
: > "$hookdir/hook.sh"
mkdir -p "$RW_HOME/.claude/hooks"
ln -s "$hookdir/hook.sh" "$RW_HOME/.claude/hooks/hook.sh"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
_rw_launch "$workspace"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qxF "$hookdir:$hookdir:ro" "$docker_log" \
|| { echo "hook target not mounted:"; cat "$docker_log"; return 1; }
rm -rf "$workspace" "$hookdir"
}
@test "an empty ssh-agent is warned about when [core] ssh_key is not set" {
_rw_setup
workspace="$(mktemp -d)"
write_config '[core]' 'forge = none' 'workflow = local-only'
_empty_agent
_rw_launch "$workspace"
_kill_agent
[[ "$output" == *"no identities loaded"* ]] || { echo "$output"; return 1; }
rm -rf "$workspace"
}
@test "a read_write entry is mounted without :ro, unlike a readonly one" {
_rw_setup
rw="$(mktemp -d)"
@@ -1338,7 +1602,7 @@ EOF
# a gitea token reaching the container: the shipped defaults select no
# forge at all, so leaving them implicit would mean nothing ever reads
# token_file and the refusal under test could never fire.
printf '[core]\nfirewall = on\nssh_key = %s\nforge = gitea\nworkflow = worktree-pr\n[forge.gitea]\nssh_host = git.example\napi = https://api.example\ntoken_file = %s/token\n' \
printf '[core]\nharness = claude-code\nfirewall = on\nssh_key = %s\nforge = gitea\nworkflow = worktree-pr\n[forge.gitea]\nssh_host = git.example\napi = https://api.example\ntoken_file = %s/token\n' \
"$RW_KEY" "$CONFIG_DIR" > "$CONFIG_DIR/config"
printf 'real-secret\ngarbage\n' > "$CONFIG_DIR/token"
STUB_DIR="$(mktemp -d)"
@@ -1357,3 +1621,250 @@ EOF
|| { echo "docker was invoked despite the refusal"; return 1; }
rm -rf "$workspace" "$RW_HOME" "$STUB_DIR"
}
# --- epitaph: what a session leaves when its context dies ---------------------------------------------------
#
# What a long session worked out is in the summary Claude Code writes when it
# compacts, and that goes away with the container. The store is host-side and
# never mounted, so a later session can neither read what an earlier one
# concluded nor edit what this one is about to record (#104).
_epitaph_fixture() {
EPI_STORE="$(mktemp -d)"
EPI_TR="$(mktemp -d)"
EPI_WS="$(mktemp -d)"; git -C "$EPI_WS" init -q
EPI_SLUG="$(printf '%s' "$EPI_WS" | sed 's/[^a-zA-Z0-9]/-/g')"
mkdir -p "$EPI_TR/$EPI_SLUG"
write_config '[epitaph]' "dir = $EPI_STORE"
}
# One compact_boundary carrying the metadata, then the summary itself.
_epitaph_transcript() {
local f="$EPI_TR/$EPI_SLUG/$1.jsonl" text="${2:-worked out X}" uid="${3:-aaaabbbb}"
{
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"
} > "$f"
}
@test "an epitaph is written out of the container's reach" {
_epitaph_fixture
_epitaph_transcript s1 "the thing this session worked out"
run "$REPO_ROOT/bin/dejima-epitaph" save "$EPI_WS" "$EPI_TR"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
local saved; saved="$(find "$EPI_STORE" -name '*.md')"
[ -n "$saved" ]
grep -q 'the thing this session worked out' "$saved"
grep -q 'trigger: manual' "$saved"
grep -q '900 before, 90 after' "$saved"
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
}
@test "saving twice does not duplicate an epitaph already kept" {
# `save` runs on every exit, and a session compacted three times has two
# epitaphs an earlier run already wrote.
_epitaph_fixture
_epitaph_transcript s1 "once only"
"$REPO_ROOT/bin/dejima-epitaph" save "$EPI_WS" "$EPI_TR" >/dev/null 2>&1
"$REPO_ROOT/bin/dejima-epitaph" save "$EPI_WS" "$EPI_TR" >/dev/null 2>&1
[ "$(find "$EPI_STORE" -name '*.md' | wc -l)" -eq 1 ]
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
}
@test "another project's epitaphs are not collected" {
_epitaph_fixture
_epitaph_transcript s1 "mine"
mkdir -p "$EPI_TR/-some-other-project"
printf '{"type":"user","isCompactSummary":true,"uuid":"cccc","sessionId":"s-2","timestamp":"2026-01-02T03:04:05.000Z","message":{"content":"theirs"}}\n' \
> "$EPI_TR/-some-other-project/s2.jsonl"
"$REPO_ROOT/bin/dejima-epitaph" save "$EPI_WS" "$EPI_TR" >/dev/null 2>&1
run grep -rl 'theirs' "$EPI_STORE"
[ "$status" -ne 0 ] || { echo "collected another project's summary"; return 1; }
grep -rq 'mine' "$EPI_STORE"
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
}
@test "no epitaph is written when [epitaph] enabled is off" {
_epitaph_fixture
write_config '[epitaph]' "dir = $EPI_STORE" 'enabled = off'
_epitaph_transcript s1 "should not be kept"
"$REPO_ROOT/bin/dejima-epitaph" save "$EPI_WS" "$EPI_TR" >/dev/null 2>&1
[ -z "$(find "$EPI_STORE" -name '*.md' 2>/dev/null)" ]
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
}
@test "a slug that kept nothing gets no directory" {
# A workspace's worktrees are slugs of its own, and they have transcripts;
# what they mostly do not have is a compaction summary in any of them. A
# directory per slug therefore made the store read as one entry per
# worktree, all of them empty.
_epitaph_fixture
_epitaph_transcript s1 "kept"
git -C "$EPI_WS" -c user.email=t@example.com -c user.name=t \
commit -q --allow-empty -m init
local wt="$EPI_WS/wt" wt_slug
git -C "$EPI_WS" worktree add -q "$wt" -b side
wt_slug="$(printf '%s' "$wt" | sed 's/[^a-zA-Z0-9]/-/g')"
mkdir -p "$EPI_TR/$wt_slug"
printf '{"type":"user","message":{"content":"no summary in here"}}\n' \
> "$EPI_TR/$wt_slug/s2.jsonl"
# A directory an earlier run already left behind goes too.
mkdir -p "$EPI_STORE/$wt_slug"
"$REPO_ROOT/bin/dejima-epitaph" save "$EPI_WS" "$EPI_TR" >/dev/null 2>&1
[ ! -d "$EPI_STORE/$wt_slug" ] \
|| { echo "empty directory left for $wt_slug"; return 1; }
[ -n "$(find "$EPI_STORE/$EPI_SLUG" -name '*.md' 2>/dev/null)" ] \
|| { echo "the workspace's own epitaph was not kept"; return 1; }
git -C "$EPI_WS" worktree remove --force "$wt" 2>/dev/null || true
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.
run grep -n 'epitaph' "$REPO_ROOT/docker/docker-compose.yml"
[ "$status" -ne 0 ] || { echo "$output"; return 1; }
run grep -nE 'extra_mounts\+=.*epitaph' "$REPO_ROOT/bin/dejima"
[ "$status" -ne 0 ] || { echo "$output"; return 1; }
}
# --- harness plugins ------------------------------------------------------
@test "a harness with a manifest but no plugin is refused" {
# Support is per harness and deliberate. A manifest alone would present
# as a supported harness while none of what dejima does for a harness
# had been written, which is the "thin drop-in manifest" this design
# exists to prevent.
_rw_setup
workspace="$(mktemp -d)"
# A provider root of its own, via the DEJIMA_PROVIDER_ROOT test hook,
# rather than a directory added to the checkout: a suite that writes into
# the repository it is testing leaves litter when it aborts and collides
# with a concurrent run.
proot="$(mktemp -d)"
cp -a "$REPO_ROOT/forges" "$REPO_ROOT/workflows" "$REPO_ROOT/harnesses" "$proot/"
mkdir -p "$proot/harnesses/test-no-plugin"
printf 'command = true\n' > "$proot/harnesses/test-no-plugin/harness.conf"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'harness = test-no-plugin'
DEJIMA_PROVIDER_ROOT="$proot" _rw_launch "$workspace"
[ "$status" -ne 0 ] || { echo "launched anyway:"; echo "$output"; return 1; }
[[ "$output" == *"No harness plugin for 'test-no-plugin'"* ]] || { echo "$output"; return 1; }
rm -rf "$workspace" "$proot"
}
@test "emit_mount refuses a harness state directory that overlaps a guarded store" {
# The reason the refusal lives in emit_mount rather than in each plugin.
# A harness's state paths are computed from config rather than written
# down, so this is exactly the shape that reaches a mount without anyone
# having looked at the value - the same blind spot as #15.
_rw_setup
workspace="$(mktemp -d)"
audit_dir="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[audit]' "dir = $audit_dir" \
'[harness.claude-code]' "projects_dir = $audit_dir/projects"
_rw_launch "$workspace"
[ "$status" -ne 0 ] || { echo "launched anyway:"; echo "$output"; return 1; }
[[ "$output" == *"Refusing harness state mount"* ]] || { echo "$output"; return 1; }
rm -rf "$workspace" "$audit_dir"
}
@test "a harness that cannot host a guard says so at launch" {
# The capability bar allows a harness to be supported at a smaller guard
# set. What it does not allow is that being invisible: an operator who
# has these everywhere else would otherwise have to read a manifest to
# find out they are gone here.
_rw_setup
workspace="$(mktemp -d)"
proot="$(mktemp -d)"
cp -a "$REPO_ROOT/forges" "$REPO_ROOT/workflows" "$REPO_ROOT/harnesses" "$proot/"
# Same harness, minus two guards, so the message is the only difference.
sed -i 's/^guards .*/guards = bash-deny notify/' \
"$proot/harnesses/claude-code/harness.conf"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
DEJIMA_PROVIDER_ROOT="$proot" _rw_launch "$workspace"
[[ "$output" == *"does not support 2 of dejima's guards"* ]] || { echo "$output"; return 1; }
[[ "$output" == *"push-guard"* ]] || { echo "$output"; return 1; }
[[ "$output" == *"merge the agent"* || "$output" == *"merge its own pull requests"* ]] \
|| { echo "$output"; return 1; }
rm -rf "$workspace" "$proot"
}
@test "a harness hosting every guard says nothing about guards" {
# The message must not become a line an operator learns to scroll past.
_rw_setup
workspace="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
_rw_launch "$workspace"
[[ "$output" != *"guards"* ]] || { echo "$output"; return 1; }
rm -rf "$workspace"
}
@test "a harness declaring a guard dejima does not know is refused" {
# A typo would otherwise turn a guard off silently, which is the failure
# the declaration exists to prevent.
_rw_setup
workspace="$(mktemp -d)"
proot="$(mktemp -d)"
cp -a "$REPO_ROOT/forges" "$REPO_ROOT/workflows" "$REPO_ROOT/harnesses" "$proot/"
sed -i 's/^guards .*/guards = bash-deny push-gaurd/' \
"$proot/harnesses/claude-code/harness.conf"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
DEJIMA_PROVIDER_ROOT="$proot" _rw_launch "$workspace"
[ "$status" -ne 0 ] || { echo "launched anyway:"; echo "$output"; return 1; }
[[ "$output" == *"unknown guard: push-gaurd"* ]] || { echo "$output"; return 1; }
rm -rf "$workspace" "$proot"
}
@test "install.sh registers the selected harness's guards, gated on its declarations" {
# Which guards exist and how they are installed is the harness's, so a
# harness declaring fewer gets fewer - and dejima does not write a
# config belonging to a harness the operator did not choose.
local proot; proot="$(mktemp -d)"
cp -a "$REPO_ROOT/forges" "$REPO_ROOT/workflows" "$REPO_ROOT/harnesses" "$proot/"
sed -i 's/^guards .*/guards = bash-deny/' \
"$proot/harnesses/claude-code/harness.conf"
run_install DEJIMA_PROVIDER_ROOT="$proot"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
run jq -r '[.hooks.PreToolUse[]?.hooks[]?.command] | join(" ")' \
"$CONFIG_DIR/.claude/settings.json"
[[ "$output" == *"deny-dangerous-bash.sh"* ]] || { echo "$output"; return 1; }
[[ "$output" != *"git-push-guard.sh"* ]] \
|| { echo "registered an undeclared guard: $output"; return 1; }
[[ "$output" != *"merge-guard.sh"* ]] \
|| { echo "registered an undeclared guard: $output"; return 1; }
rm -rf "$proot"
}
@test "install.sh registers every guard when the harness declares them all" {
# The shipped harness, unmodified: the behaviour this replaced, kept.
run_install
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
local cmds
cmds="$(jq -r '[.hooks.PreToolUse[]?.hooks[]?.command,
.hooks.SessionStart[]?.hooks[]?.command] | join(" ")' \
"$CONFIG_DIR/.claude/settings.json")"
local h
for h in sandbox-context.sh git-push-guard.sh merge-guard.sh deny-dangerous-bash.sh; do
[[ "$cmds" == *"$h"* ]] || { echo "$h not registered: $cmds"; return 1; }
done
# Linked for the read-only hooks mount, but registered per session by
# bin/dejima rather than here, where it would fire for host sessions too.
[ -L "$CONFIG_DIR/.claude/hooks/dejima-notify.sh" ]
[[ "$cmds" != *"dejima-notify.sh"* ]] \
|| { echo "notify registered host-wide: $cmds"; return 1; }
}
@test "install.sh registers no guards when no harness is selected" {
# Writing one harness's config for an operator who has chosen another,
# or none, is the coupling this removed.
# A config that already exists is never overwritten, so this is how an
# operator upgrading from before [core] harness was required arrives.
printf '[core]\nforge = none\nworkflow = local-only\n' > "$CONFIG_DIR/config"
run_install
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"No [core] harness is set"* ]] || { echo "$output"; return 1; }
[ ! -f "$CONFIG_DIR/.claude/settings.json" ] \
|| { echo "wrote a harness config anyway"; return 1; }
}
+39 -2
View File
@@ -92,7 +92,8 @@ launch() {
local conf
conf="$(bash -c 'source "$1"; config_path' _ "$LIB")"
mkdir -p "$(dirname "$conf")"
printf '[core]\nssh_key = %s\n' "$LAUNCH_DIR/sandbox-key" >> "$conf"
# No default, so a config that launches must select one.
printf '[core]\nharness = claude-code\nssh_key = %s\n' "$LAUNCH_DIR/sandbox-key" >> "$conf"
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
SANDBOX_TEST_RUN_ENV="$LAUNCH_ENV" \
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
@@ -817,7 +818,7 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
# no domains at all, or by the env prefix below never reaching the child.
printf 'good.example.com\n' > "$CONFIG_DIR/allowed-domains"
launch_setup
printf '[core]\nssh_key = %s\n' "$LAUNCH_DIR/sandbox-key" >> "$CONFIG_DIR/config"
printf '[core]\nharness = claude-code\nssh_key = %s\n' "$LAUNCH_DIR/sandbox-key" >> "$CONFIG_DIR/config"
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
SANDBOX_TEST_RUN_ENV="$LAUNCH_ENV" \
SSH_AUTH_SOCK= ALLOWED_DOMAINS=evil.example \
@@ -908,6 +909,42 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
[ "$(launched_env SANDBOX_IMAGE)" = "dejima:$(id -un)-$slug" ]
}
# nvim's data/state/cache are Docker volumes, not host paths, so nothing a
# session installs reaches the host's own nvim. They do outlive the container
# though, and under one fixed name each they were shared by every project on
# the machine: a Lua file dropped under lazy/, or a binary replaced under
# mason/bin/, is executed by whatever project's session next runs nvim in the
# sandbox. Scoped per project, the same way the derived image tag is.
@test "the nvim volumes are per project, not one set shared by every project" {
launch_setup
run launch "$PROJ"
[ "$status" -eq 0 ]
slug="$(bash -c 'source "$1"; project_slug "$2"' _ "$LIB" "$PROJ")"
[ "$(launched_env SANDBOX_NVIM_DATA)" = "dejima_nvim-data$slug" ]
[ "$(launched_env SANDBOX_NVIM_STATE)" = "dejima_nvim-state$slug" ]
[ "$(launched_env SANDBOX_NVIM_CACHE)" = "dejima_nvim-cache$slug" ]
local other; other="$(mktemp -d)"
run launch "$other"
[ "$status" -eq 0 ]
[ "$(launched_env SANDBOX_NVIM_DATA)" != "dejima_nvim-data$slug" ]
rm -rf "$other"
}
# project_slug turns a leading / into -, and a Docker volume name may not
# start with one: without the prefix every launch would fail at `compose run`
# rather than at anything that names the cause.
@test "the derived nvim volume names are valid Docker volume names" {
launch_setup
run launch "$PROJ"
[ "$status" -eq 0 ]
local v
for v in SANDBOX_NVIM_DATA SANDBOX_NVIM_STATE SANDBOX_NVIM_CACHE; do
[[ "$(launched_env "$v")" =~ ^[a-zA-Z0-9][a-zA-Z0-9_.-]*$ ]] \
|| { echo "$v=$(launched_env "$v") is not a valid volume name"; return 1; }
done
}
@test "the launcher builds and runs the global layer when only global deps are approved" {
"$REPO_ROOT/bin/dejima-dep" add --global apt:libpq-dev >/dev/null
launch_setup
+76 -1
View File
@@ -236,7 +236,8 @@ assert_remedy_forges_exist() {
local forge workflow harness
forge="$(lib config_default core.forge)"
workflow="$(lib config_default core.workflow)"
harness="$(lib config_default core.harness)"
# No default to read: the shipped pair under test is forge/workflow.
harness=claude-code
[ "$(lib manifest_get workflow "$workflow" push)" = "off" ] \
|| { echo "default workflow '$workflow' pushes - a fresh install has no forge to push to"; return 1; }
run lib provider_validate_selection "$forge" "$workflow" "$harness"
@@ -591,6 +592,80 @@ assert_remedy_forges_exist() {
[[ "$output" != *"create"*"PR"* ]] || { echo "branch-push briefing mentions opening a PR"; return 1; }
}
# #32: an implementer ran a bare `git config user.name ...` inside a
# worktree and wrote the parent checkout's real config, misattributing three
# commits. A worktree isolates the working tree, not `.git/config`, and the
# briefing told the agent to use worktrees while saying nothing about that -
# which reads as more isolation than is on offer. The same unscoped command
# reaches core.hooksPath and credential.helper, which execute on the host.
#
# In the core paragraph rather than a workflow's own prose, so a workflow can
# opt into worktrees but cannot ship them without this.
@test "every worktree briefing says a worktree does not isolate .git/config" {
local wf
for wf in worktree-pr branch-push; do
[ "$(lib manifest_get workflow "$wf" requires_worktree)" = "on" ]
run lib briefing_compose gitea "$wf" claude-code
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *".git/config"* ]] \
|| { echo "$wf briefing does not mention .git/config"; return 1; }
[[ "$output" == *"git -C"* ]] \
|| { echo "$wf briefing does not give the scoped form"; return 1; }
done
}
# The negative half: a workflow that does not use worktrees has no reason to
# carry the paragraph, and its presence there would mean the splice is
# unconditional rather than opt-in.
@test "a workflow without worktrees does not carry the worktree paragraph" {
[ "$(lib manifest_get workflow local-only requires_worktree)" = "off" ]
run lib briefing_compose none local-only claude-code
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" != *"does not isolate"* ]]
[[ "$output" != *"worktree MUST live"* ]]
}
# The default that blocks GitHub push exists to stop an eager agent pushing to
# somebody else's project. When the operator lifts it, the agent gains the
# ability without the reason, so the briefing supplies the reason.
@test "the briefing warns against pushing elsewhere when GitHub push is on" {
DEJIMA_GITHUB_PUSH=on run lib briefing_compose gitea worktree-pr claude-code
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"GitHub push is enabled for this session"* ]]
[[ "$output" == *"pointed at and nothing else"* ]]
[[ "$output" == *"not a contribution"* ]]
# and it says what to do instead of nothing at all
[[ "$output" == *"say so in your reply"* ]]
}
# Two points from docs/responsible-use.md that an agent can act on, as
# opposed to the majority which are the operator's to follow. Kept short on
# purpose: this text is in every session's context, for every user.
@test "the briefing names the two responsible-use points an agent can act on" {
run lib briefing_compose gitea worktree-pr claude-code
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
# a domain that acts on the world rather than serving data
[[ "$output" == *"action rather than a source"* ]]
[[ "$output" == *"let the operator decide"* ]]
# and things in the working tree that are not code
[[ "$output" == *"not"*"code"* ]]
[[ "$output" == *".env"* ]]
}
@test "the briefing says nothing about GitHub push when it is off" {
run lib briefing_compose gitea worktree-pr claude-code
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" != *"GitHub push is enabled"* ]]
}
@test "bin/dejima turns the GitHub push briefing on for both routes to it" {
# --allow-github-push, and forge = github under a pushing workflow.
grep -q 'DEJIMA_GITHUB_PUSH=on' "$REPO_ROOT/bin/dejima"
grep -q 'ALLOW_GITHUB_PUSH_FLAG" = 1 \]; then' "$REPO_ROOT/bin/dejima"
grep -q 'hosts_allowlisted_by_core" ) \?= "on"\|hosts_allowlisted_by_core)" = "on"' "$REPO_ROOT/bin/dejima"
grep -q 'export DEJIMA_GITHUB_PUSH' "$REPO_ROOT/bin/dejima"
}
@test "local-only pushes nothing and blocks no branches" {
run lib manifest_validate workflow local-only
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
+9 -14
View File
@@ -409,21 +409,16 @@ make_cert() {
# shell *function* persists in the calling shell rather than scoping to
# the call, so it would leak into every later test in this file.
#
# The -u pair is what makes this test about the code rather than about
# where it runs (#56). "No cert configured" was expressed by not setting
# the variables, which only holds where nothing else set them -
# entrypoint.sh:211 exports both into every tls-transport session, so a
# suite run from inside a dejima container inherited real cert paths, the
# guard below never fired, and execution fell through to the listen. What
# failed then was an EADDRINUSE against the session's own forwarder on
# :443, reported as this assertion failing - the code was fine and the
# test was reading its own environment. Unsetting them states the
# precondition instead of assuming it.
# The -u pair makes this about the code rather than where it runs (#56).
# "No cert configured" used to be expressed by not setting the variables,
# which only holds where nothing else did - entrypoint.sh exports both
# into every tls-transport session, so a suite run inside a dejima
# container inherited real paths, the guard never fired, and the
# fall-through hit EADDRINUSE on :443 and was reported as this assertion
# failing. Unsetting states the precondition instead of assuming it.
#
# The port override is belt-and-braces: the cert check runs before the
# listen today, so a passing test never binds anything, but if that order
# ever changes the fall-through lands on an unused port rather than on
# whatever holds :443.
# The port override is belt-and-braces: the cert check precedes the listen
# today, so a fall-through lands on an unused port if that ever changes.
run env -u SANDBOX_ANTHROPIC_TLS_CERT -u SANDBOX_ANTHROPIC_TLS_KEY \
SANDBOX_ANTHROPIC_PROXY_SOCKET="$SOCK" SANDBOX_ANTHROPIC_TRANSPORT=tls \
SANDBOX_ANTHROPIC_PROXY_PORT=18398 \
+79 -28
View File
@@ -5,6 +5,25 @@
# compromised session writes into - so most of what's here is about what it
# refuses to do with that input.
# No test in this file may modify the repository's own CLIs. Two of them need
# an exit-time audit that fails, and they get it from a temp shadow of bin/
# (see shadow_repo) rather than by overwriting the real one. This is what
# proves it stayed that way even if a test aborts partway through, and it is
# checked against a hash rather than against `git status` so that it holds
# while someone has uncommitted edits to that file.
setup_file() {
REMOTE_AUDIT_SUM="$(sha256sum "$BATS_TEST_DIRNAME/../bin/dejima-audit" | cut -d' ' -f1)"
export REMOTE_AUDIT_SUM
}
teardown_file() {
local now
now="$(sha256sum "$BATS_TEST_DIRNAME/../bin/dejima-audit" | cut -d' ' -f1)"
[ "$now" = "$REMOTE_AUDIT_SUM" ] || {
echo "test/remote.bats modified bin/dejima-audit in the repository" >&2
return 1
}
}
setup() {
REPO_ROOT="$BATS_TEST_DIRNAME/.."
LIB="$REPO_ROOT/bin/dejima-project-lib.sh"
@@ -19,15 +38,6 @@ teardown() {
if [ -n "${TMUX_DIR:-}" ]; then
TMUX_TMPDIR="$TMUX_DIR" tmux kill-server 2>/dev/null || true
fi
# Safety net for tests that temporarily swap out the real bin/dejima-
# audit: each such test restores it itself right after `run`, before any
# assertion that could short-circuit the test body, but this catches the
# case anyway if something in between still managed to abort first.
if [ -n "${AUDIT_BACKUP:-}" ] && [ -f "$AUDIT_BACKUP" ]; then
cp "$AUDIT_BACKUP" "$REPO_ROOT/bin/dejima-audit"
chmod +x "$REPO_ROOT/bin/dejima-audit"
rm -f "$AUDIT_BACKUP"
fi
rm -rf "$CONFIG_DIR" "$RUNTIME_DIR" ${STUB_DIR:+"$STUB_DIR"} ${LAUNCH_DIR:+"$LAUNCH_DIR"} \
${TMUX_DIR:+"$TMUX_DIR"} ${FAKE_HOME:+"$FAKE_HOME"}
}
@@ -70,6 +80,54 @@ write_remote_config() {
[ "$output" = "$RUNTIME_DIR/dejima" ]
}
# A throwaway copy of the repository whose bin/dejima-audit a test may
# replace or delete. Everything else is a symlink to the real thing, so this
# costs one directory of links.
#
# bin/ is copied rather than linked, and both reasons matter. bin/dejima
# resolves its own location with `readlink -f`, so a symlinked copy would
# point SCRIPT_DIR back at the repository and the stub would never be the one
# it calls. And a symlink is actively dangerous here: `cat > $SHADOW/bin/
# dejima-audit` follows it and truncates the real script, which is precisely
# what this helper exists to stop. Copying removes the whole class rather
# than one instance of it.
#
# Two tests here need an audit binary that fails, and they used to get one by
# overwriting `$REPO_ROOT/bin/dejima-audit` and restoring it afterwards. That
# left the working tree holding a two-line stub for the length of the test,
# which is a poor thing to do to a security tool: a run killed partway, or
# two runs of this file at once, restores the stub permanently instead of the
# script, and a suite that passes while silently disabling the exit-time
# audit is the exact failure this file exists to prevent. It also, reasonably
# enough, reads like an attack to anything watching the repository for
# changes.
shadow_repo() {
local root entry
root="$(cd "$REPO_ROOT" && pwd)"
SHADOW="$BATS_TEST_TMPDIR/shadow"
mkdir -p "$SHADOW"
for entry in "$root"/*; do
[ "$(basename "$entry")" = bin ] && continue
ln -sfn "$entry" "$SHADOW/$(basename "$entry")"
done
cp -a "$root/bin" "$SHADOW/bin"
DEJIMA_BIN="$SHADOW/bin/dejima"
}
# Stands in for the real audit and does nothing but fail. Self-describing
# because a file watcher can surface it mid-test, and a bare `exit 3` sitting
# where a security tool belongs is alarming without this comment on it.
shadow_failing_audit() {
cat > "$SHADOW/bin/dejima-audit" <<'EOF'
#!/bin/bash
# Test fixture for test/remote.bats, living in that test's temp directory
# and never in the repository. Fails on purpose: bin/dejima must still
# report the container's exit code and not this one.
exit 3
EOF
chmod +x "$SHADOW/bin/dejima-audit"
}
# A fake `docker` that records its argv, so bin/dejima's launch path can be
# exercised with no daemon. Same approach as test/project.bats.
stub_launch() {
@@ -119,12 +177,13 @@ launch() {
local conf
conf="$(bash -c 'source "$1"; config_path' _ "$LIB")"
mkdir -p "$(dirname "$conf")"
printf '[core]\nssh_key = %s\n' "$LAUNCH_DIR/sandbox-key" >> "$conf"
# No default, so a config that launches must select one.
printf '[core]\nharness = claude-code\nssh_key = %s\n' "$LAUNCH_DIR/sandbox-key" >> "$conf"
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
SANDBOX_TEST_SETTINGS_COPY="$LAUNCH_DIR/settings-copy.json" \
SSH_AUTH_SOCK= ALLOWED_DOMAINS= \
"$REPO_ROOT/bin/dejima" "$@"
"${DEJIMA_BIN:-$REPO_ROOT/bin/dejima}" "$@"
}
run_line() { grep -- ' run --rm ' "$LAUNCH_DIR/docker-log" | tail -1; }
@@ -280,15 +339,9 @@ done
exit 0
EOF
chmod +x "$LAUNCH_DIR/docker"
AUDIT_REAL="$REPO_ROOT/bin/dejima-audit"
AUDIT_BACKUP="$(mktemp)"
cp "$AUDIT_REAL" "$AUDIT_BACKUP"
printf '#!/bin/bash\nexit 3\n' > "$AUDIT_REAL"
chmod +x "$AUDIT_REAL"
shadow_repo
shadow_failing_audit
run launch "$PROJ"
cp "$AUDIT_BACKUP" "$AUDIT_REAL"
chmod +x "$AUDIT_REAL"
rm -f "$AUDIT_BACKUP"
[ "$status" -eq 5 ]
rm -rf "$PROJ"
}
@@ -309,14 +362,9 @@ done
exit 0
EOF
chmod +x "$LAUNCH_DIR/docker"
AUDIT_REAL="$REPO_ROOT/bin/dejima-audit"
AUDIT_BACKUP="$(mktemp)"
cp "$AUDIT_REAL" "$AUDIT_BACKUP"
rm -f "$AUDIT_REAL"
shadow_repo
rm -f "$SHADOW/bin/dejima-audit"
run launch "$PROJ"
cp "$AUDIT_BACKUP" "$AUDIT_REAL"
chmod +x "$AUDIT_REAL"
rm -f "$AUDIT_BACKUP"
[ "$status" -eq 5 ]
rm -rf "$PROJ"
}
@@ -2450,14 +2498,17 @@ nginx_blocks() {
}
@test "install.sh links the remote scripts and the notify hook, registers neither, and enables nothing" {
grep -q '^link_hook dejima-notify.sh$' "$REPO_ROOT/install.sh"
# The hook is linked by the harness plugin now - which file it is, and
# where the harness reads it from, is the harness's business - while the
# CLIs stay install.sh's, being dejima's own.
grep -q '_link_hook dejima-notify.sh' "$REPO_ROOT/bin/harness/claude-code.sh"
grep -q '^link_bin dejima-attach$' "$REPO_ROOT/install.sh"
grep -q '^link_bin dejima-ttyd$' "$REPO_ROOT/install.sh"
grep -q '^link_bin dejima-notify-watch$' "$REPO_ROOT/install.sh"
# The notify hook is registered per session by bin/dejima, into a
# throwaway settings copy. Registered here it would fire for every
# unsandboxed host session too.
run grep -nE 'register_hook.*dejima-notify' "$REPO_ROOT/install.sh"
run grep -nE '_register .*dejima-notify' "$REPO_ROOT/bin/harness/claude-code.sh"
[ "$status" -ne 0 ]
# Standing up a front door is an explicit operator decision. Nothing in
# the installer may start, enable or even copy a unit. Code only - the
+259 -35
View File
@@ -29,42 +29,31 @@ IMAGE=dejima:latest
container_name() { echo "dejima-test-$(basename "$BATS_FILE_TMPDIR")"; }
network_name() { echo "dejima-test-net-$(basename "$BATS_FILE_TMPDIR")"; }
# Every container this file starts must go on network_name(), never the
# default bridge: dns-proxy.sh forwards allowlisted names to Docker's
# embedded resolver at 127.0.0.11, which only exists on a user-defined
# network (compose gives the real sandbox one). On the default bridge the
# container gets the host's nameservers instead, dnsmasq has nowhere to
# forward, and startup dies in dns-proxy.sh - see its own check for this.
# Every container here must go on network_name(), never the default bridge:
# dns-proxy.sh forwards to Docker's embedded resolver at 127.0.0.11, which
# only exists on a user-defined network. On the default bridge dnsmasq has
# nowhere to forward and startup dies in dns-proxy.sh.
#
# Readiness is the *final* `sleep infinity` (entrypoint.sh's
# SANDBOX_TEST_MODE exec), matched exactly. A bare `pgrep -x sleep` also
# matches the short-lived sleeps of the startup sequence itself
# (dns-proxy.sh's dig-retry loop, entrypoint.sh's forwarder-bind wait), and
# a test that proceeds on one of those asserts against a container whose
# firewall hasn't been installed yet - which silently turns egress
# assertions into vacuous passes, since everything is still reachable at
# that point. Waiting for `sleep infinity` means the whole startup sequence
# (DNS proxy, firewall, ipsets, logging, forwarder bind) has completed.
# Readiness is the FINAL `sleep infinity` (entrypoint.sh's SANDBOX_TEST_MODE
# exec), matched exactly. A bare `pgrep -x sleep` also matches the startup
# sequence's own short sleeps, and proceeding on one of those asserts against
# a container with no firewall yet - turning egress assertions into vacuous
# passes, since everything is still reachable.
#
# 120 rather than 60: init-firewall.sh's GitHub ranges fetch now retries, and a
# run that loses two attempts before succeeding spends ~26s of the budget on
# that call alone (#59) - which on a runner that has just spent three minutes
# unpacking an image is enough to turn a recovery into a spurious failure. A
# longer budget only delays reporting a genuinely stuck startup; it cannot hide
# one, since nothing here passes on a timeout.
# `docker wait` returns the moment the container exits, but the json-file log
# driver writes asynchronously - so reading the log right then can return one
# that stops mid-stream, with everything after some flush boundary missing.
# 120s rather than 60: the GitHub ranges fetch retries, and losing two
# attempts costs ~26s (#59), which on a runner that just spent minutes
# unpacking an image turns a recovery into a spurious failure. A longer
# budget only delays reporting a stuck startup; nothing here passes on a
# timeout.
# `docker wait` returns on exit, but the json-file log driver writes
# asynchronously, so reading right then can return a log that stops
# mid-stream. That presented as a container "aborting" partway through
# init-firewall.sh's domain loop at a different domain each run, and survived
# three fixes before the giveaway: no message from an ERR trap that cannot be
# bypassed. Nothing had aborted; the read was short.
#
# That is not hypothetical. It presented as a container "aborting" partway
# through init-firewall.sh's domain loop, at a slightly different domain each
# run, and survived three separate fixes to that loop before the giveaway: the
# log ended with no message from an ERR trap that cannot be bypassed and no
# output from a retry that prints on every path. Nothing had aborted. The read
# was short.
#
# Two identical consecutive reads means the driver has caught up. Deliberately
# content-neutral - waiting for an expected string would make the wait pass or
# Two identical consecutive reads means the driver has caught up.
# Content-neutral on purpose - waiting for an expected string would pass or
# fail on the very thing the caller is about to assert.
settled_logs() {
local container="$1" current previous=""
@@ -207,6 +196,7 @@ teardown() {
docker rm -f "dejima-test-sshkey-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-ghpush-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-ghforge-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-ghextra-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-claudejson-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-rebind-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-overlays-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
@@ -220,6 +210,8 @@ teardown() {
docker rm -f "dejima-test-tls-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-nohosts-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-badtransport-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-notifydir-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "dejima-test-nonotify-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
}
dexec() {
@@ -368,6 +360,26 @@ dexec_node() {
[ -n "$output" ]
}
@test "the session user cannot address a resolver directly, so the DNS allowlist is not bypassable" {
# init-firewall.sh opens UDP/TCP 53 to any destination for root-owned
# sockets, because that is what Docker's embedded resolver forwards
# external names from and without it nothing in here resolves at all.
# This is the assertion that the opening stops there. `node` is the
# session user and there is no sudo in the image, so if it could reach a
# resolver of its own choosing it could carry data out in the query name
# itself - the exact thing dns-proxy.sh exists to prevent, and something
# no allowlist of *names* can catch once the *resolver* is attacker-chosen.
#
# 1.1.1.1 rather than an unrouted address: it answers if it is reachable,
# so a pass here means the packet was stopped and not that the probe was
# aimed at nothing.
run dexec_node dig +short registry.npmjs.org
[ -n "$output" ]
run dexec_node dig +time=2 +tries=1 @1.1.1.1 definitely-not-allowed.example.org
[ "$status" -ne 0 ]
}
@test "api.anthropic.com resolves in proxy-off mode (kill switch / API-key auth)" {
# api.anthropic.com is left off the allowlist when the credential proxy is
# on, so a bypass of the /etc/hosts pin that resolves the name by DNS gets
@@ -385,8 +397,30 @@ dexec_node() {
}
@test "GitHub is reachable on port 443" {
run dexec curl --connect-timeout 5 -s -o /dev/null -w "%{http_code}" https://api.github.com/zen
[ "$status" -eq 0 ]
# Retried, and only because this is a POSITIVE assertion: more attempts
# can only turn a pass into a pass. The unreachability assertions below
# are never retried - there, a retry could hide a hole that opens once.
#
# It failed once on CI with nothing to go on: no curl exit code, no HTTP
# status, no way to tell DNS from the ipset from the far end. Everything
# else in that run said the firewall was right - the port-22 push grant
# reached GitHub, so github-domains held real addresses - which left one
# five-second connect against a live third-party service as the only
# explanation, and no evidence to confirm it with. Hence the diagnostics
# as much as the retries.
local attempt code rc=0
for attempt in 1 2 3; do
code="$(dexec curl --connect-timeout 5 -s -o /dev/null \
-w '%{http_code}' https://api.github.com/zen 2>&1)" && return 0
rc=$?
sleep 2
done
echo "api.github.com:443 unreachable after 3 attempts." >&2
echo "last curl exit: $rc, last output: '$code'" >&2
echo "exit 6 is DNS, 7 or 28 is the route or the firewall; an HTTP code" >&2
echo "with a non-zero exit means the connection worked. Check with" >&2
echo "'ipset list github-domains' inside the container." >&2
false
}
@test "GitHub is NOT reachable on port 22 (push transport is physically absent)" {
@@ -468,6 +502,54 @@ dexec_node() {
docker rm -f "$test_container" >/dev/null 2>&1 || true
}
@test "a GitHub name declared as an extra domain stays port-restricted, so push stays unreachable" {
# .dejima/allowed-domains is read with the project and is meant to widen
# what a session can reach. It must not widen how. GitHub's addresses are
# confined to 443 by init-firewall.sh, and that is the entire read-only
# guarantee - with 22 shut there is no transport left for the forwarded
# ssh-agent's key to push with, whatever a hook does or does not catch.
#
# allowed-domains carries no port restriction, so before ipset_for_domain
# this path put GitHub's address there and opened 22. It surfaced as an
# intermittent startup failure rather than as a silent hole only because
# the port-22 self-check re-resolves the name: it catches the hole when
# GitHub's second answer matches the first, and misses it when the name
# has rotated in between. This test does not depend on that coin flip.
local test_container="dejima-test-ghextra-$(basename "$BATS_FILE_TMPDIR")"
docker run -d --name "$test_container" \
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
-e SANDBOX_TEST_MODE=1 \
-e GH_TOKEN="${GH_TOKEN:-}" \
-e DEJIMA_FORGE_HOSTS= \
-e ALLOWED_DOMAINS="github.com api.github.com" \
--entrypoint /usr/local/bin/entrypoint.sh \
"$IMAGE" >/dev/null
# Coming up at all is part of the assertion: the port-22 self-check runs
# during startup, and with the address in the wrong set it fails the boot.
wait_ready "$test_container"
# Resolved inside the container, through the same dnsmasq that answered
# during startup - so this asks about the address actually allowlisted,
# not one a fresh lookup from elsewhere happened to return.
local ip
ip=$(docker exec "$test_container" dig +short A github.com | head -1)
[ -n "$ip" ]
run docker exec "$test_container" ipset test github-domains "$ip"
[ "$status" -eq 0 ]
run docker exec "$test_container" ipset test allowed-domains "$ip"
[ "$status" -ne 0 ]
# And the property those two sets exist to produce.
run docker exec "$test_container" timeout 5 bash -c "cat < /dev/null > /dev/tcp/github.com/22"
[ "$status" -ne 0 ]
docker rm -f "$test_container" >/dev/null 2>&1 || true
}
@test "a configured forge host is exempted from stop-dns-rebind, other allowed domains are not" {
# Passed as DEJIMA_FORGE_HOSTS, not GITEA_SSH_HOST: the container no longer
# derives hostnames from any one forge's variables - bin/dejima resolves
@@ -523,6 +605,73 @@ dexec_node() {
rm -rf "$src"
}
# #104: the whole ~/.claude/projects tree used to be mounted read-write, so a
# session could write another project's transcripts and agent memory. Memory
# is loaded into context at session start, which makes that a write into a
# future session's premises rather than its input.
#
# Three properties, and the second is the one that decides whether scoping is
# viable at all: a slug that is absent has to be creatable, because that is
# what Claude Code does on a first run against a path with no history. If the
# parent were read-only instead, this is where it would hang the way a
# blocked ~/.claude.json write did.
@test "only the mounted project slugs exist in the container, and an unmounted one is still creatable" {
local test_container="dejima-test-projects-$(basename "$BATS_FILE_TMPDIR")"
local src; src="$(mktemp -d)"
mkdir -p "$src/own" "$src/sibling" "$src/fh-own"
echo host > "$src/own/existing.jsonl"
echo secret > "$src/sibling/other.jsonl"
# file-history holds verbatim copies of edited files, so an unmounted
# session directory being absent is the whole point of scoping it.
echo 'other project source' > "$src/fh-own/aaaa@v1"
chmod -R 777 "$src"
docker run -d --name "$test_container" \
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
-e SANDBOX_TEST_MODE=1 \
-e GH_TOKEN="${GH_TOKEN:-}" \
-v "$src/own:/home/node/.claude/projects/own" \
-v "$src/fh-own:/home/node/.claude/file-history/own-session" \
--entrypoint /usr/local/bin/entrypoint.sh \
"$IMAGE" >/dev/null
wait_ready "$test_container"
# the sibling project is absent, not merely unwritable
run docker exec -u node "$test_container" test -e /home/node/.claude/projects/sibling
[ "$status" -ne 0 ]
run docker exec -u node "$test_container" ls /home/node/.claude/projects
[[ "$output" != *sibling* ]]
# a slug with no history yet can still be created, and stays in the
# container: it is not on the host afterwards
run docker exec -u node "$test_container" \
bash -c 'mkdir -p /home/node/.claude/projects/fresh && echo x > /home/node/.claude/projects/fresh/a.jsonl'
[ "$status" -eq 0 ]
[ ! -e "$src/fresh" ]
# and the mounted slug is the one that does reach the host
docker exec -u node "$test_container" \
bash -c 'echo written > /home/node/.claude/projects/own/new.jsonl'
[ "$(cat "$src/own/new.jsonl")" = written ]
# Same three properties for file-history. The middle one is what the
# image's pre-created directory buys: without it Docker creates
# file-history/ root-owned while setting up the mount nested in it, and
# the session cannot record an undo entry for its own id at all.
run docker exec -u node "$test_container" ls /home/node/.claude/file-history
[[ "$output" != *other-session* ]]
run docker exec -u node "$test_container" \
bash -c 'mkdir -p /home/node/.claude/file-history/fresh-session && echo x > /home/node/.claude/file-history/fresh-session/b@v1'
[ "$status" -eq 0 ]
[ ! -e "$src/fresh-session" ]
docker exec -u node "$test_container" \
bash -c 'echo written > /home/node/.claude/file-history/own-session/c@v1'
[ "$(cat "$src/fh-own/c@v1")" = written ]
docker rm -f "$test_container" >/dev/null 2>&1 || true
rm -rf "$src"
}
@test "a staged statusline.sh is copied in root-owned, and replacing it never reaches the host" {
# Companion to the overlay-dirs test above, but for the config
# bin/dejima stages at /etc/dejima-claude for entrypoint.sh to copy into
@@ -1272,3 +1421,78 @@ EOF
[[ "$logs" == *"'tls'"* ]] || dump "the refusal did not name the valid values"
[[ "$logs" == *"'loopback'"* ]] || dump "the refusal did not name the valid values"
}
# The three-symptom failure entrypoint.sh's marker-directory check exists to
# name: notifications stop, the session vanishes from dejima-attach, and the
# notify hook reports EACCES - with no obvious moment they started.
#
# It is Docker's answer to a missing bind source that produces it. bin/dejima
# creates the marker directory per launch and removes it on exit, so a
# container that is RESTARTED rather than relaunched has no source left, and
# Docker creates one root-owned rather than refusing. The directory then
# exists, so the hook's own `[ -d ]` guard passes and the write fails instead.
@test "a marker directory the sandbox user cannot write is reported at startup" {
# Its own container, not container_name() - that one is created in
# setup_file and used by every dexec test in this file. Starting a second
# container under that name conflicts, and removing it at the end of this
# body would take the shared one with it.
local container="dejima-test-notifydir-$(basename "$BATS_FILE_TMPDIR")"
local host_dir; host_dir="$(mktemp -d)"
# Unwritable by the sandbox user, which is the property the check keys on.
# Built here rather than by removing a bind source, so the test does not
# depend on Docker's fallback behaviour to set up a test OF the
# consequences of that behaviour.
#
# chmod rather than mounting :ro. A read-only mount would make the
# "not repaired" assertion below vacuous - root could not chown it even
# if entrypoint.sh tried. Mode 555 leaves it repairable by root inside the
# container and still unwritable by the session user, so that assertion
# means something.
chmod 555 "$host_dir"
docker run -d --name "$container" \
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
-e SANDBOX_TEST_MODE=1 \
-e GH_TOKEN="${GH_TOKEN:-}" \
-e DEJIMA_NOTIFY_DIR=/run/dejima-notify \
-v "$host_dir:/run/dejima-notify" \
--entrypoint /usr/local/bin/entrypoint.sh \
"$IMAGE" >/dev/null
wait_ready "$container"
run settled_logs "$container"
[[ "$output" == *"not writable by"* ]]
[[ "$output" == *"RESTARTED rather than"* ]]
# Reported, not repaired: chowning would restore notifications while the
# session stayed absent from a registry this container never sees.
#
# Asserted as "still not writable by the session user" rather than "still
# owned by root": ownership depends on who ran the suite, since mktemp -d
# follows the caller, while writability is the property the check reads
# and the one repairing it would change.
run docker exec -u node "$container" test -w /run/dejima-notify
[ "$status" -ne 0 ]
rm -rf "$host_dir"
}
# Remote access is opt-in and off by default. The check above must say nothing
# at all for the sessions that never asked for it, which is every session by
# default - a warning about an unconfigured optional feature is noise on every
# start, and noise is what stops warnings being read.
@test "no marker-directory warning when remote access is not enabled" {
local container="dejima-test-nonotify-$(basename "$BATS_FILE_TMPDIR")"
docker run -d --name "$container" \
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
-e SANDBOX_TEST_MODE=1 \
-e GH_TOKEN="${GH_TOKEN:-}" \
--entrypoint /usr/local/bin/entrypoint.sh \
"$IMAGE" >/dev/null
wait_ready "$container"
run settled_logs "$container"
[[ "$output" != *"dejima-notify"* ]]
[[ "$output" != *"remote access is enabled"* ]]
}
+869 -52
View File
File diff suppressed because it is too large Load Diff
+16 -2
View File
@@ -44,5 +44,19 @@ Git workflow - do this even though nothing stops you from doing otherwise:
crash, a restart, or a destructive command it is warning about will get
the call blocked, which looks like a broken tool rather than what it is.
Writing the file first keeps the prose out of the command entirely.
`{{issue_command}}` (list/view/create/comment/close/reopen) takes the same
flag, for recording a problem you found but aren't fixing.
`{{issue_command}}` (list/view/create/edit/comment/close/reopen) takes the
same flag, for recording a problem you found but aren't fixing.
- Do not hard-wrap prose in those bodies. Write each paragraph as one long
line and separate paragraphs with a blank line. The forge renders a single
newline as a line break, so text you wrapped at a column arrives with a
break every ten words: it wraps early on a desktop, and on a phone the
screen breaks the line first and your newline breaks it again a few words
later, which is much harder to read than either alone. Keep the breaks
that carry structure - list items, table rows, headings, and anything
inside a code fence - and drop the ones that were only there to keep the
source tidy. The tools send the body through exactly as written; nothing
reflows it for you.
- To revise a body after the fact, use `{{pr_command}} edit <N> --body-file`
(or `{{issue_command}} edit <N>`). Rewriting the file you built the body
from changes nothing on its own - the forge has its own copy from the
moment you created it, so an edit has to be sent.