Compare commits

...
Author SHA1 Message Date
branderson 53da70aae9 Point the shared-directory section at what reaching it remotely costs
tests / confined (shellcheck) (pull_request_target) Successful in 1m8s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (static) (pull_request_target) Successful in 2m9s
tests / confined (audit) (pull_request_target) Successful in 4m11s
tests / confined (config) (pull_request_target) Successful in 6m46s
tests / confined (conversation) (pull_request_target) Successful in 51s
tests / confined (project) (pull_request_target) Successful in 2m52s
tests / confined (providers) (pull_request_target) Successful in 1m48s
tests / confined (proxy) (pull_request_target) Successful in 58s
tests / confined (remote) (pull_request_target) Successful in 2m19s
tests / confined (share) (pull_request_target) Successful in 1m36s
tests / privileged (pull_request_target) Successful in 3m30s
tests / runner-env (confined) (pull_request_target) Successful in 46s
remote-access.md's "Mounting the box's files" is how you get at a project's
share from another machine, and it already links back here for the
clear-it-out advice. The other direction was missing, so an operator
reading about ~/shared had no way to reach the warning that a desktop
indexes, thumbnails and previews everything a sandboxed session wrote
there, with nothing of dejima's in the path.
2026-09-02 18:35:30 -07:00
branderson 14a91be8f1 Name the credential proxy's own machinery after the proxy, not after Anthropic
SANDBOX_ANTHROPIC_PROXY_SOCKET, _PROXY_PID, _PROXY_DIR, _PROXY_PORT,
_TLS_CERT, _TLS_KEY and _TRANSPORT describe a unix socket, a forwarder
process, a self-signed cert and a choice between TLS and loopback. None of
that is Anthropic's; the terminator answers for whichever backend's
proxied_domain was selected, and openrouter goes through the same code.
They are SANDBOX_CREDENTIAL_* now, and /etc/dejima-anthropic-tls inside the
container is /etc/dejima-credential-tls for the same reason.

Three keep the old name, and that is the interesting half. BASE_URL,
AUTH_TOKEN and API_KEY each carry a value into the ANTHROPIC_ variable
beside it in docker-compose.yml, which is what Claude Code reads. Renaming
those would name them after the thing they are not. Said at the
declaration so nobody finishes the rename by mistake.

SANDBOX_ANTHROPIC_TRANSPORT keeps its old name in the two places it is the
operator's rather than dejima's: install.sh seeds [credentials] transport
from it on a first install, and _warn_legacy_env names it as retired. Both
read what is in someone's shell profile, and that string did not change.
The rename removes the collision those had with dejima's own variable of
the same name.

A stub in config.bats filtered docker's environment to ^SANDBOX_ANTHROPIC,
so the renamed socket variable stopped appearing in what two credential
tests assert on. It takes both prefixes now.
2026-09-02 18:35:30 -07:00
branderson af480bbf95 Say what the read_write refusal actually covers
The list in configuration.md named ~/.claude and stopped, which was the
same list bin/dejima had. It also left out the epitaph store and the share
root, both of which have been guarded for a while.
2026-09-02 18:35:30 -07:00
branderson 476c0e3e82 Guard the selected harness's own config, not the one harness named in a list
[mounts] read_write refused a writable mount overlapping $HOME/.claude, by
name. Cline keeps its configuration in ~/.agents and ~/.cline and its state
under ~/.local/share/dejima/cline, none of which were in that list - so
read_write = ~/.agents was accepted, handing a session write access to the
hooks and skills the operator's own machine runs next time. That is the
persistence this refusal exists to stop, and the docs already claimed it
was stopped.

The list is derived now. Every host path the selected harness's plugin
names, in all three declaration tiers, plus every `path`-typed key in its
manifest - so the state directories are covered without this knowing what
a given harness calls them, and a harness landing later is guarded on the
day it lands rather than when someone remembers.

Read-only tiers count too. A read-only mount protects the host's file from
the session that has it; it says nothing about a writable mount an
operator points at the same file, and what matters here is only that the
harness reads it on the host.

A staged or seeded entry contributes its host source AND $HOME/<rel>, the
path it is installed at. The two differ when the operator keeps the file
elsewhere, and the rel is named whether or not the file exists, which the
source is not - emit_seed_from drops an absent source. That is what still
covers ~/.claude/plugins, which is reached only through a seed
destination and which the literal used to cover.

The three loads move up to where the guard is, because a guard that runs
after the mount it would refuse is not one. They were already idempotent;
their consumers further down now read the arrays rather than reloading.

Tested by launch rather than by reading the list: one case per way a path
reaches it, under both shipped harnesses, plus a mount outside the
harness's config that must still be accepted - refusing everything would
pass the first test on its own. Each of the three arms was removed in
turn and each failed on a different path.
2026-09-02 18:35:30 -07:00
branderson 4edf40f2fa Merge pull request 'Stop reporting the guards as off because they are no longer on the host' (#310) from guards-check into development
Reviewed-on: #310
Reviewed-by: branderson <brad@branderson.io>
2026-09-02 18:25:20 -07:00
branderson def800d1be Merge pull request 'Let a model be named the way the endpoint that serves it names one' (#311) from model-id into development
Reviewed-on: #311
Reviewed-by: branderson <brad@branderson.io>
2026-09-02 18:21:50 -07:00
branderson 103ef3f9c5 Let a model be named the way the endpoint that serves it names one
tests / confined (conversation) (pull_request_target) Successful in 51s
tests / confined (proxy) (pull_request_target) Successful in 53s
tests / confined (providers) (pull_request_target) Successful in 1m34s
tests / confined (shellcheck) (pull_request_target) Successful in 1m5s
tests / runner-env (confined) (pull_request_target) Successful in 40s
tests / confined (project) (pull_request_target) Successful in 2m23s
tests / confined (audit) (pull_request_target) Successful in 2m52s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (share) (pull_request_target) Successful in 6m22s
tests / confined (remote) (pull_request_target) Successful in 1m59s
tests / confined (static) (pull_request_target) Successful in 2m31s
tests / privileged (pull_request_target) Successful in 3m22s
tests / confined (config) (pull_request_target) Successful in 3m37s
[harness.cline] model was typed `identifier`, which is [A-Za-z0-9._-].
Every hosted catalogue namespaces by vendor with a slash and several
qualify a variant with a colon, so `z-ai/glm-5.3` was refused and not one
OpenRouter id could be configured at all - the backend this harness exists
to reach was unusable, and the launch said only "invalid value" without
saying which character it objected to.

A `modelid` type instead: a leading alphanumeric, then the identifier set
plus `/` and `:`. Still narrow, and for the reason `path` is rather than
for shell safety - the value is written with jq --arg, so JSON escaping is
not the concern. What it protects is that a newline or a tab would split a
record every reader of this config expects to be one line.

Every existing test said `model = m`, which is a bare identifier and
passes under either type. That is why nothing caught it. The new ones
configure real catalogue ids and check what lands in providers.json, not
just that the validator was happy - reverting the type fails them with the
exact error reported, and widening the type to accept anything fails the
half that keeps a space out.
2026-09-02 18:08:52 -07:00
branderson 3f3ddb782b Stop reporting the guards as off because they are no longer on the host
tests / confined (conversation) (pull_request_target) Successful in 49s
tests / confined (proxy) (pull_request_target) Successful in 1m0s
tests / confined (share) (pull_request_target) Successful in 1m32s
tests / confined (providers) (pull_request_target) Successful in 1m45s
tests / confined (shellcheck) (pull_request_target) Successful in 1m6s
tests / runner-env (confined) (pull_request_target) Successful in 42s
tests / confined (remote) (pull_request_target) Successful in 2m8s
tests / confined (project) (pull_request_target) Successful in 2m31s
tests / confined (audit) (pull_request_target) Successful in 3m0s
tests / confined (static) (pull_request_target) Successful in 2m34s
tests / privileged (pull_request_target) Successful in 3m26s
tests / confined (config) (pull_request_target) Successful in 3m36s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
Moving the hook registrations into dejima's own settings overlay left
harness_guards_registered reading the operator's ~/.claude/settings.json
and naming everything it did not find there. Nothing writes there any
more, so on a host that never ran the old installer every launch printed

  not registered for claude-code, so not in force this session:
    the session briefing
    push-guard
    merge-guard
    bash-deny
  Run ./install.sh to register them.

with all four in force, in the overlay, at their baked paths. Following
the instruction makes it permanent: install.sh's upgrade cleanup strips
exactly the registrations the check is looking for.

The check is deleted rather than repointed. What the overlay carries is
composed fresh each launch from the manifest, so reading it back would be
asking dejima whether it did what it just did; which guards a harness
cannot host at all is the manifest's, and harness_report_guards already
says it. The hook stays in the plugin interface - what a harness needs
wired on the host is a real question a future one may answer differently.

Three tests went with it, and their being green is why this shipped. All
three asserted the notice against the operator's settings.json - one that
it fires when nothing is registered there, one that it names a single
hand-dropped guard, one that it stays quiet when all four are present.
Every one of them still passed after the move, because the notice did
still fire; what changed was that firing had become wrong. Tests that
pinned where the guards used to live outlived the guards.

What replaces them asks the question the launch actually answers: with
settings.json present and dejima nowhere in it - the state a correct
install now leaves - a launch must not claim the guards are off, and the
overlay must carry all four. The second half is not optional: without it
the first passes on a launch that registered nothing anywhere.
2026-09-02 17:39:22 -07:00
branderson ae0f31079e Merge pull request 'Answer the microVM question in the threat model, where it can be contradicted' (#307) from why-not-vm into development
Reviewed-on: #307
Reviewed-by: branderson <brad@branderson.io>
2026-09-02 17:06:10 -07:00
branderson 1e35c5c90a Merge pull request 'Say harness where the doc means harness, and backend where it means backend' (#306) from docs-prose into development
Reviewed-on: #306
Reviewed-by: branderson <brad@branderson.io>
2026-09-02 16:47:46 -07:00
branderson bd0d5f41d0 Merge pull request 'Register the sandbox's hooks in its own settings layer, not the operator's' (#304) from hooks-overlay into development
Reviewed-on: #304
Reviewed-by: branderson <brad@branderson.io>
2026-09-02 16:46:31 -07:00
branderson 698020693d Cut the microVM section to what a reader deciding whether to trust it needs
tests / confined (audit) (pull_request_target) Successful in 4m29s
tests / confined (config) (pull_request_target) Successful in 6m28s
tests / confined (conversation) (pull_request_target) Successful in 51s
tests / confined (project) (pull_request_target) Successful in 3m2s
tests / confined (providers) (pull_request_target) Successful in 1m56s
tests / confined (proxy) (pull_request_target) Successful in 1m4s
tests / confined (remote) (pull_request_target) Successful in 2m25s
tests / confined (share) (pull_request_target) Successful in 1m35s
tests / confined (shellcheck) (pull_request_target) Successful in 1m14s
tests / privileged (pull_request_target) Successful in 3m46s
tests / runner-env (confined) (pull_request_target) Successful in 52s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (static) (pull_request_target) Successful in 2m12s
It was the longest section in the file and buried its answer. Most readers
bounce before the end of a section, and this one's audience is someone
wondering whether the container choice was considered - not someone planning a
port. So the whole argument is now in the first two hundred words, and the
detail that only serves a port is gone: what virtiofs would cost per mount,
what a vsock forwarder would be for, which of Docker's verbs the tooling uses,
and gVisor's odds against the browser in the image.

What survives is what the question is actually about. A microVM is the stronger
boundary and this does not dispute it. Escape is the least likely of the six
and the only one needing an exploit. There is already a VM here, hardened from
outside the guest, which is where the effort went. And the three things that
would reopen the decision, so it reads as a judgement rather than a door held
shut.

#104 and #305 are links now rather than bare numbers: a reader who wants the
detail this section stopped carrying should be one click from it.

The README callout goes in the stack section rather than the docs table,
because that is where a reader is standing when the question occurs to them,
and an answer nobody finds gets asked as a question instead.
2026-09-02 14:13:10 -07:00
branderson 5d16b273be Answer the microVM question in the threat model, where it can be contradicted
tests / confined (conversation) (pull_request_target) Successful in 51s
tests / confined (share) (pull_request_target) Successful in 1m29s
tests / runner-env (confined) (pull_request_target) Successful in 45s
tests / confined (static) (pull_request_target) Successful in 2m38s
tests / privileged (pull_request_target) Successful in 3m29s
tests / confined (config) (pull_request_target) Successful in 3m48s
tests / runner-env (privileged) (pull_request_target) Successful in 40s
tests / confined (proxy) (pull_request_target) Successful in 59s
tests / confined (providers) (pull_request_target) Successful in 1m44s
tests / confined (shellcheck) (pull_request_target) Successful in 1m8s
tests / confined (remote) (pull_request_target) Successful in 2m10s
tests / confined (project) (pull_request_target) Successful in 2m34s
tests / confined (audit) (pull_request_target) Successful in 3m5s
The most frequently asked question about this project, and the docs answered
only half of it. "Which deployment this describes" concedes the field's
tiering and says escape moves a layer; it does not say why the effort went
below the container rather than into a stronger one, so the answer lived in
conversations rather than anywhere a reader or a sweep could reach it.

Written as an argument that can lose. It concedes the ordering outright rather
than defending a container as the better primitive, names what gets harder
under a VM as well as the one thing that gets easier, and ends with four things
that would change the answer - Kata as the cheap path, gVisor as a spike,
dropping the trusted-project assumption, and a live escape clearing this
configuration's floor.

The point the field's framing usually misses is that there is already a VM
here. The choice is not one boundary against two: it is an outer VM enforced
from the hypervisor, against that plus an inner one managed by software running
inside the blast radius of the row it is meant to bound.

static.bats pins the concession rather than the prose, because the failure mode
is a later revision quietly upgrading it into a claim that a container is as
strong - which would be false, and would read as reasonable.
2026-09-02 12:50:55 -07:00
branderson 90e32b8936 Say harness where the doc means harness, and backend where it means backend
tests / confined (conversation) (pull_request_target) Successful in 52s
tests / confined (proxy) (pull_request_target) Successful in 58s
tests / confined (share) (pull_request_target) Successful in 1m31s
tests / confined (providers) (pull_request_target) Successful in 1m42s
tests / confined (shellcheck) (pull_request_target) Successful in 1m5s
tests / runner-env (confined) (pull_request_target) Successful in 43s
tests / confined (remote) (pull_request_target) Successful in 2m9s
tests / confined (project) (pull_request_target) Successful in 2m32s
tests / confined (audit) (pull_request_target) Successful in 3m4s
tests / confined (static) (pull_request_target) Successful in 2m35s
tests / privileged (pull_request_target) Successful in 3m33s
tests / confined (config) (pull_request_target) Successful in 3m48s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
The README and docs still stated general properties in Claude Code's
vocabulary, from before there was a second harness or a backend axis. Some
of it was only narrow; the rest had become wrong.

Wrong, and fixed here:

The exit-time audit does not watch ~/.claude/projects. That surface was
retired when the cross-project finding was, because since #104 a session
gets its own slugs and nothing else, so the only writes visible there are
other sessions doing their own work. The README still listed it, and
omitted .vscode/ and the host-execution surfaces the harness declares.

configuration.md described Cline's model configuration in terms of a
[harness.cline] base_url that no longer exists - where the model is moved
to the backend - and said a hosted API cannot be proxied, which openrouter
is. It also told an operator to resume a Cline session on the host with
CLINE_DATA_DIR, three paragraphs above the section saying that is not a
supported path (#301), and opened Cline's parity section with "almost
nothing carries over" while the table of what carries over sat below it.

development.md said the credential proxy was Claude Code's shape
throughout. It is arguments now, which is what lets Cline reach a proxied
backend without a branch of its own.

security.md described the push and merge guards as regex over a command
string. They shadow git and the forge CLI on PATH and read argv; only
deny-dangerous-bash.sh is regex.

The README claimed hooks refuse destructive commands, flat. That needs a
hook the harness can host, and Cline hosts none - the launch already says
so, and now the README does too.

Narrow rather than wrong, and widened:

"One project's session cannot reach another's" documented one harness's
mechanism for a property both have, so it now says what the property is
and then how each harness reaches it. The Cline half is a data directory
per workspace rather than a slice of yours, which is why it clears R4 more
cleanly rather than less.

The credential is a model credential, not an Anthropic OAuth one; the
allowlisted endpoint is the selected backend's, not api.anthropic.com; the
briefing reaches a session by one of three routes rather than through a
SessionStart hook. The ~/.claude mount inventory stays Claude Code's and
now says so, with a pointer to Cline's.

Notifications are gated on a harness declaring the notify guard, which
remote-access.md did not mention - a remote-enabled Cline session gets
attach and the web terminal and no push.
2026-09-02 12:27:18 -07:00
branderson bcb360a29b Say that install.sh writes nothing, rather than that it writes one hook
tests / confined (audit) (pull_request_target) Successful in 2m57s
tests / confined (config) (pull_request_target) Successful in 3m36s
tests / confined (conversation) (pull_request_target) Successful in 54s
tests / confined (project) (pull_request_target) Successful in 2m28s
tests / confined (providers) (pull_request_target) Successful in 1m37s
tests / confined (proxy) (pull_request_target) Successful in 57s
tests / confined (remote) (pull_request_target) Successful in 2m4s
tests / confined (share) (pull_request_target) Successful in 1m28s
tests / confined (static) (pull_request_target) Successful in 2m29s
tests / confined (shellcheck) (pull_request_target) Successful in 1m6s
tests / runner-env (confined) (pull_request_target) Successful in 41s
tests / runner-env (privileged) (pull_request_target) Successful in 36s
tests / privileged (pull_request_target) Successful in 3m36s
The commit that took deny-dangerous-bash.sh off the host left three places
saying otherwise: the README's install step and development.md both named
it as the one hook install.sh still writes, and install.sh's own messages
reported that no guard hooks were registered when it can no longer
register any. The block those messages sit in still needs the harness
name, but for the upgrade cleanup rather than for registration.

security.md described the tamper it guards against as unlinking one of
install.sh's hook symlinks. The tamper is unchanged and the directory is
still mounted read-only for it, but the symlinks in it are now the
operator's own.
2026-09-02 11:50:38 -07:00
branderson 707ffaaa69 Make the placeholder files in the container, not on the operator's machine
tests / confined (conversation) (pull_request_target) Successful in 54s
tests / confined (proxy) (pull_request_target) Successful in 57s
tests / confined (share) (pull_request_target) Successful in 1m31s
tests / confined (providers) (pull_request_target) Successful in 1m43s
tests / confined (shellcheck) (pull_request_target) Successful in 1m9s
tests / runner-env (confined) (pull_request_target) Successful in 41s
tests / confined (remote) (pull_request_target) Successful in 2m11s
tests / confined (project) (pull_request_target) Successful in 2m31s
tests / confined (audit) (pull_request_target) Successful in 3m1s
tests / confined (static) (pull_request_target) Successful in 2m34s
tests / privileged (pull_request_target) Successful in 3m33s
tests / confined (config) (pull_request_target) Successful in 3m45s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
harness_prepare_config created an empty settings.json and CLAUDE.md in
~/.claude when the host had none, so that staging had something to copy.
A file the operator does not have is not one dejima should make on their
machine for its own convenience, and the file is needed in the container
rather than there.

emit_staged_config takes an `ensure` flag now, and the entrypoint creates
what it names - root-owned, read-only, refusing a path that could climb
out of $HOME, and leaving an existing file alone so a second launch does
not blank what was staged from the operator's own.

Two kinds, because empty is not always a valid file. settings.json asks
for `{}`: an empty one is not valid JSON, and Claude Code discards a
settings.json it cannot parse wholesale, taking every permission, hook
and plugin with it - the exact failure the validation above it exists to
catch. statusline.sh asks for neither, because a placeholder there would
be installed executable and run as the status line.

That distinction was not something I reasoned out. The test asserting the
old behaviour said in its own comment that an empty settings.json is one
Claude Code would refuse to parse, which is the only reason the first
version of this - writing an empty file for both - did not ship.

Also adds a Model backend maturity section to docs/providers.md. The
harnesses and forges each have one saying how much use they have actually
had; four backends in a table without one invites the reading that they
are equally exercised. anthropic is the one with the miles on it,
anthropic-api-key and openrouter are confirmed working rather than worn
in, and self-hosted runs no proxy at all so there is least of dejima
between the session and the endpoint. A test derives the list from what
ships, so a backend added later fails it until someone says.
2026-09-02 10:41:04 -07:00
branderson 6b8eda6ff4 Wait for the child before calling it hung
tests / confined (conversation) (pull_request_target) Successful in 52s
tests / confined (proxy) (pull_request_target) Successful in 56s
tests / confined (share) (pull_request_target) Successful in 1m30s
tests / confined (providers) (pull_request_target) Successful in 1m38s
tests / confined (shellcheck) (pull_request_target) Successful in 1m5s
tests / runner-env (confined) (pull_request_target) Successful in 41s
tests / confined (static) (pull_request_target) Successful in 2m30s
tests / privileged (pull_request_target) Successful in 3m22s
tests / runner-env (privileged) (pull_request_target) Successful in 40s
tests / confined (remote) (pull_request_target) Successful in 2m7s
tests / confined (project) (pull_request_target) Successful in 2m27s
tests / confined (audit) (pull_request_target) Successful in 2m57s
tests / confined (config) (pull_request_target) Successful in 3m39s
The pty fixture ended its read loop when the child stopped WRITING and
then required it to have already exited. Those are not the same moment:
dejima runs its exit audit and stops its shipper after the last thing it
prints, so a child that was merely busy got killed and reported as a
hang - and took SIGPIPE on a pty nobody was reading any more, which is
where the "printf: write error: Broken pipe" in the failures came from.

It waits up to a minute for the child to finish now, and only kills after
that. An unanswered prompt still ends in a kill rather than a hung suite,
which is what the kill was for.

This is why --no-pause failed intermittently on the gate and once in six
isolated runs. It was never load: it was a race between a child
finishing and a fixture deciding it never would.

Recorded because the first attempt made it worse in a useful way. Adding
the grace period without importing `time` turned an intermittent failure
into a consistent traceback, which is how the real cause became visible
at all - an intermittent test that fails for two different reasons hides
the second one behind the first.
2026-09-02 09:55:18 -07:00
branderson 2758c998ed Write nothing to the harness's config, including the last hook
tests / confined (conversation) (pull_request_target) Successful in 49s
tests / confined (proxy) (pull_request_target) Successful in 57s
tests / confined (share) (pull_request_target) Successful in 1m30s
tests / confined (providers) (pull_request_target) Successful in 1m41s
tests / confined (shellcheck) (pull_request_target) Successful in 1m6s
tests / runner-env (confined) (pull_request_target) Successful in 42s
tests / confined (remote) (pull_request_target) Successful in 2m8s
tests / confined (project) (pull_request_target) Successful in 2m31s
tests / confined (audit) (pull_request_target) Successful in 2m58s
tests / confined (static) (pull_request_target) Successful in 2m33s
tests / privileged (pull_request_target) Successful in 3m34s
tests / confined (config) (pull_request_target) Successful in 3m39s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
deny-dangerous-bash.sh was the one exception: baked nowhere, registered
in the operator's ~/.claude/settings.json, and deliberately ungated so it
applied to their own sessions too. That was dejima deciding something
about a machine outside its sandbox, which is not dejima's to decide.

It is baked and overlay-registered with the others now, so it still
blocks what it blocked inside a container and stops existing anywhere
else. An operator who wants it on their host sessions can register it
themselves - it is a hook in this repo and always was.

install.sh consequently writes nothing to ~/.claude at all: no symlink,
no registration, and no settings.json brought into existence to hold
neither. harness_register_guards registers nothing and says so; the hook
is kept because "what a harness needs on the host" is a real question a
future one may answer differently, and for Claude Code the answer is now
nothing. The upgrade cleanup takes back this hook as well.

One bug found by a test rather than by reading. Removing install.sh's
`mkdir -p ~/.claude/hooks` left nothing creating ~/.claude, and
harness_prepare_config writes a settings.json and a CLAUDE.md there - so
on a host where Claude Code had never run, a launch failed reporting a
settings.json it had just failed to create. It creates the directory it
writes into now.
2026-09-02 09:32:04 -07:00
branderson b10f5ff89f Register the sandbox's hooks in its own settings layer, not the operator's
tests / confined (conversation) (pull_request_target) Successful in 52s
tests / confined (proxy) (pull_request_target) Successful in 57s
tests / confined (share) (pull_request_target) Successful in 1m28s
tests / confined (providers) (pull_request_target) Successful in 1m44s
tests / confined (shellcheck) (pull_request_target) Successful in 1m11s
tests / runner-env (confined) (pull_request_target) Successful in 43s
tests / confined (remote) (pull_request_target) Successful in 2m9s
tests / confined (project) (pull_request_target) Successful in 2m30s
tests / confined (audit) (pull_request_target) Successful in 3m0s
tests / confined (static) (pull_request_target) Successful in 2m36s
tests / privileged (pull_request_target) Successful in 3m27s
tests / confined (config) (pull_request_target) Successful in 3m44s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
Fixes #303.

install.sh symlinked dejima's hooks into ~/.claude/hooks and registered
them in ~/.claude/settings.json. Five of six no-op outside a sandbox, so
nothing misbehaved - but dejima was editing a config that is not its own
to make its own sandbox work, and "harmless because six gates say so" is
a worse claim than not doing it.

Claude Code merges the --settings overlay with the operator's settings
rather than replacing it, which is what makes the alternative possible.
The codebase already relied on that: harness_register_notify has been
writing a Stop hook into the overlay all along.

So sandbox-context.sh, git-push-guard.sh, merge-guard.sh and
dejima-notify.sh are baked into the image at /usr/local/lib/dejima/hooks
and registered per launch into the overlay by absolute path. Baked rather
than staged under $HOME: a session owns its home directory, and a guard
whose script it can unlink is not one. That is the property the read-only
~/.claude/hooks mount used to provide.

deny-dangerous-bash.sh stays where it was. It is the one hook meant to
apply to the operator's own sessions, and it already yields to an
existing install.

An upgrade takes back what an older install.sh wrote - the symlinks and
the registrations - matched by name and only when the symlink points into
this repo, so an operator's own file at one of those names is left alone.
Without that, the claim would hold only for a fresh install.

The operator's own ~/.claude/hooks is still mounted read-only, so their
hooks reach a session exactly as before.
2026-09-02 02:59:28 -07:00
branderson dda28a3864 Merge pull request 'Bring Cline's host-side user config in, the way Claude Code's already is' (#300) from cline-configs into development
Reviewed-on: #300
Reviewed-by: branderson <brad@branderson.io>
2026-09-02 02:04:32 -07:00
branderson 4202e1fea1 Say that a Cline session cannot be resumed outside dejima
tests / confined (conversation) (pull_request_target) Successful in 51s
tests / confined (proxy) (pull_request_target) Successful in 56s
tests / confined (share) (pull_request_target) Successful in 1m28s
tests / confined (providers) (pull_request_target) Successful in 1m37s
tests / confined (shellcheck) (pull_request_target) Successful in 1m4s
tests / confined (audit) (pull_request_target) Successful in 2m52s
tests / confined (static) (pull_request_target) Successful in 2m31s
tests / privileged (pull_request_target) Successful in 3m23s
tests / runner-env (confined) (pull_request_target) Successful in 40s
tests / confined (remote) (pull_request_target) Successful in 2m3s
tests / confined (project) (pull_request_target) Successful in 2m22s
tests / confined (config) (pull_request_target) Successful in 3m35s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
Session persistence per project already works: this project's Cline state
lives on the host in a directory of its own and survives the container, so
launching dejima again picks up where the last session left off. Importing
or exporting sessions across that boundary was assessed and is not being
built (#301), so the behaviour is documented instead.

The briefing was telling agents the opposite. Core's closing carried "They
can continue this exact session outside the container by running on the
host:", which is Claude Code's fact stated as everyone's - and Cline's
fragment answered it with a CLINE_DATA_DIR incantation for driving a
host-side Cline against a directory that is dejima's rather than Cline's
own. A Cline agent reading that would send its operator somewhere not
supported.

The sentence moves into the harness fragments, where the fact belongs and
where the comment above the assembly already said it belonged. Claude Code
keeps the offer and the command. Cline says it cannot be continued outside
dejima, tells the agent not to offer it, and says what continuing does
look like - launching dejima again - because an agent told nothing will
invent something.

--harness's briefing test keyed on CLINE_DATA_DIR to tell the two apart.
It keys on what each says about resuming now, in both directions, so a
briefing carrying both would fail either way rather than passing one.

harness_state_dir is now read by no shipped fragment. Left in place: it is
documented briefing vocabulary rather than a dead manifest key, and
removing it would take cline.sh's harness_briefing_state_dir with it.
Worth a decision rather than a silent deletion.
2026-09-02 01:07:18 -07:00
branderson 785c9d34d7 Stage global rules into the directory cline actually reads
tests / confined (conversation) (pull_request_target) Successful in 1m23s
tests / confined (proxy) (pull_request_target) Successful in 1m27s
tests / confined (share) (pull_request_target) Successful in 1m58s
tests / confined (providers) (pull_request_target) Successful in 2m13s
tests / confined (shellcheck) (pull_request_target) Successful in 1m11s
tests / runner-env (confined) (pull_request_target) Successful in 41s
tests / confined (remote) (pull_request_target) Successful in 2m38s
tests / confined (project) (pull_request_target) Successful in 3m0s
tests / privileged (pull_request_target) Successful in 3m23s
tests / confined (audit) (pull_request_target) Successful in 3m32s
tests / runner-env (privileged) (pull_request_target) Successful in 35s
tests / confined (static) (pull_request_target) Successful in 2m35s
tests / confined (config) (pull_request_target) Successful in 4m13s
cline loads global rules from Documents/Cline/Rules and nowhere else.
~/.agents/rules reads like the obvious companion to ~/.agents/skills and
is not loaded - planting a file in each and asking `cline config` which
it had returned only the Documents one, while Skills(1) in the same run
confirmed ~/.agents/skills IS read.

So ~/.agents/rules is supported by staging its files INTO the directory
that is read, rather than by mounting it where nothing would look at it.
Host Documents/Cline/Rules is staged the same way. Dotfiles stay the
place an operator keeps rules; the Documents path is where they have to
arrive.

File by file rather than as a directory mount, because dejima writes its
own briefing into that directory and a read-only mount over it would
leave nowhere to put the briefing.

Two collisions have fixed answers. A name in both host locations is taken
from Documents/Cline/Rules: that is cline's own location for rules and
where it writes when they are edited through cline, and staging both
would be two bind mounts at one container path. A host file named
00-dejima.md is skipped - the briefing's name is read from the manifest
rather than repeated here, and entrypoint.sh already writes the briefing
after the staged files, so staging one it overwrites is only noise.

Only .md and .txt, which is what cline reads out of a rules directory.
2026-09-01 23:59:51 -07:00
branderson 585d1c3208 Bring Cline's host-side user config in, the way Claude Code's already is
tests / confined (conversation) (pull_request_target) Successful in 1m12s
tests / confined (proxy) (pull_request_target) Successful in 1m20s
tests / confined (share) (pull_request_target) Successful in 2m2s
tests / confined (providers) (pull_request_target) Successful in 2m19s
tests / confined (shellcheck) (pull_request_target) Successful in 1m14s
tests / runner-env (confined) (pull_request_target) Successful in 50s
tests / confined (remote) (pull_request_target) Successful in 2m50s
tests / privileged (pull_request_target) Successful in 3m13s
tests / confined (project) (pull_request_target) Successful in 3m17s
tests / runner-env (privileged) (pull_request_target) Successful in 35s
tests / confined (config) (pull_request_target) Successful in 4m42s
tests / confined (audit) (pull_request_target) Successful in 3m51s
tests / confined (static) (pull_request_target) Successful in 2m53s
Cline declared no config directories at all, so none of the operator's own
configuration reached a session: no skills, no hooks, no AGENTS.md. The
mechanisms this needs already existed for claude-code, so this is three
hook implementations rather than new machinery.

Read-only mounts for ~/.agents/skills, ~/.agents/hooks and
~/.cline/hooks. Read-only is the point rather than caution: everything
there is either an instruction the agent follows or a program cline runs,
and a session that could rewrite them would be choosing what happens on
the HOST the next time cline starts outside a container - persistence for
a prompt injection, in files nobody re-reads before the next run. Scanned
for symlink targets, because on a Linux host these are routinely links
into a dotfiles checkout.

~/.agents/AGENTS.md is staged: a root-owned read-only copy. It is
instructions the agent follows, so writing the host's would choose what
the next session is told - and staging also survives it being a symlink,
which a file mount does not.

~/.cline/settings.json and cline_mcp_settings.json are seeded writable
and discarded with the container. cline rewrites both as it runs, so a
read-only copy would fail the write rather than the persistence - and the
persistence is the part that matters, because cline_mcp_settings.json
names MCP servers, which are commands cline executes.

~/.agents/rules is deliberately absent. It reads like the obvious
companion to ~/.agents/skills and cline does not load it: planting a file
there and one in ~/Documents/Cline/Rules and asking `cline config` which
it had returned only the second. Mounting a directory the harness never
reads would be a promise dejima cannot keep, so the test asserts its
absence.

Also raises the pty fixture's idle bound. Sixty seconds was enough on an
idle box and not enough under the full gate, where it killed a child that
was merely slow and reported it as a hang - the same shape as #295.
2026-09-01 23:19:44 -07:00
branderson cb31e463f9 Merge pull request 'Say where a moved key went, and stop appending a section that already exists' (#299) from startup-ux into development
Reviewed-on: #299
Reviewed-by: branderson <brad@branderson.io>
2026-09-01 20:25:28 -07:00
branderson 3e995a477a Add --no-pause, and stop offering a flag that no longer exists
tests / confined (audit) (pull_request_target) Successful in 3m7s
tests / confined (config) (pull_request_target) Successful in 3m50s
tests / confined (conversation) (pull_request_target) Successful in 51s
tests / confined (project) (pull_request_target) Successful in 2m35s
tests / confined (providers) (pull_request_target) Successful in 1m47s
tests / confined (remote) (pull_request_target) Successful in 2m14s
tests / confined (share) (pull_request_target) Successful in 1m33s
tests / confined (static) (pull_request_target) Successful in 2m49s
tests / confined (shellcheck) (pull_request_target) Successful in 1m8s
tests / privileged (pull_request_target) Successful in 3m22s
tests / runner-env (confined) (pull_request_target) Successful in 45s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (proxy) (pull_request_target) Successful in 56s
Starting several sessions from a tmuxinator layout gives every one of
them a terminal, so the pause fires on every one, and the operator wants
them all at a prompt rather than eight keys to press. --no-pause skips
the wait and still prints the notice: skipping the wait is not skipping
the telling.

A flag rather than a config key, deliberately. It describes the terminal
in front of one launch, not a standing preference - someone who wants
eight sessions to start unattended today has said nothing about the
launch they run by hand tomorrow.

Making room for it in --help found a real bug. --base-url was listed
there and had not been parsed since the provider axis moved the model's
address onto the backend and deleted the flag. Passing it answered
"unknown option", which is the worst kind of documentation: it reads as a
bug in dejima rather than in the text. Removing it also frees the line
the help page's own budget needed, which is how the constraint earned its
keep.

So a test now cross-checks the two: every flag --help offers must be one
bin/dejima parses. It has to match a flag anywhere in a case pattern,
because --ro is the second alternative of `--extra-readonly|--ro)`.

docs/configuration.md gains a short section on what dejima says before it
starts - the pause, when it fires, and that the shared-directory list is
sampled - since none of that was written down anywhere.
2026-09-01 19:46:14 -07:00
branderson ef6939f5d3 Answer the new pause where a test drives a terminal, and never hang on one
tests / confined (conversation) (pull_request_target) Successful in 56s
tests / confined (proxy) (pull_request_target) Successful in 54s
tests / confined (providers) (pull_request_target) Successful in 1m40s
tests / confined (remote) (pull_request_target) Successful in 2m8s
tests / confined (share) (pull_request_target) Successful in 1m33s
tests / confined (shellcheck) (pull_request_target) Successful in 1m8s
tests / confined (audit) (pull_request_target) Successful in 3m9s
tests / confined (static) (pull_request_target) Successful in 2m42s
tests / runner-env (privileged) (pull_request_target) Successful in 40s
tests / confined (project) (pull_request_target) Successful in 2m37s
tests / runner-env (confined) (pull_request_target) Successful in 41s
tests / privileged (pull_request_target) Successful in 3m43s
tests / confined (config) (pull_request_target) Successful in 3m56s
Adding a prompt before the build changed what every pty-driven launch
test is typing into. Two of them were driving a different prompt and had
their first keystroke eaten by this one.

test/fixtures/answer-prompt.py answers the pause automatically. It is
ambient - every launch fixture sets [core] ssh_key, which is a
deprecation, so it fires on all of them - and it is not the prompt any of
those tests is about. static.bats's tty helper does the same with a
leading newline, in the helper rather than in six callers, so each
caller's keys still mean what they say.

And the fixture no longer hangs. It waited on the child unconditionally,
so an unanswered prompt - the likeliest way for it to end badly - blocked
waitpid forever and took the whole suite with it, silently. It kills the
child and says which it was. That is what actually happened here: the
gate ran for twenty minutes on one stuck test before I looked.
2026-09-01 19:21:07 -07:00
branderson 72599e5c2d Give the startup notices somewhere to be read, and report a bad config once
Reported from a real launch, where the notices were sandwiched between a
ninety-entry file list and the first thousand lines of a Docker build.

The orphaned-share list is a sample now. It is a standing condition an
operator clears once, and printing all of it on every launch pushed
everything about THIS run off the top of the screen. Eight entries and a
count of the rest.

And a notice earns a pause. Every line after it is the image build, so a
notice that lands there scrolls away before it can be read - the same
reason the exit audit prints its findings last. It fires only when
something was said, and only with a terminal attached: an unattended
launch must not block on a keystroke nobody will press. The share list
deliberately does not raise it, because pausing every launch until a
standing condition is cleared teaches the operator to press Enter without
looking, which is the failure the pause exists to avoid.

Separately, and pre-existing: an invalid config reported its reason once
per PARSING CONTEXT. The early validation runs suppressed and, on failure,
dropped the cache so the next caller would parse and report - but the next
caller is usually a subshell, whose cache dies with it. One duplicated
line was merely odd; with a hint under it the same message printed twenty
lines. Re-parsed in this shell instead, so the reason is printed once and
the failed status is cached where subshells inherit it. Deliberately not
an exit: flags are not parsed at that point, and someone with a broken
config is exactly who needs --help.

The pause has a positive test as well as the unattended one. Without it
the guard test passes just as well when the feature does not exist, which
is not hypothetical: an accidental `git checkout` wiped both this and the
share sampling mid-session, and only the sampling test noticed.
2026-09-01 18:37:40 -07:00
branderson 63e79d81cc Say where a moved key went, write into the section that exists, and
give the startup notices room

Three things reported from a real launch on a fresh container.

A key dejima itself moved reported only "unknown key". base_url left the
harness for the backend in the provider axis, and an operator who had it
got a message that is true and useless: it does not distinguish a typo
from a move, and the answer is in a document they have no reason to think
they need. Unknown keys dejima once accepted now say where they went, and
show the two sections to write. Kept deliberately to keys dejima moved -
a key nobody ever accepted is a typo, and guessing at what it meant is
worse than saying nothing.

The assumed_backend migration appended its own [harness.<name>] section
even when the operator already had one, leaving the same header twice. It
parses, and it reads as a mistake dejima made in someone else's file. It
writes into the existing section now, and appends only when there is
none. Inserting right after the header is safe here specifically because
the key is UNSET: there is no later assignment for a line near the top to
lose to, which is what makes editing a config file dangerous in general.
The file keeps its own permissions rather than a fresh temp file's.

And the startup notices ran together as one block: an environment
deprecation, an identity deprecation, dejima editing your config, and the
guards that are not registered, with nothing between them. Each notice
owns the blank line above it now - never below, so two firing on the same
launch cannot double up - and the migration says what it wrote with the
line picked out, because that is the one notice an operator may want to
go and look at.

The migration test pinned the old wording; it asserts the announcement
and the written line instead, and now also that exactly one section
exists, which is the reported bug.
2026-09-01 18:37:40 -07:00
branderson 2c14b10888 Merge pull request 'Stop documenting a manifest key this release deleted' (#298) from release-docs-drift into development
Reviewed-on: #298
Reviewed-by: branderson <brad@branderson.io>
2026-09-01 18:20:02 -07:00
branderson 4706a27ab2 Merge pull request 'Report the config keys a provider declares, and the backends axis' (#297) from release-pass-keys into development
Reviewed-on: #297
Reviewed-by: branderson <brad@branderson.io>
2026-09-01 18:19:36 -07:00
branderson 8350f68071 Stop documenting a manifest key this release deleted
tests / confined (conversation) (pull_request_target) Successful in 52s
tests / confined (proxy) (pull_request_target) Successful in 56s
tests / confined (share) (pull_request_target) Successful in 1m27s
tests / confined (providers) (pull_request_target) Successful in 1m40s
tests / confined (shellcheck) (pull_request_target) Successful in 1m6s
tests / runner-env (confined) (pull_request_target) Successful in 44s
tests / confined (remote) (pull_request_target) Successful in 2m9s
tests / confined (project) (pull_request_target) Successful in 2m29s
tests / confined (audit) (pull_request_target) Successful in 2m59s
tests / confined (static) (pull_request_target) Successful in 2m35s
tests / privileged (pull_request_target) Successful in 3m17s
tests / confined (config) (pull_request_target) Successful in 3m41s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
Found by the release pass's documentation review, which asks whether any
document describes something that no longer exists.

proxied_api_domain was a HARNESS manifest key. The provider axis moved it
to the backend as proxied_domain, because a harness reaches whichever
endpoint it is configured for - leaving the name on the harness would
have the terminator answering for one API while the session talked to
another. The key is gone from the schema and from both harness manifests,
and docs/providers.md still had a row describing it, and
docs/development.md named it twice as something to find in
harnesses/<name>/harness.conf.

A reader following either would go looking for a key that cannot be set,
and conclude the feature was missing rather than moved.

Two smaller leftovers from the same rename: a refusal's comment cited
anthropic-console, which the two-backend consolidation removed, and a
static test was named for the provider's declared domain when the axis it
asserts on is the backend's.
2026-09-01 03:07:16 -07:00
branderson 705809116a Report the config keys a provider declares, and the backends axis
tests / confined (conversation) (pull_request_target) Successful in 54s
tests / confined (proxy) (pull_request_target) Successful in 56s
tests / confined (share) (pull_request_target) Successful in 1m27s
tests / confined (providers) (pull_request_target) Successful in 1m41s
tests / confined (shellcheck) (pull_request_target) Successful in 1m5s
tests / runner-env (confined) (pull_request_target) Successful in 42s
tests / confined (remote) (pull_request_target) Successful in 2m6s
tests / confined (project) (pull_request_target) Successful in 2m28s
tests / confined (audit) (pull_request_target) Successful in 2m56s
tests / privileged (pull_request_target) Successful in 3m16s
tests / confined (static) (pull_request_target) Successful in 2m33s
tests / confined (config) (pull_request_target) Successful in 3m42s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
Found by running the release pass over the provider-axis release: it
listed seven keys a rollback would refuse, and the real number is
twenty-two.

Two blind spots, and both matter most for the newest work.

The axis loop was harnesses, forges, workflows. `backends` was added by
the release being checked, so the one axis whose sections are new was the
one nothing looked at - which is the general shape of a hand-maintained
list: it is updated by whoever remembers.

And a provider's own `keys` were not diffed at all. Those are config keys
an operator writes and an older dejima refuses, but they live in a
manifest rather than in core's schema, so the schema diff cannot see
them. `[harness.claude-code] backend` is one: adopting it is exactly what
makes the rollback refuse, and nothing said so.

The point of that section is that the rollback which looks like a safety
net is not one. A list that understates it by two thirds is worse than no
list, because it is read as complete.
2026-09-01 03:00:17 -07:00
branderson ff90448c79 Merge pull request 'Budget the gate's concurrency, and stop timing the machine in three tests' (#296) from flaky-tests into development
Reviewed-on: #296
Reviewed-by: branderson <brad@branderson.io>
2026-09-01 02:38:45 -07:00
branderson 17a51bd1c3 Budget the gate's concurrency, and stop timing the machine in three tests
tests / confined (config) (pull_request_target) Successful in 3m46s
tests / confined (conversation) (pull_request_target) Successful in 50s
tests / confined (proxy) (pull_request_target) Successful in 55s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (providers) (pull_request_target) Successful in 1m41s
tests / confined (shellcheck) (pull_request_target) Successful in 1m6s
tests / runner-env (confined) (pull_request_target) Successful in 42s
tests / confined (project) (pull_request_target) Successful in 2m31s
tests / privileged (pull_request_target) Successful in 3m20s
tests / confined (remote) (pull_request_target) Successful in 2m7s
tests / confined (audit) (pull_request_target) Successful in 3m3s
tests / confined (static) (pull_request_target) Successful in 2m34s
tests / confined (share) (pull_request_target) Successful in 1m29s
Fixes #295.

test/run-confined.sh runs every selected suite at once, each asking bats
for `nproc` tests in parallel. What lands on the machine is the product:
nine suites times ten cores is ninety concurrent tests on ten cores. The
oversubscription was deliberate and helps - these tests fork more than
they compute - but it is a property of the whole run rather than of each
suite. It is budgeted once now, at roughly twice the cores, and divided
across the selection. The announcement prints both numbers.

That is most of the fix, because the failures were all the same shape: a
test waits on a bounded deadline - a pty reaching raw mode, a shipper
polling, an audit finishing - and a saturated machine blows through it.
None of them was failing for the thing it checks.

Two tests were also wrong on their own merits.

audit's "rotation gives up instead of spinning forever" asserted a
LIVENESS property with a five-second latency bound, so a correct run on a
loaded machine reported a spin that had not happened. The subject is
forever, not fast, and any finite bound catches forever - so the bound is
generous now, and it additionally asserts what giving up looks like: an
unwritable directory means every planted report is still there. Without
that it said only that the command exited, which a rotation silently
doing nothing also satisfies.

conversation's #284 regression test read the collector's log the instant
the shipper exited. `wait` returns when the SHIPPER is gone, which says
nothing about the other end having written what it was sent, so a loaded
machine lost that race - and it failed as "the tail was lost", which is
this test's bug signature exactly. It waits for the line now, the same
way it already waits for the first one.

Both fixes are mutation-checked and neither weakens what it tests:
removing rotation's `|| break` still fails on the bound, and removing the
#284 warm-up still reports the tail lost.

Before: three gate runs on development, three failures, five distinct
tests between them. After: three consecutive clean runs.
2026-09-01 02:30:06 -07:00
branderson 7ebae027fa Merge pull request 'Declare the model provider in a manifest, not in core' (#280) from provider-axis into development
Reviewed-on: #280
Reviewed-by: branderson <brad@branderson.io>
2026-09-01 01:45:11 -07:00
branderson c2ade350ed Merge pull request 'Keep both Anthropic API-key logins on the host, and say which backend fits' (#288) from anthropic-api-key into provider-axis
tests / confined (conversation) (pull_request_target) Successful in 1m20s
tests / confined (share) (pull_request_target) Successful in 1m52s
tests / confined (proxy) (pull_request_target) Successful in 1m20s
tests / confined (providers) (pull_request_target) Successful in 2m6s
tests / confined (shellcheck) (pull_request_target) Successful in 1m7s
tests / runner-env (confined) (pull_request_target) Successful in 41s
tests / runner-env (privileged) (pull_request_target) Successful in 36s
tests / confined (remote) (pull_request_target) Successful in 2m10s
tests / confined (project) (pull_request_target) Successful in 2m52s
tests / confined (audit) (pull_request_target) Successful in 3m23s
tests / privileged (pull_request_target) Successful in 3m27s
tests / confined (static) (pull_request_target) Successful in 2m30s
tests / confined (config) (pull_request_target) Successful in 4m8s
Reviewed-on: #288
Reviewed-by: branderson <brad@branderson.io>
2026-09-01 00:27:17 -07:00
branderson e54db2b299 Merge pull request 'Declare GNU parallel, so a sandbox can run the suites the way CI does' (#294) from parallel-dep into development
Reviewed-on: #294
Reviewed-by: branderson <brad@branderson.io>
2026-08-31 23:15:17 -07:00
branderson 7b3809e395 Declare GNU parallel, so a sandbox can run the suites the way CI does
tests / confined (proxy) (pull_request_target) Successful in 55s
tests / runner-env (confined) (pull_request_target) Successful in 42s
tests / confined (audit) (pull_request_target) Successful in 2m48s
tests / confined (static) (pull_request_target) Successful in 2m27s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (share) (pull_request_target) Successful in 1m16s
tests / confined (providers) (pull_request_target) Successful in 1m31s
tests / confined (shellcheck) (pull_request_target) Successful in 1m5s
tests / confined (remote) (pull_request_target) Successful in 1m53s
tests / confined (project) (pull_request_target) Successful in 2m8s
tests / confined (config) (pull_request_target) Successful in 2m55s
tests / privileged (pull_request_target) Successful in 3m20s
tests / confined (conversation) (pull_request_target) Successful in 51s
CI runs every suite with `--jobs $(nproc) --no-parallelize-across-files`,
and `bats --jobs` needs GNU parallel to do it. Without it a sandbox can
run the suites only serially, which is not how they are run - and #284
was a race that only appeared under `--jobs`, so serial-only is a lane
that cannot see a whole class of bug.

Declared rather than added to the base image, for the same reason bats
is: a project that never runs bats needs neither.

The failure mode is why this is worth a comment rather than a bare line.
bats exits with "Cannot execute N jobs without GNU parallel" and prints
no test output at all, so a suite that never ran emits no `not ok` line -
and the obvious `| grep '^not ok'` check reports it as passing. That
happened during the #288 work: a suite was reported green having not
executed. .dejima.md now says to require both a zero exit status and a
non-zero count of `ok` lines, with the snippet.

Debian's `parallel` is GNU parallel; moreutils ships a different binary
of the same name and conflicts with it. The image installs no moreutils,
so there is nothing to resolve, but the next person to look at this
should not have to work that out.
2026-08-31 22:54:28 -07:00
branderson 4afd0c62b3 Merge pull request 'Ask about credential-shaped values instead of assuming they are safe' (#293) from credential-scan into anthropic-api-key
tests / confined (conversation) (pull_request_target) Successful in 49s
tests / confined (proxy) (pull_request_target) Successful in 55s
tests / confined (share) (pull_request_target) Successful in 1m29s
tests / confined (providers) (pull_request_target) Successful in 1m41s
tests / confined (remote) (pull_request_target) Successful in 2m8s
tests / confined (shellcheck) (pull_request_target) Successful in 1m5s
tests / runner-env (confined) (pull_request_target) Successful in 42s
tests / confined (project) (pull_request_target) Successful in 2m30s
tests / confined (static) (pull_request_target) Successful in 2m35s
tests / privileged (pull_request_target) Successful in 3m19s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (audit) (pull_request_target) Successful in 2m59s
tests / confined (config) (pull_request_target) Successful in 3m43s
Reviewed-on: #293
Reviewed-by: branderson <brad@branderson.io>
2026-08-31 22:49:18 -07:00
branderson d57d38566f State what the credential scan does not catch
tests / confined (proxy) (pull_request_target) Successful in 57s
tests / confined (conversation) (pull_request_target) Successful in 48s
tests / confined (share) (pull_request_target) Successful in 1m29s
tests / confined (providers) (pull_request_target) Successful in 1m40s
tests / confined (remote) (pull_request_target) Successful in 2m9s
tests / confined (shellcheck) (pull_request_target) Successful in 1m7s
tests / runner-env (confined) (pull_request_target) Successful in 42s
tests / confined (project) (pull_request_target) Successful in 2m31s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (audit) (pull_request_target) Successful in 3m4s
tests / confined (static) (pull_request_target) Successful in 2m37s
tests / privileged (pull_request_target) Successful in 3m21s
tests / confined (config) (pull_request_target) Successful in 3m47s
A best-effort control that reads as a guarantee is worse than none: an
operator who believes nothing can cross stops looking.

docs/security.md gains the limits, four of them, each a hole someone
could otherwise walk into.

Whitespace is the one worth stating plainly, because it is a trade rather
than an oversight. Both heuristic rules require one token from a
restricted alphabet, since the alternative flags every sentence in a
config file - English prose scores 4.0-4.5 bits per character by the same
measure a random key does. People do put spaces in passphrases they chose
themselves, and one of those goes straight through. It fits these files,
which hold credentials issued to a machine rather than typed by a person,
and it would not fit a file that held the other kind.

Also stated: short or low-entropy secrets in fields not named like one;
formats it cannot parse, which are copied unchecked and say so; and that
it only covers files dejima stages, not read-write mounts or the
workspace.

A static test pins all four, because they are exactly the sentences a
later compression pass reads as hedging and cuts.
2026-08-31 22:16:03 -07:00
branderson bb33c62721 Stop flagging prose and account fields as credentials
Reported from a real launch: cachedGrowthBookFeatures.tengu_flint_harbor
_prompt.prompt, a cached feature-flag payload, offered as a credential.

Measured rather than guessed at afterwards. Against this machine's real
~/.claude.json the scanner produced 22 findings and not one was a
credential. Two independent faults, both mine.

Entropy alone does not separate secrets from text. English prose scores
4.0-4.5 bits per character by character-frequency Shannon, comfortably
over the 3.5 threshold, so every sentence in a config file was a finding.
What actually separates them is shape: a secret is one token from a
restricted alphabet with no whitespace in it. Both value rules now
require that first, and the entropy bar moved to 4.0.

And matching credential words as substrings put `auth` inside
`oauthAccount`, so every field of the signed-in account's description was
flagged, along with a feature flag named tengu_mcp_local_oauth_blocked
_hosts. Names are split into words - camelCase, snake_case and
kebab-case alike - and matched whole. Scope is the leaf and its immediate
parent: the parent is what makes `credential.helper` a credential, and
anything further up is how a subtree named after an oauth feature flag
became a subtree of credentials.

Same real file now produces zero findings, and a full launch against it
asks nothing. The canaries still land: a bare API key, a JWT, a hex
session secret, a base64 blob, a github token, and both gitconfig shapes.
Prose, slugs, paths and model names stay quiet.

Both halves are mutation-checked: removing the charset gate or restoring
substring matching fails the new test.
2026-08-31 22:16:03 -07:00
branderson 041e38bf4f Make the prompt readable, and scrubbing a one-launch answer
Review feedback, all of it right.

The report pushed five short labels out of line to accommodate "looks
like". Every label is padded to that width now, so the values line up.

"Scrub it from the copy" described dejima's implementation rather than
what the operator gets. It says "keep it out of the container this
launch" now, and the four answers read in parallel.

And scrubbing is one launch only. Removing a field the harness turns out
to need breaks every launch after, with the cause several layers from the
symptom, so the safe answer must not also be the sticky one. A standing
scrub is still honoured from the decisions file, where writing it is a
deliberate act - and it is the only way a scrubbed field survives an
unattended launch, so the non-interactive message still offers it, with
what it costs.

The best-effort qualifier was on the wrong half. FINDING a credential is
best effort: this recognises common shapes and can miss an unusual one.
REMOVING one it has found was not reliable, and now is - walk_ini read
line by line, so a value continued across lines parsed as a key that does
not exist (`password=...` under `helper = !echo \`), and removing one
line of it left the rest behind with a dangling backslash that changes
what git parses next. Both reading and removal work on whole logical
lines now.

Two test fixes: the pty writes CRLF, so an id lifted from that output
carried a \r into a tab-separated line that then silently failed to
match; and the persistence test asserted scrub sticks, which is exactly
what stopped being true.
2026-08-31 22:16:02 -07:00
branderson 0634730d60 Ask about credential-shaped values instead of assuming they are safe
Fixes #291.

The two leaks found by hand were both a host file copied wholesale
holding a credential nobody had classified as one, and both fixes are
denylists: they encode last week's knowledge. A harness's authentication
is not a stable interface, so the next field added leaks silently unless
something is looking.

bin/dejima-credential-scan classifies values in the files dejima stages -
vendor prefixes, credential-shaped names, high entropy - and reports
findings. It decides nothing, which is what lets the same classifier run
in a test with no terminal.

A value never leaves it, and neither does a real path. In a gitconfig the
userinfo sits in the section header, so `url.https://me:PAT@github.com/`
is a path that IS a credential. Findings carry a redacted path for a
human and an opaque id for matching, and --scrub takes ids back.

bin/dejima asks, with the four answers the operator asked for: scrub it
from the copy every time, allow it every time, allow it once, or refuse.
Answers are keyed on the field rather than the value, so a rotated
credential does not re-ask. With nothing attached to answer, the launch
stops rather than carrying it in - and says how to record a decision
without a terminal.

The report names file, field, shape, harness, backend and id: enough to
send to whoever ships the harness, and useless to anyone who intercepts
it.

Two bugs of mine worth recording. The prompt sat inside a `while ... done
<<< "$findings"` loop, which redirects stdin - so `[ -t 0 ]` was false
however the launch started and it would never have asked. Interactivity
is decided before the loop now. And the judged-safe list matched paths
exactly, so `customApiKeyResponses.approved` did not cover
`...approved[0]`; it covers a subtree now, while decisions stay exact -
broadening those would let one answer cover fields nobody saw.
2026-08-31 22:16:02 -07:00
branderson dab367231c Two Anthropic backends, not three
tests / confined (conversation) (pull_request_target) Successful in 49s
tests / confined (proxy) (pull_request_target) Successful in 56s
tests / confined (providers) (pull_request_target) Successful in 1m45s
tests / confined (audit) (pull_request_target) Successful in 3m5s
tests / confined (share) (pull_request_target) Successful in 1m28s
tests / confined (shellcheck) (pull_request_target) Successful in 1m7s
tests / runner-env (confined) (pull_request_target) Successful in 44s
tests / confined (remote) (pull_request_target) Successful in 2m9s
tests / confined (project) (pull_request_target) Successful in 2m30s
tests / runner-env (privileged) (pull_request_target) Successful in 40s
tests / confined (static) (pull_request_target) Successful in 2m38s
tests / privileged (pull_request_target) Successful in 3m22s
tests / confined (config) (pull_request_target) Successful in 3m36s
anthropic-api-key and anthropic-console differed in exactly two lines:
credential_field, and the credential_file default. They were never two
backends - they were one arrangement with the key in a different place,
and "anthropic-console" named a storage location rather than anything an
operator chooses.

The seam that does exist is the wire arrangement. A subscription login is
a Bearer token that expires and is refreshed, with an anthropic-beta
header and x-api-key stripped; an API key is x-api-key, bare, with
Authorization stripped. That is a different backend. Where the key sits
is configuration.

So credential_field moves off the manifest and into the backend's own
keys, defaulted to primaryApiKey beside a credential_file defaulted to
~/.claude.json. Claude Code's console login then needs nothing
configured, and a key held in a file of your own is the same backend with
both cleared - an empty credential_field means the whole file is the key.

The jsonfield type moves to dejima-config-lib.sh, since a keyspec default
is validated by the config file's own validator. The manifest validator
already falls through to it, so this is one definition rather than two.

The two backends now map onto the two things Anthropic sells, subscription
billing and API billing, and a federated login (#290) would be a third
mechanism rather than a fourth location.

The three endpoint values are still duplicated across the two manifests,
which is what a test now holds: upstream, proxied_domain and api_style
describe Anthropic's API rather than any credential, and must not drift.
Cheaper to assert than to deduplicate.
2026-08-31 22:15:53 -07:00
branderson b2c1a0ea89 Keep credentials out of the gitconfig copy, and explain a federated host
tests / confined (conversation) (pull_request_target) Successful in 52s
tests / confined (proxy) (pull_request_target) Successful in 55s
tests / confined (share) (pull_request_target) Successful in 1m25s
tests / confined (shellcheck) (pull_request_target) Successful in 1m3s
tests / confined (project) (pull_request_target) Successful in 2m22s
tests / confined (audit) (pull_request_target) Successful in 2m53s
tests / confined (static) (pull_request_target) Successful in 2m28s
tests / confined (providers) (pull_request_target) Successful in 1m40s
tests / runner-env (confined) (pull_request_target) Successful in 40s
tests / confined (remote) (pull_request_target) Successful in 2m3s
tests / privileged (pull_request_target) Successful in 3m22s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (config) (pull_request_target) Successful in 3m20s
Two things found by asking what else looks like the ~/.claude.json leak.

~/.gitconfig is the other host file copied wholesale into a container,
and it can hold credentials in three ordinary shapes: a credential.helper
that prints a token, an http.<url>.extraHeader carrying an Authorization
line, and userinfo embedded in a url.<...>.insteadOf key, where the
secret is the key name. None of them is part of what the sandbox is
granted - the session gets the ssh key and forge token dejima manages,
and gh auth setup-git writes its own helper inside the container. The
copy drops them by name and says so; the operator's own file is untouched
and a rewrite with no credential in it survives.

And Claude Code can sign in with no stored credential at all, through
workload identity federation: ANTHROPIC_FEDERATION_RULE_ID and an OIDC
identity token, exchanged for an access token by Claude Code itself. No
backend serves it (#290). On such a host every backend finds nothing, so
"no backend fits" is true and useless - it reads as dejima failing to see
a login that plainly works. Detected and named instead. Guidance only; no
credential handling changes.

The general problem is that both of this week's fixes are denylists, and
a harness's authentication is not a stable interface. Filed as #291.

Refs #171, #289, #290, #291.
2026-08-31 19:21:32 -07:00
branderson ddf0d61efc Name the backend that fits, instead of leaving the operator to guess
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (conversation) (pull_request_target) Successful in 49s
tests / confined (proxy) (pull_request_target) Successful in 57s
tests / confined (share) (pull_request_target) Successful in 1m25s
tests / confined (providers) (pull_request_target) Successful in 1m41s
tests / confined (shellcheck) (pull_request_target) Successful in 1m3s
tests / confined (static) (pull_request_target) Successful in 2m32s
tests / privileged (pull_request_target) Successful in 3m20s
tests / confined (config) (pull_request_target) Successful in 3m24s
tests / runner-env (confined) (pull_request_target) Successful in 40s
tests / confined (remote) (pull_request_target) Successful in 2m4s
tests / confined (project) (pull_request_target) Successful in 2m25s
tests / confined (audit) (pull_request_target) Successful in 2m55s
Reported from a real setup: an operator with a Claude Code console login
tried backend = anthropic, then anthropic-api-key, and neither refusal
mentioned anthropic-console. Three dead ends, and the way out was only
in the docs.

Every refusal now checks which shipped backends could serve a credential
that is actually on this host, and names them with the line to write.
Derived from the manifests and the filesystem rather than listed, so a
backend added later is suggested the day it lands, and filtered by what
the selected harness can speak - naming a backend it has no client for
just trades one refusal for another.

The wrong-shape refusal also puts the fix ahead of the downgrade. It
offered proxy = off first, which is the answer least likely to be right.

And the placeholder key is pre-approved in the staged ~/.claude.json.
Claude Code asks about any ANTHROPIC_API_KEY it did not set itself, and
recommends declining - which on an unattended session leaves it with no
credential. The approval is the last 20 characters of the key, derived
from the value rather than written as a literal. If that record format
changes, the prompt comes back and nothing else: a courtesy, not a
control.

Verified on the reporter's machine before this commit: with
anthropic-console selected, the model answers, billing shows as API
usage, ANTHROPIC_API_KEY holds only the placeholder, and the container's
~/.claude.json has no primaryApiKey.

Refs #171, #289.
2026-08-31 17:43:12 -07:00
branderson bce3daf61e Serve Claude Code's console login, and switch the proxy per backend
tests / confined (conversation) (pull_request_target) Successful in 55s
tests / confined (proxy) (pull_request_target) Successful in 58s
tests / confined (share) (pull_request_target) Successful in 1m27s
tests / confined (shellcheck) (pull_request_target) Successful in 1m2s
tests / runner-env (confined) (pull_request_target) Successful in 42s
tests / confined (project) (pull_request_target) Successful in 2m27s
tests / confined (audit) (pull_request_target) Successful in 2m58s
tests / confined (config) (pull_request_target) Successful in 3m19s
tests / runner-env (privileged) (pull_request_target) Successful in 36s
tests / confined (providers) (pull_request_target) Successful in 1m41s
tests / confined (remote) (pull_request_target) Successful in 2m5s
tests / confined (static) (pull_request_target) Successful in 2m29s
tests / privileged (pull_request_target) Successful in 3m19s
Two coupled pieces, both from a real desktop launch.

A key is not always a whole file. Claude Code signed in with a console
API token keeps its key as a top-level primaryApiKey in ~/.claude.json,
beside unrelated app state, and leaves .credentials.json as `{}`. So the
static credential kind can now name a field: credential_field on the
manifest, --credential-field on the proxy, walked segment by segment in
Python. backends/anthropic-console declares it. anthropic-api-key stays
field-less, for a key held in a file of your own - two arrangements, two
directories, which is what the axis is for.

The gate learned about it too. _credential_usable checks the named field
is actually present, so a ~/.claude.json without one is refused at launch
rather than starting a proxy whose first request fails.

And the proxy switch is per backend. [credentials] proxy had the wrong
blast radius: one backend the proxy cannot serve meant disabling
withholding for every backend, including ones it serves perfectly.
[backend.<name>] proxy now falls back to it. Core appends that key to
every backend section rather than each manifest declaring it - whether to
withhold a credential is dejima's question, not the backend author's. It
ships no default, because unset is a third state that on/off cannot
carry.

The two meet at ~/.claude.json. Its staged copy has the credential
stripped, which is what keeps a console login's key on the host - but
with the proxy deliberately off for that backend, stripping it would
leave the session with no credential and nothing to mount, since the copy
is a throwaway. So the downgrade re-seeds the copy verbatim and says what
it handed over. The safe form is still what every other path gets.

Refs #171, #289.
2026-08-31 17:17:03 -07:00
branderson 3ab5c41068 Strip the console API key from the staged ~/.claude.json
tests / confined (conversation) (pull_request_target) Successful in 56s
tests / confined (proxy) (pull_request_target) Successful in 55s
tests / confined (shellcheck) (pull_request_target) Successful in 1m5s
tests / runner-env (confined) (pull_request_target) Successful in 41s
tests / confined (share) (pull_request_target) Successful in 1m24s
tests / confined (providers) (pull_request_target) Successful in 1m38s
tests / confined (remote) (pull_request_target) Successful in 1m59s
tests / confined (project) (pull_request_target) Successful in 2m19s
tests / confined (audit) (pull_request_target) Successful in 2m52s
tests / confined (config) (pull_request_target) Successful in 3m17s
tests / confined (static) (pull_request_target) Successful in 2m50s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
tests / privileged (pull_request_target) Successful in 3m21s
Found on a real desktop launch, and it explains the whole report.

A Claude Code signed in with a console API token keeps the key as a
top-level primaryApiKey in ~/.claude.json. Its .credentials.json stays
`{}`. dejima stages ~/.claude.json as a throwaway copy so a session gets
the operator's mcpServers and project state - and copied it with cp, so
that key crossed into every container, whatever backend was selected and
whether or not the credential proxy was running.

The proxy withheld one file while dejima copied another. That is also why
the reported session worked with an empty .credentials.json mounted and
an empty ANTHROPIC_API_KEY: it was authenticating from the copy.

Seeded through jq now, dropping primaryApiKey and keeping everything the
copy exists for. customApiKeyResponses stays - it holds hashes of keys
the operator approved, not the keys, and dropping it re-prompts inside
the sandbox for a decision already made on the host.

A ~/.claude.json jq cannot parse is a refusal rather than a fallback to
cp, because falling back restores this exact leak at the moment least
likely to be noticed.

Refs #171, #289.
2026-08-31 16:28:31 -07:00
branderson c306c32457 Refuse a credential the selected backend cannot serve
tests / confined (conversation) (pull_request_target) Successful in 56s
tests / confined (share) (pull_request_target) Successful in 1m24s
tests / confined (shellcheck) (pull_request_target) Successful in 1m4s
tests / runner-env (confined) (pull_request_target) Successful in 43s
tests / confined (remote) (pull_request_target) Successful in 2m2s
tests / confined (audit) (pull_request_target) Successful in 2m55s
tests / confined (static) (pull_request_target) Successful in 2m29s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (proxy) (pull_request_target) Successful in 57s
tests / confined (providers) (pull_request_target) Successful in 1m38s
tests / confined (project) (pull_request_target) Successful in 2m21s
tests / confined (config) (pull_request_target) Successful in 3m15s
tests / privileged (pull_request_target) Successful in 3m21s
Found on a real desktop launch, not by a test. A Claude Code signed in
with a console API token has no claudeAiOauth block, so the anthropic
backend could not serve it - and dejima skipped the credential proxy and
bind-mounted the real credential file into the container, read-write,
saying nothing at all.

The session worked, which is what made it invisible. Both the UI and an
empty ANTHROPIC_API_KEY look identical to a correctly proxied launch; the
only outward sign was the mount.

That fallthrough is the right answer when the operator asked for it with
proxy = off, and the wrong one when they asked for proxy = on. So it is
now a refusal that names the backend, the file, what shape was expected
and the two ways forward. docs/security.md calls the mounted arrangement
a kill switch rather than an option, and a kill switch that can engage
itself is not one.

Scoped to a credential that exists and cannot be served. An absent one
mounts nothing and downgrades nothing - that is someone who has not
logged in on the host yet, and it still launches.

This does not make that setup work; it stops it failing open. The
anthropic-api-key backend added here reads a bare key file, and Claude
Code stores a console token inside .credentials.json instead, so serving
it needs the static kind to be able to name a field. Refs #171.
2026-08-31 15:40:12 -07:00
branderson 2e1b96c104 Keep an Anthropic API key on the host too
tests / confined (conversation) (pull_request_target) Successful in 53s
tests / confined (proxy) (pull_request_target) Successful in 56s
tests / confined (share) (pull_request_target) Successful in 1m25s
tests / confined (shellcheck) (pull_request_target) Successful in 1m7s
tests / confined (remote) (pull_request_target) Successful in 2m5s
tests / confined (project) (pull_request_target) Successful in 2m26s
tests / confined (providers) (pull_request_target) Successful in 1m40s
tests / runner-env (confined) (pull_request_target) Successful in 42s
tests / confined (audit) (pull_request_target) Successful in 3m2s
tests / confined (static) (pull_request_target) Successful in 2m34s
tests / confined (config) (pull_request_target) Successful in 3m23s
tests / privileged (pull_request_target) Successful in 3m34s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
API billing is what most organisations put their people on; a
subscription login is the narrower case. Without a backend for it, an API
key has no claudeAiOauth block to protect, so bin/dejima skips the
credential proxy and mounts the operator's real key into the container -
leaving the arrangement most people use as the one where a long-lived,
account-wide credential sits where a compromised session can read it.

An Anthropic API key is a static credential against the same endpoint,
presented as x-api-key with no scheme prefix. The proxy already injects
whatever header a backend names in whatever shape it names, so this needs
no new machinery, only somewhere to say it. strip_headers = authorization
matters: a placeholder must not travel beside the injected key, and here
the wrong answer is the placeholder.

The container side is one variable. An API-billing session authenticates
from ANTHROPIC_API_KEY rather than a credential file, so there is nothing
to fake - it gets the placeholder and no credential file at all.

Not verified here: that Claude Code in the container authenticates from
ANTHROPIC_API_KEY through the terminator. That needs a real launch with a
real key. Everything above is the host side.
2026-08-31 14:23:22 -07:00
branderson e01cc71b7d Resolve the backend's credential_file, and pin that the proxy starts
tests / confined (conversation) (pull_request_target) Successful in 53s
tests / confined (share) (pull_request_target) Successful in 1m26s
tests / confined (providers) (pull_request_target) Successful in 1m40s
tests / runner-env (confined) (pull_request_target) Successful in 45s
tests / confined (remote) (pull_request_target) Successful in 2m7s
tests / confined (static) (pull_request_target) Successful in 2m37s
tests / privileged (pull_request_target) Successful in 3m23s
tests / confined (config) (pull_request_target) Successful in 3m25s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (proxy) (pull_request_target) Successful in 57s
tests / confined (shellcheck) (pull_request_target) Successful in 1m10s
tests / confined (project) (pull_request_target) Successful in 2m30s
tests / confined (audit) (pull_request_target) Successful in 3m4s
The rename missed one call: the guard around the credential_file lookup
still asked config_type for a "provider.<name>" section, which no longer
exists. It answered no on every launch, so DEJIMA_BACKEND_CREDENTIAL_FILE
stayed empty, _credential_usable refused, and the proxy never started -
falling through to mounting the operator's real credential into the
container, which is the one arrangement dejima exists to avoid.

Nothing failed. Every suite passed with the proxy silently out of the
path, because what was being asserted was that a misconfiguration is
refused, never that a correct one gets as far as starting a proxy.

So the regression test is end to end and asserts the positive: a shipped
subscription-login launch reaches a proxy socket, its real credential
file is not among docker's mounts, and the real token appears nowhere in
the environment or argv. Reverting the one-word fix fails it.
2026-08-31 14:18:09 -07:00
branderson 8315468776 Call the model API a backend, not a provider
tests / confined (conversation) (pull_request_target) Successful in 54s
tests / confined (proxy) (pull_request_target) Successful in 56s
tests / runner-env (confined) (pull_request_target) Successful in 41s
tests / confined (remote) (pull_request_target) Successful in 2m2s
tests / confined (project) (pull_request_target) Successful in 2m21s
tests / confined (audit) (pull_request_target) Successful in 2m56s
tests / confined (static) (pull_request_target) Successful in 2m32s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (share) (pull_request_target) Successful in 1m25s
tests / confined (providers) (pull_request_target) Successful in 1m37s
tests / confined (shellcheck) (pull_request_target) Successful in 1m5s
tests / confined (config) (pull_request_target) Successful in 3m19s
tests / privileged (pull_request_target) Successful in 3m30s
This repo already calls the forge/workflow/harness axes "providers", so
using the same word for the model API a session talks to left one word
meaning two things in the same sentence. The axis mechanism keeps the
name; the model-API axis becomes "backend".

  providers/<name>/provider.conf  ->  backends/<name>/backend.conf
  [harness.<name>] provider       ->  backend
  [provider.<name>] <key>         ->  [backend.<name>] <key>
  --provider                      ->  --backend
  assumed_provider                ->  assumed_backend
  SANDBOX_PROVIDER_PROXIED_DOMAIN ->  SANDBOX_BACKEND_PROXIED_DOMAIN

A harness keeps its own vocabulary: cline writes a providers.json with a
lastUsedProvider key, and bin/harness/cline.sh translates.

Three things found while sweeping, fixed here rather than left:

backends/ was missing from the audit's TCB list. A backend.conf names the
upstream every request is forwarded to and the host path the credential is
read from, so an edit there redirects where a real credential is sent -
the same class as tools/*/tool.conf, which is listed.

docs/providers.md documented upstream and proxied_domain as required and
credential as static|anthropic-oauth, all three stale since credential =
none landed. Its axis tables also never gained the fourth axis.

The new axis was left out of _provider_warm_dirs, so its directory
resolution was re-paid in every subshell that read a manifest.
2026-08-31 14:06:37 -07:00
branderson 4aa984717c Merge pull request 'Add OpenRouter, and make Cline's endpoint one decision' (#287) from cline-openrouter into provider-axis
tests / confined (conversation) (pull_request_target) Successful in 52s
tests / confined (proxy) (pull_request_target) Successful in 55s
tests / confined (share) (pull_request_target) Successful in 1m24s
tests / confined (config) (pull_request_target) Successful in 3m16s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (providers) (pull_request_target) Successful in 1m35s
tests / confined (shellcheck) (pull_request_target) Successful in 1m3s
tests / runner-env (confined) (pull_request_target) Successful in 39s
tests / confined (remote) (pull_request_target) Successful in 2m0s
tests / confined (project) (pull_request_target) Successful in 2m21s
tests / confined (audit) (pull_request_target) Successful in 2m52s
tests / confined (static) (pull_request_target) Successful in 2m30s
tests / privileged (pull_request_target) Successful in 3m18s
Reviewed-on: #287
Reviewed-by: branderson <brad@branderson.io>
2026-08-31 02:49:56 -07:00
branderson ab457fdbbe Let the fake Loki bind its own port, and wait for the shipper
tests / confined (proxy) (pull_request_target) Successful in 56s
tests / confined (share) (pull_request_target) Successful in 1m25s
tests / confined (conversation) (pull_request_target) Successful in 1m32s
tests / confined (providers) (pull_request_target) Successful in 1m39s
tests / runner-env (confined) (pull_request_target) Successful in 40s
tests / confined (remote) (pull_request_target) Successful in 2m3s
tests / confined (shellcheck) (pull_request_target) Successful in 1m1s
tests / confined (project) (pull_request_target) Successful in 2m26s
tests / confined (audit) (pull_request_target) Successful in 2m56s
tests / confined (config) (pull_request_target) Successful in 3m20s
tests / privileged (pull_request_target) Successful in 3m21s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (static) (pull_request_target) Successful in 2m33s
2026-08-31 01:00:18 -07:00
branderson 31bd7c3cfe Cache only successes, and make the probe fixture fail loudly
tests / confined (conversation) (pull_request_target) Failing after 52s
tests / confined (proxy) (pull_request_target) Successful in 52s
tests / confined (share) (pull_request_target) Successful in 1m23s
tests / confined (providers) (pull_request_target) Successful in 1m36s
tests / confined (shellcheck) (pull_request_target) Successful in 1m4s
tests / runner-env (confined) (pull_request_target) Successful in 43s
tests / confined (remote) (pull_request_target) Successful in 2m1s
tests / confined (project) (pull_request_target) Successful in 2m21s
tests / confined (audit) (pull_request_target) Successful in 2m52s
tests / confined (static) (pull_request_target) Successful in 2m30s
tests / confined (config) (pull_request_target) Successful in 3m15s
tests / privileged (pull_request_target) Successful in 3m25s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
2026-08-31 00:52:28 -07:00
branderson 18666c1bea Add a provider/harness matrix, checked against the manifests
tests / confined (conversation) (pull_request_target) Failing after 51s
tests / confined (proxy) (pull_request_target) Successful in 56s
tests / confined (share) (pull_request_target) Successful in 1m24s
tests / confined (providers) (pull_request_target) Successful in 1m36s
tests / confined (shellcheck) (pull_request_target) Successful in 1m3s
tests / runner-env (confined) (pull_request_target) Successful in 41s
tests / confined (remote) (pull_request_target) Successful in 2m2s
tests / confined (project) (pull_request_target) Successful in 2m25s
tests / confined (static) (pull_request_target) Successful in 2m32s
tests / confined (config) (pull_request_target) Successful in 3m20s
tests / confined (audit) (pull_request_target) Successful in 2m58s
tests / privileged (pull_request_target) Successful in 3m23s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
2026-08-31 00:16:21 -07:00
branderson 61add62e98 Make a self-served endpoint a provider too, so base_url stays put
tests / confined (conversation) (pull_request_target) Successful in 50s
tests / confined (proxy) (pull_request_target) Successful in 54s
tests / confined (share) (pull_request_target) Successful in 1m25s
tests / confined (providers) (pull_request_target) Successful in 1m33s
tests / confined (shellcheck) (pull_request_target) Successful in 1m4s
tests / runner-env (confined) (pull_request_target) Successful in 40s
tests / confined (remote) (pull_request_target) Successful in 2m3s
tests / confined (project) (pull_request_target) Successful in 2m24s
tests / confined (audit) (pull_request_target) Successful in 2m55s
tests / confined (static) (pull_request_target) Successful in 2m32s
tests / confined (config) (pull_request_target) Successful in 3m18s
tests / privileged (pull_request_target) Successful in 3m23s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
2026-08-31 00:02:14 -07:00
branderson 7ba8a2fe7d Add OpenRouter, and make Cline's endpoint one decision
tests / confined (conversation) (pull_request_target) Successful in 52s
tests / confined (proxy) (pull_request_target) Successful in 55s
tests / confined (share) (pull_request_target) Successful in 1m25s
tests / confined (providers) (pull_request_target) Successful in 1m33s
tests / confined (shellcheck) (pull_request_target) Successful in 1m5s
tests / runner-env (confined) (pull_request_target) Successful in 41s
tests / confined (remote) (pull_request_target) Successful in 2m1s
tests / confined (project) (pull_request_target) Successful in 2m23s
tests / confined (audit) (pull_request_target) Successful in 2m56s
tests / confined (static) (pull_request_target) Successful in 2m28s
tests / confined (config) (pull_request_target) Successful in 3m19s
tests / privileged (pull_request_target) Successful in 3m25s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
2026-08-30 23:12:58 -07:00
branderson a9e75313e9 Record an unset provider in the config rather than defaulting it
tests / confined (conversation) (pull_request_target) Successful in 56s
tests / confined (proxy) (pull_request_target) Successful in 1m0s
tests / confined (share) (pull_request_target) Successful in 1m24s
tests / confined (providers) (pull_request_target) Successful in 1m34s
tests / confined (shellcheck) (pull_request_target) Successful in 1m5s
tests / runner-env (confined) (pull_request_target) Successful in 46s
tests / confined (remote) (pull_request_target) Successful in 2m2s
tests / confined (project) (pull_request_target) Successful in 2m22s
tests / confined (audit) (pull_request_target) Successful in 3m0s
tests / confined (config) (pull_request_target) Successful in 3m17s
tests / confined (static) (pull_request_target) Successful in 2m31s
tests / privileged (pull_request_target) Successful in 3m25s
tests / runner-env (privileged) (pull_request_target) Successful in 35s
2026-08-30 21:55:05 -07:00
branderson 6eada984e0 Give the provider no default, and document what each arrangement exposes 2026-08-30 21:55:05 -07:00
branderson 20cb52defb Select the provider per harness, with a --provider flag 2026-08-30 21:55:05 -07:00
branderson fff6f02a55 Declare the model provider in a manifest, not in core 2026-08-30 21:55:05 -07:00
branderson 1809c3415b Merge pull request 'Resolve a harness's key schema before anything can signal us' (#286) from shipper-fix into development
Reviewed-on: #286
Reviewed-by: branderson <brad@branderson.io>
2026-08-30 21:53:54 -07:00
branderson 6ad65da574 Resolve a harness's key schema before anything can signal us
tests / confined (conversation) (pull_request_target) Successful in 51s
tests / confined (proxy) (pull_request_target) Successful in 55s
tests / confined (share) (pull_request_target) Successful in 1m17s
tests / confined (providers) (pull_request_target) Successful in 1m31s
tests / confined (shellcheck) (pull_request_target) Successful in 1m4s
tests / runner-env (confined) (pull_request_target) Successful in 42s
tests / confined (remote) (pull_request_target) Successful in 1m54s
tests / confined (project) (pull_request_target) Successful in 2m11s
tests / confined (audit) (pull_request_target) Successful in 2m54s
tests / confined (config) (pull_request_target) Successful in 2m59s
tests / confined (static) (pull_request_target) Successful in 2m34s
tests / privileged (pull_request_target) Successful in 3m22s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
2026-08-30 21:39:57 -07:00
branderson 8e992ae0bf Merge pull request 'Give each project its own share directory, and mount only that at ~/shared' (#285) from share-per-project into development
Reviewed-on: #285
Reviewed-by: branderson <brad@branderson.io>
2026-08-30 21:25:32 -07:00
branderson b1374b21ea Say where ~/shared is now, in the briefing and the four docs that name it
tests / confined (conversation) (pull_request_target) Successful in 55s
tests / confined (proxy) (pull_request_target) Successful in 55s
tests / confined (providers) (pull_request_target) Successful in 1m32s
tests / runner-env (confined) (pull_request_target) Successful in 51s
tests / confined (shellcheck) (pull_request_target) Successful in 1m9s
tests / confined (remote) (pull_request_target) Successful in 1m55s
tests / confined (project) (pull_request_target) Successful in 2m33s
tests / confined (static) (pull_request_target) Successful in 2m42s
tests / confined (audit) (pull_request_target) Successful in 2m56s
tests / confined (config) (pull_request_target) Successful in 3m2s
tests / privileged (pull_request_target) Successful in 4m0s
tests / runner-env (privileged) (pull_request_target) Successful in 40s
The briefing told the session ~/shared was a persistent host directory and
nothing about who else could read it, which was accurate when the answer was
everyone. It now says the directory is this project's own and that every
session against this project shares it.

configuration.md gains the layout, the dejima-share commands, and what happened
to files left at share_dir's top level. security.md gains a bullet in the
section that already lists what is sliced per project, because the share
directory was the remaining way around that slicing. remote-access.md's sshfs
example mounts projects/ - one directory per project, no loose files - and says
a single project's directory is mountable on its own.

The 'clear it out periodically' warning stays and gets sharper: the split
bounds which sessions can read what you left, and bounds nothing about how
long.
2026-08-30 21:16:39 -07:00
branderson 6a2b36740e Cover the share split, and wire the new suite into both runners
share.bats drives the real launch with a stubbed docker and asserts on the
SANDBOX_SHARE_DIR compose was actually handed, rather than on --print-config: a
version that resolved the path correctly and then mounted something else would
pass the second.

What it pins is the part that can regress silently - that two projects get two
directories, that dejima-share path names the directory the launch mounts, that
the launch keeps saying what the split left behind until it is gone, that adopt
moves nothing without a yes and never overwrites, and that neither [mounts]
readonly nor read_write can hand back the root.
2026-08-30 21:16:31 -07:00
branderson bc994ccf80 Add dejima-share: where a project's ~/shared is, and what is in it
Splitting share_dir per project makes the inbound direction the hard one - an
operator dropping a file in for the next launch needs to know which directory
that launch will mount. `dejima-share path` answers it and creates the
directory, so a file copied there is waiting.

`list` is the retention story, which is that there is not one: dejima removes
nothing from share_dir and never has, so this prints each project's size and
the date of its last write and says plainly that clearing it out is yours. The
per-project split bounds which sessions can read what you left and bounds
nothing about how long.

`adopt` moves what the split left loose at share_dir's top level into one
project's share, after showing it and asking once, the way dejima-project
approve does. It never overwrites: a name already in the destination is left
loose and reported, because both files are the operator's and this cannot know
which one they meant.

Nothing here deletes anything by itself. The boundary is the mount bin/dejima
sets up; this is the operator's view of the other side of it.
2026-08-30 21:16:31 -07:00
branderson 5f2b51d2ce Mount one project's share directory at ~/shared, not the whole of share_dir
`[core] share_dir` was one directory mounted into every container, so a
session saw the handoff files of every session before it and of every session
running beside it, across projects. That is the boundary ~/.claude is already
sliced per project to hold, with the share directory as the way around it.

A launch now mounts <share_dir>/projects/<slug>, keyed by the same workspace
slug the audit and epitaph stores use. What the container sees is still
~/shared, so nothing an agent has been told changes.

The layout lives beside ws_slug because two callers have to agree on it: what
bin/dejima mounts and what dejima-share tells an operator to put a file in.

<share_dir>/projects joins the guarded stores. Per-project only holds while
nothing else hands the whole of it over, and a session's own share arrives at
~/shared without going through [mounts] at all.

Files an operator left at share_dir's top level are still there and are now
mounted nowhere. Moving them is not this change's business, so every launch
says which ones they are until they are gone - a file handed to a session
yesterday and silently not arriving today is the failure worth avoiding here.
2026-08-30 21:16:19 -07:00
branderson e8ea09a347 Merge pull request 'A fast local test lane: parallel within each suite, and change-aware selection' (#283) from fast-test-lane into development
Reviewed-on: #283
2026-08-30 20:38:26 -07:00
branderson b232636960 Document the two lanes where the suite instructions already are
tests / confined (proxy) (pull_request_target) Successful in 1m15s
tests / confined (conversation) (pull_request_target) Successful in 1m19s
tests / runner-env (confined) (pull_request_target) Successful in 37s
tests / confined (providers) (pull_request_target) Successful in 2m2s
tests / confined (shellcheck) (pull_request_target) Successful in 58s
tests / confined (remote) (pull_request_target) Successful in 3m2s
tests / confined (project) (pull_request_target) Successful in 3m49s
tests / privileged (pull_request_target) Successful in 3m47s
tests / runner-env (privileged) (pull_request_target) Successful in 41s
tests / confined (static) (pull_request_target) Successful in 5m46s
tests / confined (audit) (pull_request_target) Successful in 7m8s
tests / confined (config) (pull_request_target) Successful in 7m25s
.dejima.md's per-file table is gone: it lived as prose an agent had to apply by
hand, and it now lives in suites_for() where it runs. What replaces it is the
distinction the table could not carry - which lane is the gate, and that a
green --changed run is evidence about the suites it names and nothing about the
ones it skipped.
2026-08-30 20:29:20 -07:00
branderson 839715605c Run the tests within a suite concurrently, and select suites from the diff
Two lanes, and each run says which one it was. The default is still every
confined suite - the pull-request gate, unchanged in what it covers. --changed
maps the working diff through suites_for() and runs only what it selects,
saying in as many words that it is not the gate.

The map is the table .dejima.md carried as prose, moved here so that it runs.
A path matching no rule selects every suite, because a path nobody has mapped
is a path nobody has decided about. A path given as an argument stands in for
one git would have reported, so --changed --list answers "what would this
change run?" without depending on what happens to be uncommitted.

Within each file, bats' own --jobs. It needs flock rather than GNU parallel,
and --no-parallelize-across-files is what tells bats that - it otherwise
refuses --jobs outright when GNU parallel is absent, which is every dejima
sandbox. Without flock the script falls back to one at a time and says so. The
CI matrix gets the same flag, where flock comes from util-linux, which Debian
marks Essential.

static.bats pins what makes two lanes safe: the map names only suites that
exist, an unmapped path selects everything, and each lane identifies itself.
2026-08-30 20:29:15 -07:00
branderson 19f5e6e452 State two timing preconditions as conditions, not as sleeps
Both surfaced once the suites started running their own tests concurrently:
they held on an idle machine and failed on a loaded one, which is the pattern
.dejima.md already records twice.

conversation.bats slept a second to mean "the shipper is up with the line in
its batch". Under load the startup that precedes the SIGTERM trap takes longer
than that, so the kill landed before there was a trap and the process died
flushing nothing. The nap between polls is a child `sleep`, so the state is
observable; await_napping waits for it.

The Ctrl-Z harness read the terminal flags at a fixed moment after `fg`. fg
returns when the job is continued and the client sets raw mode some scheduling
later, so on a loaded machine the check read the shell's own canonical mode and
called it a failure to restore. await_raw waits for raw mode the way
await_reclaim already waits for the helper.
2026-08-30 20:29:06 -07:00
branderson b23ea0edb2 Cut the fork cost of every provider-library load
Sourcing bin/dejima-provider-lib.sh cost ~235ms, of which ~180ms was the
source-time warm-up: provider_list and provider_dir reached provider_root and
_provider_axis_dir through $( ), and provider_dir resolved the axis directory
again for every provider, so warming eight directories was around forty forks.

Both helpers gain an assign-into-a-variable form beside the printing one, the
way _config_type_into already sits beside config_type. readlink answers are
memoized per path, and the warm-up resolves every path in one readlink - it
takes several operands and prints one line each - dropping the batch on a
mismatched count rather than caching a pairing it cannot trust.

What is cached is one path's resolution, not a verdict: provider_dir still runs
the containment comparison and the -d check on every call.

This is paid by every bin/dejima invocation and by every test that drives the
library in a fresh shell, which is most of them.
2026-08-30 20:28:59 -07:00
branderson c83e54e35d Merge pull request 'Make the credential proxy provider-generic, with Anthropic as one caller' (#279) from generic-proxy into development
Reviewed-on: #279
Reviewed-by: branderson <brad@branderson.io>
2026-08-30 14:06:34 -07:00
branderson f2cfd0d868 Make the credential proxy provider-generic, with Anthropic as one caller
tests / confined (proxy) (pull_request_target) Successful in 1m16s
tests / confined (conversation) (pull_request_target) Successful in 1m22s
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / confined (shellcheck) (pull_request_target) Successful in 56s
tests / confined (providers) (pull_request_target) Successful in 2m34s
tests / privileged (pull_request_target) Successful in 3m29s
tests / confined (remote) (pull_request_target) Successful in 3m41s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (project) (pull_request_target) Successful in 5m4s
tests / confined (config) (pull_request_target) Successful in 9m30s
tests / confined (static) (pull_request_target) Successful in 5m34s
tests / confined (audit) (pull_request_target) Successful in 8m10s
2026-08-29 17:31:35 -07:00
branderson 817959acb9 Merge pull request 'Match both image spellings, so the reap stops matching nothing' (#278) from runtime-reap into development
Reviewed-on: #278
Reviewed-by: branderson <brad@branderson.io>
2026-08-29 16:43:53 -07:00
branderson a133ba4b7e Match both image spellings, so the reap stops matching nothing
tests / confined (proxy) (pull_request_target) Successful in 1m8s
tests / confined (conversation) (pull_request_target) Successful in 1m20s
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / confined (shellcheck) (pull_request_target) Successful in 59s
tests / confined (providers) (pull_request_target) Successful in 2m32s
tests / privileged (pull_request_target) Successful in 3m20s
tests / confined (remote) (pull_request_target) Successful in 3m37s
tests / runner-env (privileged) (pull_request_target) Successful in 36s
tests / confined (project) (pull_request_target) Successful in 5m5s
tests / confined (static) (pull_request_target) Successful in 5m37s
tests / confined (audit) (pull_request_target) Successful in 8m9s
tests / confined (config) (pull_request_target) Successful in 9m33s
2026-08-29 15:36:10 -07:00
branderson 9db269d8ba Merge pull request 'Name every image dejima builds under localhost, not Docker Hub' (#273) from local-tags into development
Reviewed-on: #273
Reviewed-by: branderson <brad@branderson.io>
2026-08-29 15:25:06 -07:00
branderson 17ef233229 Merge pull request 'Lay out the dejima stack, and list dejima-guard with its gaps' (#274) from stack-table into development
Reviewed-on: #274
Reviewed-by: branderson <brad@branderson.io>
2026-08-29 15:24:39 -07:00
branderson 3c68d868de List the dejima stack in the README, linking out for the detail
tests / confined (proxy) (pull_request_target) Successful in 1m10s
tests / confined (conversation) (pull_request_target) Successful in 1m22s
tests / runner-env (confined) (pull_request_target) Successful in 35s
tests / confined (shellcheck) (pull_request_target) Successful in 59s
tests / confined (providers) (pull_request_target) Successful in 2m40s
tests / privileged (pull_request_target) Successful in 3m23s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (remote) (pull_request_target) Successful in 3m51s
tests / confined (project) (pull_request_target) Successful in 5m17s
tests / confined (static) (pull_request_target) Successful in 5m42s
tests / confined (audit) (pull_request_target) Successful in 8m18s
tests / confined (config) (pull_request_target) Successful in 9m40s
2026-08-29 15:04:48 -07:00
branderson 851da62449 Describe dejima-guard by what it adds, not by how it differs from dejima
tests / confined (proxy) (pull_request_target) Successful in 1m4s
tests / confined (conversation) (pull_request_target) Successful in 1m21s
tests / runner-env (confined) (pull_request_target) Successful in 35s
tests / confined (shellcheck) (pull_request_target) Successful in 57s
tests / confined (providers) (pull_request_target) Successful in 2m25s
tests / privileged (pull_request_target) Successful in 3m13s
tests / confined (remote) (pull_request_target) Successful in 3m27s
tests / runner-env (privileged) (pull_request_target) Successful in 34s
tests / confined (project) (pull_request_target) Successful in 4m46s
tests / confined (static) (pull_request_target) Successful in 5m17s
tests / confined (audit) (pull_request_target) Successful in 7m43s
tests / confined (config) (pull_request_target) Successful in 9m6s
2026-08-29 14:23:54 -07:00
branderson 38cd96a06b Attach the guard's gaps to its name, and link it 2026-08-29 14:14:27 -07:00
branderson 0d8e241801 Lay out the dejima stack, and list dejima-guard with its gaps 2026-08-29 14:14:27 -07:00
branderson e44ddc01ff Tag the workflow image too, and check the invariant repo-wide
tests / confined (proxy) (pull_request_target) Successful in 1m5s
tests / confined (conversation) (pull_request_target) Successful in 1m19s
tests / runner-env (confined) (pull_request_target) Successful in 35s
tests / confined (shellcheck) (pull_request_target) Successful in 57s
tests / confined (providers) (pull_request_target) Successful in 2m21s
tests / privileged (pull_request_target) Successful in 3m8s
tests / confined (remote) (pull_request_target) Successful in 3m20s
tests / runner-env (privileged) (pull_request_target) Successful in 34s
tests / confined (project) (pull_request_target) Successful in 4m37s
tests / confined (static) (pull_request_target) Successful in 5m8s
tests / confined (audit) (pull_request_target) Successful in 7m29s
tests / confined (config) (pull_request_target) Successful in 8m49s
2026-08-29 13:58:59 -07:00
branderson 88bb0394d8 Name every image dejima builds under localhost, not Docker Hub 2026-08-29 13:58:14 -07:00
branderson 185c862559 Merge pull request 'Tag the CI sandbox image under both names so a rename can land' (#277) from ci-image-tag into development
Reviewed-on: #277
Reviewed-by: branderson <brad@branderson.io>
2026-08-29 13:57:13 -07:00
branderson 309f2269c4 Tag the CI sandbox image under both names so a rename can land
tests / confined (proxy) (pull_request_target) Successful in 1m12s
tests / confined (conversation) (pull_request_target) Successful in 1m24s
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / confined (shellcheck) (pull_request_target) Successful in 56s
tests / confined (providers) (pull_request_target) Successful in 2m35s
tests / privileged (pull_request_target) Successful in 3m22s
tests / confined (remote) (pull_request_target) Successful in 3m38s
tests / runner-env (privileged) (pull_request_target) Successful in 35s
tests / confined (project) (pull_request_target) Successful in 5m8s
tests / confined (static) (pull_request_target) Successful in 5m31s
tests / confined (audit) (pull_request_target) Successful in 8m6s
tests / confined (config) (pull_request_target) Successful in 9m32s
2026-08-29 12:12:20 -07:00
branderson bc979ced2d Merge pull request 'Widen the gh repo guard to grouped short flags and api paths' (#270) from gh-guard-spellings into development
Reviewed-on: #270
Reviewed-by: branderson <brad@branderson.io>
2026-08-29 12:01:38 -07:00
branderson 540ee183ab Merge pull request 'Assert the Ctrl-Z race test by name, not by the whole harness passing' (#272) from suspend-test-scope into development
Reviewed-on: #272
Reviewed-by: branderson <brad@branderson.io>
2026-08-29 06:00:58 -07:00
branderson c99dfcb7ec Merge pull request 'Keep the ntfy URL out of curl's argv and out of error output' (#275) from ntfy-argv into development
Reviewed-on: #275
Reviewed-by: branderson <brad@branderson.io>
2026-08-29 06:00:37 -07:00
branderson 0e6b96963a Merge pull request 'Reap runtime.bats' abandoned images, not just its containers and networks' (#271) from runtime-reap into development
Reviewed-on: #271
Reviewed-by: branderson <brad@branderson.io>
2026-08-29 06:00:08 -07:00
branderson 2e0bc4153b Keep the ntfy URL out of curl's argv and out of error output
tests / confined (conversation) (pull_request_target) Successful in 1m22s
tests / confined (proxy) (pull_request_target) Successful in 1m7s
tests / confined (providers) (pull_request_target) Successful in 2m31s
tests / confined (shellcheck) (pull_request_target) Successful in 59s
tests / runner-env (confined) (pull_request_target) Successful in 34s
tests / confined (project) (pull_request_target) Successful in 4m45s
tests / confined (remote) (pull_request_target) Successful in 3m31s
tests / privileged (pull_request_target) Successful in 3m17s
tests / confined (audit) (pull_request_target) Successful in 7m35s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (config) (pull_request_target) Successful in 8m52s
tests / confined (static) (pull_request_target) Successful in 5m29s
2026-08-29 04:24:08 -07:00
branderson 59c27df8c4 Assert the Ctrl-Z race test by name, not by the whole harness passing
tests / confined (proxy) (pull_request_target) Successful in 1m8s
tests / confined (conversation) (pull_request_target) Successful in 1m22s
tests / runner-env (confined) (pull_request_target) Successful in 34s
tests / confined (shellcheck) (pull_request_target) Successful in 58s
tests / confined (providers) (pull_request_target) Successful in 2m32s
tests / privileged (pull_request_target) Successful in 3m20s
tests / confined (remote) (pull_request_target) Successful in 3m34s
tests / runner-env (privileged) (pull_request_target) Successful in 33s
tests / confined (project) (pull_request_target) Successful in 4m51s
tests / confined (static) (pull_request_target) Successful in 5m12s
tests / confined (audit) (pull_request_target) Successful in 7m32s
tests / confined (config) (pull_request_target) Successful in 8m43s
2026-08-29 02:54:16 -07:00
branderson 0fceaaeeb3 Widen the gh repo guard to grouped short flags and api paths
tests / confined (audit) (pull_request_target) Successful in 7m30s
tests / confined (config) (pull_request_target) Successful in 8m46s
tests / confined (conversation) (pull_request_target) Successful in 1m21s
tests / confined (project) (pull_request_target) Successful in 4m51s
tests / confined (providers) (pull_request_target) Successful in 2m36s
tests / confined (proxy) (pull_request_target) Successful in 1m7s
tests / confined (remote) (pull_request_target) Successful in 3m36s
tests / confined (static) (pull_request_target) Successful in 5m8s
tests / privileged (pull_request_target) Successful in 3m26s
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / runner-env (privileged) (pull_request_target) Successful in 33s
tests / confined (shellcheck) (pull_request_target) Successful in 56s
2026-08-29 01:53:13 -07:00
branderson 3cdcd289e1 Reap this suite's abandoned images, not just its containers and networks
tests / confined (conversation) (pull_request_target) Successful in 1m20s
tests / confined (proxy) (pull_request_target) Successful in 1m8s
tests / confined (providers) (pull_request_target) Successful in 2m31s
tests / privileged (pull_request_target) Successful in 3m16s
tests / confined (shellcheck) (pull_request_target) Successful in 59s
tests / runner-env (privileged) (pull_request_target) Successful in 33s
tests / runner-env (confined) (pull_request_target) Successful in 35s
tests / confined (project) (pull_request_target) Successful in 4m49s
tests / confined (remote) (pull_request_target) Successful in 3m34s
tests / confined (audit) (pull_request_target) Successful in 7m32s
tests / confined (config) (pull_request_target) Successful in 8m52s
tests / confined (static) (pull_request_target) Successful in 5m25s
2026-08-29 01:53:10 -07:00
branderson 79fe71a14c Merge pull request 'Wait for the suspend helper before typing Ctrl-Z at it' (#269) from suspend-harness into development
Reviewed-on: #269
Reviewed-by: branderson <brad@branderson.io>
2026-08-29 01:45:28 -07:00
branderson 3dd3022dd3 Wait for the suspend helper before typing Ctrl-Z at it
tests / confined (conversation) (pull_request_target) Successful in 1m22s
tests / confined (proxy) (pull_request_target) Successful in 1m37s
tests / runner-env (confined) (pull_request_target) Successful in 37s
tests / confined (shellcheck) (pull_request_target) Successful in 56s
tests / confined (providers) (pull_request_target) Successful in 2m36s
tests / privileged (pull_request_target) Successful in 3m19s
tests / confined (remote) (pull_request_target) Successful in 3m41s
tests / runner-env (privileged) (pull_request_target) Successful in 35s
tests / confined (project) (pull_request_target) Successful in 5m10s
tests / confined (static) (pull_request_target) Successful in 5m33s
tests / confined (audit) (pull_request_target) Successful in 8m15s
tests / confined (config) (pull_request_target) Successful in 9m34s
2026-08-29 01:13:35 -07:00
branderson 9a612f7b97 Merge pull request 'Document mounting the box's shared directories over sshfs' (#268) from sshfs-docs into development
Reviewed-on: #268
Reviewed-by: branderson <brad@branderson.io>
2026-08-28 23:49:49 -07:00
branderson ad03d02f4a Drop the fstab route, and make mounting on demand the recommendation
tests / confined (proxy) (pull_request_target) Successful in 1m9s
tests / confined (conversation) (pull_request_target) Successful in 1m23s
tests / runner-env (confined) (pull_request_target) Successful in 35s
tests / confined (shellcheck) (pull_request_target) Successful in 58s
tests / confined (providers) (pull_request_target) Successful in 2m42s
tests / privileged (pull_request_target) Successful in 3m30s
tests / confined (remote) (pull_request_target) Successful in 3m59s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
tests / confined (project) (pull_request_target) Successful in 5m40s
tests / confined (static) (pull_request_target) Successful in 5m55s
tests / confined (audit) (pull_request_target) Successful in 9m10s
tests / confined (config) (pull_request_target) Successful in 10m37s
2026-08-28 23:29:52 -07:00
branderson f638c40b76 Disclose what an sshfs mount costs before the recipe, and in the threat model
tests / confined (proxy) (pull_request_target) Successful in 1m12s
tests / runner-env (confined) (pull_request_target) Successful in 38s
tests / confined (conversation) (pull_request_target) Successful in 1m23s
tests / confined (shellcheck) (pull_request_target) Successful in 1m0s
tests / confined (providers) (pull_request_target) Successful in 2m46s
tests / privileged (pull_request_target) Successful in 3m33s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (remote) (pull_request_target) Successful in 4m3s
tests / confined (project) (pull_request_target) Successful in 5m43s
tests / confined (static) (pull_request_target) Successful in 6m1s
tests / confined (audit) (pull_request_target) Successful in 9m8s
tests / confined (config) (pull_request_target) Successful in 10m41s
2026-08-28 23:05:17 -07:00
branderson ed57f646a3 Merge pull request 'Document what the front door's authentication has to do, not one way to do it' (#264) from auth-alternatives into development
Reviewed-on: #264
Reviewed-by: branderson <brad@branderson.io>
2026-08-28 22:27:07 -07:00
branderson 90a264c63c Document mounting the box's shared directories over sshfs
tests / confined (proxy) (pull_request_target) Successful in 1m14s
tests / confined (conversation) (pull_request_target) Successful in 1m28s
tests / runner-env (confined) (pull_request_target) Successful in 37s
tests / confined (shellcheck) (pull_request_target) Successful in 1m0s
tests / confined (providers) (pull_request_target) Successful in 2m56s
tests / privileged (pull_request_target) Successful in 3m41s
tests / runner-env (privileged) (pull_request_target) Successful in 40s
tests / confined (remote) (pull_request_target) Successful in 4m14s
tests / confined (project) (pull_request_target) Successful in 5m53s
tests / confined (static) (pull_request_target) Successful in 6m7s
tests / confined (audit) (pull_request_target) Successful in 9m23s
tests / confined (config) (pull_request_target) Successful in 11m2s
2026-08-28 22:15:07 -07:00
56 changed files with 7993 additions and 980 deletions
+61 -31
View File
@@ -24,45 +24,75 @@ relaunch so the derived image is rebuilt), not installing it in-session.
If it is missing and you cannot get a rebuild, say the suites were not run
rather than reporting a change as tested.
## Run the suite for what you changed, not all of them
**`parallel` is declared for the same reason**, because CI runs every suite
with `--jobs $(nproc) --no-parallelize-across-files` and `bats --jobs` needs
GNU parallel to do it. Without it bats exits with `Cannot execute N jobs
without GNU parallel` and prints no test output at all.
The suites are independent and cover different areas. Running all of them for
every edit is the single biggest drag on iteration here — reach for the whole
set before opening a PR, not between edits.
That failure is quiet in the worst way, so **check the exit status, not the
output**. A suite that never ran emits no `not ok` line, so the obvious
`| grep '^not ok'` check reports it as passing — which is how a change here
was once reported green against a suite that had not executed. Requiring both
a zero exit status and a non-zero count of `ok` lines catches it:
| You changed | Run |
| --- | --- |
| `bin/dejima-audit` | `test/audit.bats` |
| `bin/dejima-config-lib.sh`, `install.sh` | `test/config.bats` |
| `bin/dejima-provider-lib.sh`, `forges/`, `workflows/`, `harnesses/` | `test/providers.bats` |
| `bin/dejima-attach`, `-ttyd`, `-notify-watch`, `web/`, `systemd/` | `test/remote.bats` |
| `bin/dejima-project-lib.sh`, `-dep`, `-project`, `-allow` | `test/project.bats` |
| `bin/dejima-update-check` | `test/config.bats` |
| `bin/dejima-tui-lib.sh` | `test/remote.bats` and `test/project.bats` (both tools use it) |
| `bin/dejima-tty-suspend` | `test/static.bats` |
| `tools/` | `test/providers.bats` |
| `bin/dejima-anthropic-proxy`, `docker/anthropic-proxy-forwarder.js` | `test/proxy.bats` |
| `docker/`, `hooks/`, `README.md`, `docs/` | `test/static.bats` |
| anything under `docker/` that has to actually boot | `test/runtime.bats` (needs Docker) |
```sh
out="$(bats --jobs "$(nproc)" --no-parallelize-across-files test/config.bats 2>&1)"
rc=$?
n="$(grep -cE '^ok ' <<< "$out")"
[ "$rc" -eq 0 ] && [ "$n" -gt 0 ] || { echo "$out"; exit 1; }
```
When the whole set genuinely is wanted, run it concurrently rather than one
file after another:
## Two lanes: one for iterating, one for the pull request
Between edits:
```sh
bash test/run-confined.sh --changed
```
It reads `git diff` — committed, uncommitted and untracked — maps each path to
the suites that cover it, and runs only those. `--list` prints the selection
without running anything, and a path given as an argument stands in for one git
would have reported, so `--changed --list docker/Dockerfile` answers "what would
this change run?" directly.
Before opening a pull request, and for any claim that a change is tested:
```sh
bash test/run-confined.sh
```
The suites share no state, so they run together safely: about seven minutes
instead of about twenty-five. It reports what each suite ran as well as what
failed, because a suite that cannot start - a syntax error, or duplicate test
names, which bats refuses outright - exits nonzero having run nothing, and
that reads as "no failures" to anything counting only failures.
That is the gate: every confined suite, no selection. Each run prints which lane
it was, and `--changed` says in as many words that it is not the gate. **A green
`--changed` run is not a tested change** — it is evidence about the suites it
names and nothing about the ones it skipped.
Two things that are not in that table because they defeat it: `bin/dejima`
itself and `bin/dejima-config-lib.sh` are read by nearly every other suite, so a
change to either is one of the cases where the whole set genuinely applies. And
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.
The map lives in `suites_for()` in `test/run-confined.sh` rather than in a table
here, so that it runs instead of being remembered, and so an agent cannot map a
change to the wrong suite by hand. A path matching no rule selects every suite:
a path nobody has mapped is a path nobody has decided about. When you add a
file that a specific suite covers, add the rule — `static.bats` checks the map
names only suites that exist, but nothing can tell that a rule is too narrow.
Both lanes run the tests within each file concurrently as well as the files
themselves, which is most of where the wall clock went. It is bats' own
`--jobs`, and it needs `flock` rather than GNU parallel; without `flock` the
script falls back to one at a time and says so.
**The concurrency is budgeted for the whole run, not per suite.** Every
selected suite runs at once, so what lands on the machine is suites × jobs;
asking each for `nproc` made that ten times the cores on a ten-core box. The
script targets roughly twice the cores in total and divides, and prints both
numbers. That is #295: several tests wait on a bounded deadline — a pty
reaching raw mode, a shipper polling, an audit finishing — and a saturated
machine blows through it, so they failed for the load rather than for the
thing they check. If you add a test that waits for something, assume the
machine is busy.
Each run reports what every suite ran as well as what failed, because a suite
that cannot start — a syntax error, or duplicate test names, which bats refuses
outright — exits nonzero having run nothing, and that reads as "no failures" to
anything counting only failures.
**CI runs `test/runtime.bats` on every pull request**, as a separate
`privileged` job in `.gitea/workflows/tests.yml`. So being unable to run it
@@ -132,7 +162,7 @@ only holds where nothing else set it. Both passed in CI and failed exactly where
the code was being worked on:
- `proxy.bats`'s cert check meant "no cert configured" by not setting
`SANDBOX_ANTHROPIC_TLS_CERT` — but `entrypoint.sh` exports it into every
`SANDBOX_CREDENTIAL_TLS_CERT` — but `entrypoint.sh` exports it into every
tls-transport session, so inside a sandbox the guard under test never fired.
- `remote.bats`'s default-port check asserted on 7681, which is the port a real
`dejima-ttyd` front door holds, so it failed on any host running the feature.
+11
View File
@@ -29,6 +29,17 @@ npm:cline
apt:tmux
apt:bats
# What `bats --jobs` needs. CI runs every suite with
# `--jobs $(nproc) --no-parallelize-across-files`, so without this a sandbox
# cannot run the suites the way they are actually run - and the failure is
# quiet in the worst way: bats exits with "Cannot execute N jobs without GNU
# parallel" and produces no test output at all, so a check that greps for
# `not ok` reads a suite that never ran as a suite that passed.
#
# Debian's `parallel` IS GNU parallel. moreutils ships a different binary of
# the same name and conflicts with it; bats needs this one.
apt:parallel
# The other half of what CI runs. The CI job installs it itself, so without
# this line a sandbox is the one place the gate cannot be run at all - and a
# base image that happens to carry it is not a guarantee, as one that stopped
+20 -2
View File
@@ -87,6 +87,7 @@ jobs:
- config
- providers
- conversation
- share
name: confined (${{ matrix.suite }})
container:
# Debian bookworm plus a node binary - node is what actions/checkout
@@ -163,9 +164,16 @@ jobs:
# they fail on the terminal rather than on anything they mean to check.
# xterm-256color ships in ncurses-base, which is Essential - nothing to
# install. Set on the command rather than in env: so it survives su.
#
# --jobs runs the tests within the file concurrently. bats needs GNU
# parallel only to parallelize ACROSS files; within one file it uses its
# own flock-based semaphore, and flock is in util-linux, which Debian
# marks Essential - so nothing here has to install anything for it.
# --no-parallelize-across-files is what tells bats that, since it
# otherwise refuses --jobs outright when GNU parallel is absent.
- run: |
chown -R node:node .
su node -c "TERM=xterm-256color bats test/${{ matrix.suite }}.bats"
su node -c "TERM=xterm-256color bats --jobs $(nproc) --no-parallelize-across-files test/${{ matrix.suite }}.bats"
shellcheck:
# Its own job rather than a matrix entry: it installs a different package,
@@ -425,8 +433,18 @@ jobs:
# SKIP_BUILD below then reuses this image rather than skipping a build.
# The image is still built fresh from this commit, so the property the
# test file defaults to - never assert against a stale image - holds.
# Named under localhost/ so a stray push cannot resolve to Docker Hub,
# like every other image this repo builds (#53).
#
# This step and runtime.bats must agree on that name, and they come from
# different commits: this file is read from the BASE branch (see the
# pull_request_target note at the top) while the checkout above is the
# branch under test. So a rename here lands in two steps - the base
# tagging both names first, then this - and a future one has to do the
# same. A single pull request that changes both sides fails, because the
# half that runs is the half it did not change.
- name: Build the sandbox image
run: docker build -t dejima:latest -f docker/Dockerfile .
run: docker build -t localhost/dejima:latest -f docker/Dockerfile .
- run: bats test/runtime.bats
env:
+1 -1
View File
@@ -1,7 +1,7 @@
.claude
web/node_modules
# Python bytecode. bin/dejima-anthropic-proxy is a python program, so anything
# Python bytecode. bin/dejima-credential-proxy is a python program, so anything
# that imports or compiles it leaves these beside it.
__pycache__/
*.pyc
+109 -48
View File
@@ -45,18 +45,18 @@ starts.
- **An egress allowlist.** DNS and IP, IPv4 and IPv6. Anything not on the
list does not connect, and the agent is told so.
- **Credentials that stay on the host.** Your Anthropic OAuth credential
never enters the container: a host-side proxy holds it and the container
gets a placeholder. Your SSH key doesn't either; the container sees an
agent socket, never the key.
- **Credentials that stay on the host.** Your model credential never enters
the container, whichever backend you selected: a host-side proxy holds it
and injects it per request, and the container gets a placeholder. Your SSH
key doesn't either; the container sees an agent socket, never the key.
- **Per-project declaration and approval.** A project declares the domains
and dependencies it needs in `.dejima/`. Declaring is not granting: the
approval is a separate decision you make on the host, and the approval
store is never mounted where a session could reach it.
- **One project's session cannot reach another's.** `~/.claude` is not
mounted whole. A session gets its own project's transcripts, memory and edit
history and nothing else, so it cannot read what you did elsewhere or write
into another project's memory.
- **One project's session cannot reach another's.** Your harness's state
directory is not mounted whole. A session gets its own project's
transcripts, memory and edit history and nothing else, so it cannot read
what you did elsewhere or write into another project's memory.
- **CI hardening, because push access is code execution elsewhere.** A session
opens a pull request, the workflow fires, and your runner executes that
commit before you've read a line of it. The container bounds what a session
@@ -64,12 +64,17 @@ starts.
[docs/ci.md](docs/ci.md) for what hardening a runner takes, and
[dejima-ci-hardener](https://gitbox.branderson.io/dejima/dejima-ci-hardener)
for a tool that does it.
- **Guard rails inside the session.** Hooks refuse obviously destructive
commands, pushes to anywhere but the allowed remote, and merging a PR from
inside the sandbox. These are fast-fail guards against an overeager agent,
not a boundary against a hostile one: the container is the boundary.
- **Guard rails inside the session.** Pushes to anywhere but the allowed
remote, and merging a PR from inside the sandbox, are refused whichever
harness is selected - they shadow `git` and the forge CLI on `PATH`. Refusing
an obviously destructive command needs a hook the harness can host, so a
harness without one goes without it, and the launch says so rather than
leaving you to assume. All of these are fast-fail guards against an
overeager agent, not a boundary against a hostile one: the container is the
boundary.
- **An exit-time audit** of the host surfaces a session could have written to,
so a change to your git config or `~/.claude` does not go unnoticed.
so a change to your git config, to the workspace's own agent configuration,
or to a file your harness runs on the host does not go unnoticed.
**What it gives an agent to work with:**
@@ -83,9 +88,10 @@ starts.
name - and dejima loads it alongside a briefing on where the session is and
why the limits are there. An agent that knows it is in a sandbox asks for a
domain rather than working around the firewall.
- **Config parity with your host.** `CLAUDE.md`, settings, hooks, plugins and
git identity carry over; uid, home directory and project path match on both
sides, so a session's transcript is readable from the host afterward.
- **Config parity with your host.** Your harness's own global instructions,
settings, hooks, skills, rules and plugins carry over, and so does your git
identity; uid, home directory and project path match on both sides, so a
session's transcript is readable from the host afterward.
- **Remote access, opt-in and off by default.** Attach to a live session from
a terminal or a browser, with a push notification when it wants you.
- **What a long session worked out, kept.** Each time the agent compacts, the
@@ -123,6 +129,32 @@ A note from the developer about the voice in this repository:
[![Developed in dejima](assets/dejima-badge.svg)](https://gitbox.branderson.io/dejima/dejima)
## The dejima stack
dejima runs inside the machine it is protecting the rest of, so it cannot bound
its own host, a runner it never touches, or notice anything after a session
ends. These are what cover the rest. **Only the first row is required.** The
last two are categories rather than products; the named ones are what this
deployment happens to use.
| Piece | Runs on | What it adds |
| --- | --- | --- |
| **dejima** | a devbox | The container boundary: what a session reaches on disk, and deny-by-default egress. |
| **[dejima-devbox-hardener](https://gitbox.branderson.io/dejima/dejima-devbox-hardener)** | the same devbox | Cuts LAN reach to what is declared, enforced from outside the guest so root in the VM cannot flush it. |
| **[dejima-guard](https://gitbox.branderson.io/dejima/dejima-guard)** | a different machine | Notices the devbox changed, against a baseline held off the box. Detection, not enforcement. |
| **[dejima-ci-hardener](https://gitbox.branderson.io/dejima/dejima-ci-hardener)** | your CI runner | A runner that executes a session's pull request without that being LAN access. Hosted CI is the alternative, on someone else's network rather than yours. |
| **ntfy**, or any push service | anywhere both can reach | Where "session needs input" and the guard's alerts arrive. |
| **Uptime Kuma**, or any uptime monitor | off the devbox | The dead man's switch: what notices the guard itself stopped reporting. |
**→ [docs/threat-model.md](docs/threat-model.md#the-stack-and-what-each-piece-buys)** -
what each one buys, what goes wrong without it, and where each one's boundary
ends. None of them is required to run dejima; what they change is what the
layer below the container costs, and whether anyone finds out.
**→ [Why a container, and not a microVM?](docs/threat-model.md#why-a-container-and-not-a-microvm)** -
the most common question about this design, and why the effort went into the
layers below the container rather than into a harder one.
## Setup
This repo only holds the tooling; no project lives here. Point it at a project
@@ -143,10 +175,13 @@ ones you want.
[Sign in on the host first](#sign-in-on-the-host-first) - and skip it
entirely for a harness pointed at a model you serve yourself, which needs
no credential.
2. **`./install.sh`**, which symlinks the CLIs into `~/.local/bin`, writes a
starter `~/.config/dejima/config` if none exists, and registers whatever
guard hooks the harness you selected can host. Idempotent, so re-run it
after pulling; it never overwrites an existing config.
2. **`./install.sh`**, which symlinks the CLIs into `~/.local/bin` and writes
a starter `~/.config/dejima/config` if none exists. It writes nothing to
your harness's own config: the sandbox's guards are baked into the image and
registered per launch into a settings layer of dejima's own, so installing
dejima does not change how your harness behaves outside a sandbox.
Idempotent, so re-run it after pulling; it never overwrites an existing
config.
3. **Put `~/.local/bin` on your `PATH`** if it isn't already.
4. **Set `[core] harness`.** It has no default and a launch is refused without
it, which is what stops a later release changing your selection for you.
@@ -271,8 +306,8 @@ works if the host has a credential to hold. Authenticating from inside is not
the fix: the container is where the credential deliberately never goes, and the
egress allowlist is not built around completing a sign-in flow there. The same
applies to the account state your harness keeps alongside the credential, read
fresh from the host at every launch (see "Throwaway ~/.claude.json" in
[docs/security.md](docs/security.md)).
fresh from the host at every launch (Claude Code's is "Throwaway
~/.claude.json" in [docs/security.md](docs/security.md)).
So: run your harness once on the host, sign in, confirm it answers, and then
point dejima at a project. When a credential expires, refresh it the same way,
@@ -283,19 +318,33 @@ There is no credential to hold or expire; what that setup needs instead is for
the endpoint to be reachable from inside the container, which is an allowlist
question rather than a sign-in one.
## Provider axes: forge, workflow, harness
## Provider axes: forge, workflow, harness, model backend
The forge integration, the push/PR workflow, and the agent harness aren't
hardcoded into dejima: each is a directory under `forges/`, `workflows/` or
`harnesses/`, selected independently by `[core] forge`, `[core] workflow` and
`[core] harness`.
The forge integration, the push/PR workflow, the agent harness and the model
API aren't hardcoded into dejima: each is a directory under `forges/`,
`workflows/`, `harnesses/` or `backends/`, selected independently by
`[core] forge`, `[core] workflow`, `[core] harness` and
`[harness.<name>] backend`.
- **Forges:** `none`, `gitea`, `github`
- **Workflows:** `local-only`, `branch-push`, `worktree-pr`
- **Harnesses:** `claude-code`, `cline`
- **Model backends:** `anthropic`, `anthropic-api-key`, `openrouter`, `self-hosted`
Which of those last two pair up is the backend's API dialect against the
harness's, checked at launch rather than maintained as a list:
| Backend | `claude-code` | `cline` | Credential |
| --- | --- | --- | --- |
| `anthropic` | yes | yes | OAuth, held on the host |
| `anthropic-api-key` | yes | yes | a static key, held on the host |
| `openrouter` | no | yes | a static key, held on the host |
| `self-hosted` | no | yes | none, and so no proxy at all |
**→ [docs/providers.md](docs/providers.md)** - the manifest contract, key by
key, and the shipped providers as worked examples.
key, what each pairing needs configured, and the shipped providers as worked
examples. The model API is called a *backend* throughout, to keep it apart
from the axes themselves.
## Using this responsibly
@@ -351,9 +400,11 @@ equivalent for it.
The allowlist covers what the sandbox needs to function - DNS, the Docker host,
GitHub on port 443 for read access whichever forge is selected, whatever the
selected forge needs, the harness's own update and model endpoints, and
`api.anthropic.com` when the credential proxy is off - plus whatever a project
has declared and had approved.
selected forge needs, the harness's own update endpoints, and the selected
backend's API endpoint when the credential proxy is off - plus whatever a
project has declared and had approved. With the proxy on that last name is
deliberately absent: the session reaches the API through the in-container
terminator rather than by resolving it.
DNS is allowlisted as well as IPs: a local resolver only forwards lookups for
names already on that same allowlist, so an unapproved domain does not resolve
@@ -402,25 +453,35 @@ exact command to hand you.
tarball dependencies, the symlink refusal that protects the approval store,
and the complete `dejima-allow`/`dejima-dep`/`dejima-project` reference.
## Config parity with your host Claude Code setup
## Config parity with your host harness setup
Global `CLAUDE.md`, settings, hooks, skills, plugins and git identity all carry
over, and your existing login stays effective, with the credential itself never
entering the container. `~/.claude` is not shared as a whole - only an
enumerated subset does, so a session transcript is readable from the host
afterward but a credential or cache isn't. The container's own user matches
yours exactly, so `$HOME`-relative paths resolve the same on both sides.
Your harness's own global instructions, settings, hooks, skills, rules and
plugins carry over, along with your git identity, and your existing login stays
effective with the credential itself never entering the container. The
harness's config directory is not shared as a whole - only an enumerated subset
is, so a session transcript is readable from the host afterward but a
credential or cache isn't. The container's own user matches yours exactly, so
`$HOME`-relative paths resolve the same on both sides.
Which files those are is each harness's own question, answered in its plugin:
`~/.claude` for Claude Code, `~/.agents` and `~/.cline` for Cline.
**→ [docs/configuration.md](docs/configuration.md)** - exactly what carries
over and what doesn't, and why single-file mounts couldn't be trusted here.
over and what doesn't, per harness, and why single-file mounts couldn't be
trusted here.
## Shared scratch space
`~/shared` is a read-write bind mount of `[core] share_dir`, separate from the
project mount, for passing files in and out of a session without touching the
checkout. It persists across sessions since it's a real directory, not a
container volume. **Clear it out between sessions:** anything left there is
readable by the next session, and by every session after that.
`~/shared` is a read-write bind mount of this project's directory under
`[core] share_dir`, separate from the project mount, for passing files in and
out of a session without touching the checkout. One directory per project, so a
session sees its own project's handoff files and no other project's.
`dejima-share path` prints the host directory to drop a file into before a
launch; `dejima-share list` shows what every project is holding.
It persists across sessions since it's a real directory, not a container
volume. **Clear it out between sessions:** anything left there is readable by
the next session against that project, and by every session after that.
`~/shared`, the project mount, and anything in `[mounts] read_write` are the
only writable host paths in the container. Everything else a session writes
@@ -484,9 +545,9 @@ what a forwarded personal agent changes, and the persistent alternative.
Before each run, dejima snapshots the host surfaces a session could write to
and persist through: `.git/config` keys that run a command on the next `git`,
`.git/hooks/`, the workspace's `.claude/`, `.mcp.json`, `.envrc`, and writes
under `~/.claude/projects`. After the container exits it diffs them and tells
you what changed.
`.git/hooks/`, the workspace's `.claude/` and `.vscode/`, `.mcp.json`, `.envrc`,
and any host-side file the selected harness declares as one it runs by itself.
After the container exits it diffs them and tells you what changed.
It is **advisory, not a block**, firing after the session has ended, so a
finding is something to clean up before your next `git` command or session in
@@ -625,7 +686,7 @@ and how to run them.
| [research-review.md](docs/research-review.md) | Checking the threat model against the field's, and the standing questions that are the agenda |
| [configuration.md](docs/configuration.md) | Per-project declaration, dependencies, config parity |
| [tools.md](docs/tools.md) | The optional tools a session can be given, and what hardening each one carries |
| [providers.md](docs/providers.md) | Forge, workflow and harness axes, and writing your own |
| [providers.md](docs/providers.md) | Forge, workflow, harness and model-backend axes, and writing your own |
| [ci.md](docs/ci.md) | What push access grants on your CI host, and how to bound it |
| [remote-access.md](docs/remote-access.md) | Picking and verifying a bind address, and the SSH and web front doors |
| [development.md](docs/development.md) | The file map, the test suites, running them |
+41
View File
@@ -0,0 +1,41 @@
# Anthropic on API billing: a key rather than a subscription login. This is
# what most organisations put their people on.
#
# One backend for both places the key can live, because they are one
# arrangement. Claude Code's own console login creates a key and stores it as
# primaryApiKey in ~/.claude.json - the defaults below - and a key you hold in
# a file of your own is the same static credential read from somewhere else:
#
# [backend.anthropic-api-key]
# credential_file = /path/to/your/key
# credential_field =
#
# An empty credential_field means the whole file is the key.
#
# For a subscription login, see anthropic. That one is a genuinely different
# arrangement rather than a different location - a Bearer token that expires
# and is refreshed, against the same endpoint.
upstream = https://api.anthropic.com
proxied_domain = api.anthropic.com
api_style = anthropic
credential = static
# Anthropic's own header, and the key goes in bare - no scheme prefix. This is
# the pair the subscription backend does NOT use, which is the whole
# difference between the two: an OAuth token is a Bearer, an API key is
# x-api-key.
auth_header = x-api-key
auth_format = {token}
# The other slot, stripped so a placeholder cannot travel beside the injected
# key. Two answers to the same question and which one the API prefers is not
# dejima's to guess - and here the wrong answer is the placeholder.
strip_headers = authorization
# Defaulted to where Claude Code puts it, so the common case needs no
# configuration. dejima strips this field from the copy of ~/.claude.json it
# stages, so the container gets the surrounding config and none of the key.
keys = credential_file:path=~/.claude.json credential_field:?jsonfield=primaryApiKey
requires = credential_file
+45
View File
@@ -0,0 +1,45 @@
# Anthropic, on a subscription login: the OAuth credential Claude Code writes
# on the host.
#
# The refreshing kind. The token expires, so the proxy renews it against the
# endpoint below and writes the rotated one back to the host file - which is
# what keeps a host-side login working rather than being signed out by a
# sandbox run. For a console key on API billing, see anthropic-api-key.
upstream = https://api.anthropic.com
# What the in-container TLS terminator answers for, and what /etc/hosts pins to
# loopback. On the backend rather than the harness, because it is a property
# of the endpoint: the same harness reaches a different one when a different
# backend is selected, and the terminator has to follow the endpoint.
proxied_domain = api.anthropic.com
# The Messages API, which is not OpenAI-compatible. A harness pointed here has
# to speak it; one that cannot is refused at launch rather than sending
# well-formed requests in the wrong shape.
api_style = anthropic
credential = anthropic-oauth
oauth_endpoint = https://platform.claude.com/v1/oauth/token
oauth_client_id = 9d1c250a-e61b-44d9-88ed-5944d1962f5e
auth_header = Authorization
auth_format = Bearer {token}
# x-api-key must not travel beside the injected Authorization: two answers to
# the same question, and which one the API prefers is not dejima's to guess.
# The container only ever holds a placeholder, so what this prevents is a
# placeholder being the one that gets read.
strip_headers = x-api-key
# The API rejects an OAuth token without it. Merged into whatever the client
# already sent rather than replacing it, and left completely alone when the
# value is already there - see the proxy's comment on why re-joining a list
# that needs no change is itself a fingerprint.
ensure_headers = anthropic-beta=oauth-2025-04-20
# Claude Code's own credential file, which is where the OAuth blob lives. A
# path, never a secret: the file is read host-side per request and is not
# mounted into the container.
keys = credential_file:path=~/.claude/.credentials.json
requires = credential_file
+34
View File
@@ -0,0 +1,34 @@
# OpenRouter, an OpenAI-compatible gateway in front of many models.
#
# A hosted endpoint behind a static API key. The key stays on the host and is
# read per request, exactly as the Anthropic OAuth token is.
upstream = https://openrouter.ai/api/v1
# The name the in-container terminator answers for, and what /etc/hosts pins to
# loopback. The host alone: the path above is where requests are FORWARDED, and
# a hostname is what a certificate and a DNS pin are about.
proxied_domain = openrouter.ai
api_style = openai
# A key, not a login. Nothing to refresh and nothing to write back, so a 401
# here means the key is wrong rather than stale - which is why the proxy does
# not retry it.
credential = static
auth_header = Authorization
auth_format = Bearer {token}
# Both spellings an OpenAI-compatible client might reach for, so a placeholder
# cannot travel beside the injected Authorization. The container only ever
# holds a placeholder; what this prevents is the placeholder being the one the
# endpoint reads.
strip_headers = x-api-key api-key
# No default, and no assumed value: a key file's location is the operator's,
# and guessing at one would be dejima pointing at a file it has no reason to
# think exists. `requires` makes an unset one a refusal at launch rather than
# an authentication failure several layers down.
keys = credential_file:?path
requires = credential_file
+27
View File
@@ -0,0 +1,27 @@
# A model the operator serves: on the host, or somewhere on the sandbox's own
# network.
#
# The third arrangement, and the only one with no credential anywhere in it.
# Nothing to withhold means no proxy, no terminator and nothing to steal, which
# makes this the strongest posture dejima offers - see docs/security.md.
#
# WHERE it is belongs to the operator rather than to this file, which is why
# base_url is a config key and not a manifest constant. A local endpoint is
# also the one an operator is most likely to keep configured while using
# something else: it lives in [backend.self-hosted] and stays there when the
# selected backend changes.
credential = none
# No upstream and no proxied_domain: both describe an endpoint dejima stands in
# front of, and it stands in front of nothing here.
# OpenAI-compatible, which is what a self-served model almost always speaks -
# llama.cpp, vLLM, Ollama and LM Studio all expose that shape.
api_style = openai
# No default. Refusing an unset one at launch beats a session starting against
# an endpoint dejima picked, and there is no address worth guessing: whatever
# is at 127.0.0.1:8080 on somebody else's machine is not this operator's model.
keys = base_url:?url
requires = base_url
+900 -60
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -970,7 +970,11 @@ tcb_snapshot() {
# missed when that layer landed: a tool.conf names a tarball URL and
# the sha256 checked against it, and the host's image build installs
# what it points at as root.
for dir in bin hooks docker test forges workflows harnesses tools; do
#
# backends/ likewise: a backend.conf names the upstream every request
# is forwarded to and the host path the credential is read from, so an
# edit here redirects where a real credential is sent.
for dir in bin hooks docker test forges workflows harnesses tools backends; do
if [ -d "$ws/$dir" ]; then
while IFS= read -r -d '' f; do
stage_entry "tcb:${f#"$ws"/}" "$f"
+61 -1
View File
@@ -165,6 +165,30 @@ _config_keys() {
_config_extra_keys
}
# A key that used to exist and now lives somewhere else.
#
# "unknown key" is true and useless on its own: an operator who wrote a key
# dejima once accepted has no way to tell a typo from a move, and the answer
# is in a document they have no reason to think they need. A rename that
# ships without this costs every operator who had the old key the same search.
#
# Keep this to keys that dejima ITSELF moved. A key nobody ever accepted is a
# typo, and guessing at what it was meant to be is worse than saying nothing.
_config_moved_hint() { # <section> <key>
local section="$1" key="$2"
case "$section.$key" in
harness.*.base_url|harness.*base_url)
printf ' Where the model is belongs to the backend now, not the harness.\n' >&2
printf ' For one you serve yourself:\n\n' >&2
printf ' [harness.%s]\n backend = self-hosted\n\n' \
"${section#harness.}" >&2
printf ' [backend.self-hosted]\n base_url = ...\n\n' >&2
printf ' A hosted API is a backend of its own instead, which carries its own\n' >&2
printf ' address - see docs/providers.md, "Model backends".\n' >&2
;;
esac
}
config_root() {
printf '%s' "${DEJIMA_CONFIG_DIR:-$HOME/.config/dejima}"
}
@@ -371,6 +395,29 @@ _config_valid_value() {
identifier)
[[ "$value" =~ ^[A-Za-z0-9._-]+$ ]]
;;
modelid)
# A model's name at whatever endpoint serves it, which is not an
# identifier: every hosted catalogue namespaces by vendor with a
# slash (`z-ai/glm-5.3`) and several qualify a variant with a colon
# (`deepseek/deepseek-r1:free`, `llama3:8b`). Typed `identifier`,
# not one OpenRouter id could be configured at all, so the backend
# this harness exists to reach was unusable.
#
# Still narrow, and for the same reason `path` is: this reaches an
# agent's configuration file. It is written there with jq --arg, so
# the JSON escaping is not what this is protecting - what it
# protects is that a value carrying a newline or a tab would split
# a record every reader of this config expects to be one line.
[[ "$value" =~ ^[A-Za-z0-9][A-Za-z0-9._:/-]*$ ]]
;;
jsonfield)
# Which value inside a credentials file is the key: dot-separated
# object names, nothing else. No array indices and no jq syntax,
# because the credential proxy walks these segments itself and a
# value that could name an expression would be one that could name
# a computation.
[[ "$value" =~ ^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$ ]]
;;
text)
# A sentence rather than a token - a person's name, an email
# address, a one-line description. An identifier has no room for a
@@ -524,10 +571,23 @@ _config_parse() {
_config_trim_into value "${line#*=}"
if ! _config_type_into type "$section.$key"; then
echo "unknown key in $conf: [$section] $key" >&2
_config_moved_hint "$section" "$key"
return 1
fi
if ! _config_valid_value "$type" "$value"; then
echo "invalid value for [$section] $key in $conf: $value" >&2
# The value is echoed so the operator can see what was wrong with
# it, except for the userinfo of a URL. A url-typed value is the
# one kind here that carries a credential - ntfy takes its
# authentication as https://<user>:<token>@<host>/<topic> - and a
# value only reaches this line by being malformed, so the mistake
# would be what wrote the token into a terminal or a journal.
# Everything after the @ is kept: the host and path are what make
# the message useful.
local shown="$value"
case "$type:$value" in
*url:*://*@*) shown="${value%%://*}://<redacted>@${value#*@}" ;;
esac
echo "invalid value for [$section] $key in $conf: $shown" >&2
return 1
fi
# Last assignment wins, which is why this overwrites rather than
@@ -1,12 +1,24 @@
#!/usr/bin/env python3
# Host-side credential-injecting proxy for sandboxed Claude Code sessions.
# Host-side credential-injecting proxy for sandboxed sessions.
#
# The container only ever holds a placeholder token; this proxy (launched on
# the host by bin/dejima, reached via a bind-mounted unix socket and
# an in-container loopback forwarder) strips the placeholder and injects the
# real OAuth access token from ~/.claude/.credentials.json, so the credential
# itself never enters the container. It also owns token refresh: rotated
# tokens are written back to the real host file atomically, keeping
# host-side sessions logged in. See docs/security.md.
# the host by bin/dejima, reached via a bind-mounted unix socket and an
# in-container loopback forwarder) strips the placeholder and injects the real
# credential, so the credential itself never enters the container. See
# docs/security.md.
#
# NOTHING HERE NAMES A BACKEND. Which upstream, which credential, how it is
# read, which header carries it and what shape that header takes are all
# arguments (#171). The Anthropic arrangement this started as is one set of
# those arguments and no longer a special case in the code, because the moment
# a second backend exists, a proxy that knows one backend's name has to grow
# a branch for every other.
#
# Two credential kinds, and the difference is refresh. A static key is read and
# sent. An OAuth credential expires, so it is refreshed against an endpoint and
# the rotated token written back to the host file atomically - which is what
# keeps a host-side session logged in rather than being signed out by a sandbox
# run. A backend says which kind it has; this decides nothing on its own.
import argparse
import http.client
import json
@@ -19,8 +31,6 @@ import time
from http.server import BaseHTTPRequestHandler
from urllib.parse import urlsplit
OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
OAUTH_BETA = "oauth-2025-04-20"
# Hop-by-hop headers describe the connection to this proxy rather than the
# request, so they stop here (RFC 9110 7.6.1). Host is deliberately not in
# this set: it has to be rewritten for the upstream, and is replaced in place
@@ -30,28 +40,106 @@ HOP_BY_HOP = {
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
"te", "trailer", "trailers", "transfer-encoding", "upgrade",
}
# Casing for the few headers this proxy has to supply when the client sent
# none of its own. Everything the client did send keeps the client's casing.
# Casing for the headers this proxy has to supply when the client sent none of
# its own. Everything the client did send keeps the client's casing; these are
# only consulted for a header being added rather than replaced. The auth and
# ensure-header names are added to this at startup, in the spelling the
# backend declared, for the same reason.
SUPPLIED_NAMES = {
"host": "Host",
"authorization": "Authorization",
"anthropic-beta": "anthropic-beta",
"content-length": "Content-Length",
}
class Credentials:
def __init__(self, path, oauth_endpoint, margin):
class StaticCredential:
"""A key in a file, sent as-is. No expiry, so nothing to refresh."""
# Trailing newline stripped, because a key file is written by an editor or
# a `>` redirection as often as by a tool, and a newline inside a header
# value is not a cosmetic difference: it would either be rejected or split
# the header block. Refused rather than trimmed if anything is left that
# cannot appear in a header, since a key that needs sanitising is a key
# that was pasted wrong, and guessing at it would send a mangled
# credential upstream and report the backend's rejection as the fault.
# A field, when the key is one value inside a document rather than the
# whole file. Claude Code signed in with a console API token keeps its key
# as primaryApiKey in ~/.claude.json, beside unrelated app state, so a
# backend has to be able to say which value is the credential.
def __init__(self, path, field=None):
self.path = path
self.field = field
def token(self):
with open(self.path) as f:
raw = f.read()
if self.field is None:
value = raw.strip()
else:
try:
doc = json.loads(raw)
except ValueError as e:
raise RuntimeError("%s is not JSON: %s" % (self.path, e))
cur = doc
for seg in self.field.split("."):
if not isinstance(cur, dict) or seg not in cur:
raise RuntimeError(
"%s has no %s" % (self.path, self.field))
cur = cur[seg]
if not isinstance(cur, str):
raise RuntimeError(
"%s: %s is not a string" % (self.path, self.field))
value = cur.strip()
if not value:
raise RuntimeError("%s is empty" % self.path)
bad = [c for c in value if ord(c) < 0x20 or ord(c) == 0x7F]
if bad:
raise RuntimeError("%s contains a control character" % self.path)
return value
def refresh(self, stale_token):
# Nothing to refresh. A 401 from a static key means the key is wrong,
# and retrying with the same bytes would turn one rejection into two.
raise RuntimeError("this credential cannot be refreshed")
@property
def refreshable(self):
return False
def startup_check(self):
self.token()
class OAuthCredential:
def __init__(self, path, oauth_endpoint, client_id, margin):
self.path = path
self.oauth_endpoint = oauth_endpoint
self.client_id = client_id
self.margin = margin
self.lock = threading.Lock()
@property
def refreshable(self):
return True
def startup_check(self):
# A plain read, not token(): this is a startup sanity check and must
# not trigger a refresh (that belongs to the first real request, so an
# external refresher gets a chance to win first).
self._read()["claudeAiOauth"]["accessToken"]
expires = self._read()["claudeAiOauth"].get("refreshTokenExpiresAt")
if expires is not None and expires / 1000 < time.time():
# Concrete, not generic. This class reads Claude Code's own
# credential shape, so the remedy is a command the operator can
# run rather than an instruction to work out.
raise RuntimeError(
"the refresh token has expired - run `claude` on the host "
"and log in again first")
def _read(self):
with open(self.path) as f:
return json.load(f)
def access_token(self):
def token(self):
oauth = self._read()["claudeAiOauth"]
if oauth["expiresAt"] / 1000 - time.time() < self.margin:
self.refresh(stale_token=oauth["accessToken"])
@@ -75,7 +163,7 @@ class Credentials:
req = json.dumps({
"grant_type": "refresh_token",
"refresh_token": oauth["refreshToken"],
"client_id": OAUTH_CLIENT_ID,
"client_id": self.client_id,
})
conn.request("POST", u.path, body=req,
headers={"Content-Type": "application/json"})
@@ -121,6 +209,24 @@ class Credentials:
raise
class AuthShape:
"""How a backend wants its credential presented.
header/format are the injection; strip is what must not travel beside it;
ensure is a comma-list header that has to contain a value, used for the
Anthropic OAuth beta and general enough to need no name of its own here.
"""
def __init__(self, header, fmt, strip, ensure):
self.header = header
self.format = fmt
# The auth header is never also stripped: it is replaced, and listing
# it in both places would drop the injected value instead of sending
# it.
self.strip = frozenset(n.lower() for n in strip) - {header.lower()}
self.ensure = ensure
class BodyTooLarge(Exception):
"""A request body over the cap. Its own type, not ValueError: _read_body
raises ValueError for a genuinely malformed request (a chunk length that
@@ -135,7 +241,7 @@ class Handler(BaseHTTPRequestHandler):
pass # per-request lines would interleave with the session's own UI
def log_error(self, fmt, *args):
sys.stderr.write("dejima-anthropic-proxy: " + fmt % args + "\n")
sys.stderr.write("dejima-credential-proxy: " + fmt % args + "\n")
# Capped, because the client is the sandboxed session and this runs on the
# host as the operator's user. Every other session-controlled size in this
@@ -207,25 +313,34 @@ class Handler(BaseHTTPRequestHandler):
# which is re-emitted in the client's own position instead.
conn.putrequest(self.command, self.path,
skip_host=True, skip_accept_encoding=True)
auth = self.server.auth
supplied = {
"host": u.netloc,
"authorization": "Bearer " + token,
auth.header.lower(): auth.format.replace("{token}", token),
"content-length": str(len(body)),
}
# anthropic-beta is left completely alone when the OAuth value is
# already among the client's own - which it is in the tls transport,
# where Claude Code believes it holds an OAuth credential. Re-joining
# a list that needs no change would normalize the client's spacing,
# which is the same class of tell as the casing.
betas = [b.strip() for v in self.headers.get_all("anthropic-beta", [])
for b in v.split(",")]
if OAUTH_BETA not in betas:
first = self.headers.get("anthropic-beta")
supplied["anthropic-beta"] = first + "," + OAUTH_BETA if first else OAUTH_BETA
# An ensure-header is left completely alone when its value is already
# among the client's own - as anthropic-beta's is in the tls transport,
# where the harness believes it holds an OAuth credential. Re-joining a
# list that needs no change would normalize the client's spacing, which
# is the same class of tell as the casing.
for name, wanted in auth.ensure:
low = name.lower()
present = [v.strip() for header in self.headers.get_all(low, [])
for v in header.split(",")]
if wanted in present:
continue
first = self.headers.get(low)
supplied[low] = first + "," + wanted if first else wanted
seen = set()
for name, value in self.headers.items():
low = name.lower()
if low in HOP_BY_HOP or low == "x-api-key":
# The strip set is the backend's, not a fixed list. It exists so a
# header the client used for a credential cannot travel alongside
# the one this proxy injects: x-api-key beside an Authorization is
# two answers to the same question, and which one an API prefers is
# not this program's to guess.
if low in HOP_BY_HOP or low in auth.strip:
continue
if low in supplied:
# One replacement even for a repeated header, or the injected
@@ -236,6 +351,13 @@ class Handler(BaseHTTPRequestHandler):
value = supplied[low]
seen.add(low)
conn.putheader(name, value)
# Only names registered above are emitted, so a value put in `supplied`
# whose name is not in SUPPLIED_NAMES is computed and then silently
# dropped. That is deliberate - it is what keeps this pass from
# inventing a header the backend never declared - but it means the two
# have to be populated from the same place, which is why main() derives
# SUPPLIED_NAMES from the auth header and the ensure list rather than
# listing them.
for low, name in SUPPLIED_NAMES.items():
if low in supplied and low not in seen:
# Content-Length only when there is a body: a bodyless GET
@@ -257,19 +379,22 @@ class Handler(BaseHTTPRequestHandler):
return self._fail(413, "credential proxy: request body too large",
log_message="refused a request body: %s" % e)
try:
token = self.server.creds.access_token()
token = self.server.creds.token()
except Exception as e:
return self._fail(502, "credential proxy: could not load or refresh the "
"credential - see host terminal",
log_message="cannot load credentials: %s" % e)
try:
conn, resp = self._upstream(token, body)
if resp.status == 401:
if resp.status == 401 and self.server.creds.refreshable:
# Upstream disagrees with expiresAt - refresh once and retry.
# Only for a credential that can be refreshed: retrying a
# static key sends the same bytes and turns one rejection into
# two, with the second one looking like a flapping upstream.
resp.read()
conn.close()
self.server.creds.refresh(stale_token=token)
token = self.server.creds.access_token()
token = self.server.creds.token()
conn, resp = self._upstream(token, body)
except Exception as e:
return self._fail(502, "credential proxy: upstream request failed - "
@@ -315,9 +440,10 @@ class Handler(BaseHTTPRequestHandler):
class ProxyServer(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
daemon_threads = True
def __init__(self, socket_path, upstream, creds):
def __init__(self, socket_path, upstream, creds, auth):
self.upstream = upstream
self.creds = creds
self.auth = auth
super().__init__(socket_path, Handler)
def get_request(self):
@@ -345,34 +471,79 @@ def main():
p = argparse.ArgumentParser()
p.add_argument("--socket", required=True)
p.add_argument("--credentials", required=True)
p.add_argument("--upstream", default="https://api.anthropic.com")
p.add_argument("--oauth-endpoint", default="https://platform.claude.com/v1/oauth/token")
# No default for any of these, deliberately. A default upstream or a
# default credential kind would be one backend's arrangement wearing the
# name of every backend's, which is the shape #171 was filed about: the
# caller says which backend this is, and this program has no opinion.
p.add_argument("--upstream", required=True)
p.add_argument("--credential-kind", required=True,
choices=("static", "anthropic-oauth"))
# Which value in the credentials file is the key, for the static kind.
# Absent means the whole file is the key.
p.add_argument("--credential-field", default=None)
p.add_argument("--auth-header", default="Authorization")
# "{token}" rather than a scheme name, so a backend that wants something
# other than "Bearer x" - a bare key, a prefix of its own - needs no code
# here.
p.add_argument("--auth-format", default="Bearer {token}")
# Repeatable, and replacing rather than adding to a default: a backend
# states what must not travel beside its credential, and inheriting
# another backend's list would be a guess.
p.add_argument("--strip-header", action="append", default=[],
metavar="NAME")
p.add_argument("--ensure-header", action="append", default=[],
metavar="NAME=VALUE")
p.add_argument("--oauth-endpoint")
p.add_argument("--oauth-client-id")
p.add_argument("--refresh-margin", type=int, default=300)
args = p.parse_args()
creds = Credentials(args.credentials, args.oauth_endpoint, args.refresh_margin)
if "{token}" not in args.auth_format:
sys.stderr.write("dejima-credential-proxy: --auth-format must contain "
"{token}, or the credential is never sent\n")
return 2
ensure = []
for item in args.ensure_header:
if "=" not in item:
sys.stderr.write("dejima-credential-proxy: --ensure-header wants "
"NAME=VALUE, got %r\n" % item)
return 2
name, _, value = item.partition("=")
if not name or not value:
sys.stderr.write("dejima-credential-proxy: --ensure-header wants "
"NAME=VALUE, got %r\n" % item)
return 2
ensure.append((name, value))
if args.credential_kind == "anthropic-oauth":
if not args.oauth_endpoint or not args.oauth_client_id:
sys.stderr.write("dejima-credential-proxy: anthropic-oauth needs "
"--oauth-endpoint and --oauth-client-id\n")
return 2
creds = OAuthCredential(args.credentials, args.oauth_endpoint,
args.oauth_client_id, args.refresh_margin)
else:
creds = StaticCredential(args.credentials, args.credential_field)
try:
# A plain read, not access_token(): this is just a startup sanity
# check and must not trigger a refresh (that belongs to the first
# real request, so an external refresher gets a chance to win first).
creds._read()["claudeAiOauth"]["accessToken"]
creds.startup_check()
except Exception as e:
sys.stderr.write(
"dejima-anthropic-proxy: unusable credentials file %s: %s\n"
"dejima-credential-proxy: unusable credential %s: %s\n"
% (args.credentials, e))
return 1
expires = creds._read()["claudeAiOauth"].get("refreshTokenExpiresAt")
if expires is not None and expires / 1000 < time.time():
sys.stderr.write(
"dejima-anthropic-proxy: the refresh token in %s has "
"expired - run `claude` on the host and log in again first\n"
% args.credentials)
return 1
auth = AuthShape(args.auth_header, args.auth_format,
args.strip_header, ensure)
# In the spelling the backend declared, for headers this has to add
# rather than replace. A client that sent its own keeps its own casing.
SUPPLIED_NAMES[auth.header.lower()] = auth.header
for name, _ in ensure:
SUPPLIED_NAMES.setdefault(name.lower(), name)
if os.path.exists(args.socket):
os.unlink(args.socket)
server = ProxyServer(args.socket, urlsplit(args.upstream), creds)
server = ProxyServer(args.socket, urlsplit(args.upstream), creds, auth)
try:
server.serve_forever()
except KeyboardInterrupt:
+367
View File
@@ -0,0 +1,367 @@
#!/usr/bin/env python3
# Finds credential-shaped values in the host files dejima copies into a
# container, and prints what it found WITHOUT printing any value.
#
# This exists because the alternative does not scale. Two leaks were found by
# hand in one week - a console API key in ~/.claude.json, credential helpers
# in ~/.gitconfig - and both fixes were denylists: they encode exactly the
# knowledge dejima did not have the week before. A harness's authentication is
# not a stable interface, so the next field added leaks silently unless
# something is looking (#291).
#
# NOTHING HERE DECIDES ANYTHING. It classifies and reports; bin/dejima asks
# the operator and applies the answer. Keeping the judgement out of here is
# what lets the same classifier run in a test without a terminal.
#
# THE ONE INVARIANT: a credential value never leaves this program. Not in a
# finding, not in an error, not in a traceback. What a caller gets is a
# redacted path and a shape name - enough to report a bug to whoever ships the
# harness, and useless to anyone who intercepts it.
#
# Which is why the caller never sees a real path either. In a gitconfig the
# userinfo sits in the SECTION HEADER, so `url.https://me:PAT@github.com/` is
# a path that IS a credential. Findings carry a redacted path for a human and
# an opaque id for matching, and --scrub takes ids back - so real paths stay
# inside this program, which is the only place that has to hold them.
import argparse
import hashlib
import json
import math
import re
import sys
# Anything that looks like credentials in a URL, wherever it appears.
USERINFO = re.compile(r"(?P<scheme>[a-z][a-z0-9+.-]*://)(?P<user>[^/@\s:]*):"
r"[^/@\s]+@")
def redact(text):
"""A path an operator can recognise, with any embedded secret removed."""
return USERINFO.sub(lambda m: "%s%s:***@" % (m.group("scheme"),
m.group("user")), text)
def finding_id(label, path):
"""Stable across runs, and reveals nothing about what it identifies.
Keyed on the real path, so a decision survives the credential itself
rotating - the operator answered about a field, not about a value."""
h = hashlib.sha256(("%s\t%s" % (label, path)).encode("utf-8"))
return h.hexdigest()[:16]
# Vendor prefixes, which are worth naming separately from the heuristics: a
# match here is not a guess, and the shape name tells an operator exactly what
# they are looking at without showing it to them.
KNOWN_SHAPES = (
("anthropic-api-key", re.compile(r"sk-ant-")),
("openrouter-key", re.compile(r"sk-or-")),
("openai-key", re.compile(r"^sk-[A-Za-z0-9]{20,}$")),
("github-token", re.compile(r"(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{8,}|github_pat_")),
("slack-token", re.compile(r"xox[baprs]-")),
("aws-access-key-id", re.compile(r"^AKIA[0-9A-Z]{16}$")),
("google-api-key", re.compile(r"^AIza[0-9A-Za-z_-]{35}$")),
("jwt", re.compile(r"^eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.")),
("private-key-pem", re.compile(r"^-----BEGIN [A-Z ]*PRIVATE KEY-----")),
("auth-header-value", re.compile(r"(?i)(^|:\s*)(basic|bearer)\s+\S{8,}")),
("url-userinfo", re.compile(r"^[a-z][a-z0-9+.-]*://[^/@\s]+:[^/@\s]+@")),
)
# Names that say "this is a secret", matched as WHOLE WORDS rather than as
# substrings. Substring matching put `auth` inside `oauthAccount` and flagged
# every field of the signed-in account's description, and `tengu_mcp_local_
# oauth_blocked_hosts` along with them.
SUSPECT_WORDS = frozenset((
"key", "apikey", "token", "secret", "secrets", "credential", "credentials",
"password", "passwd", "auth", "authorization", "bearer", "cookie",
"signature", "privatekey",
))
# Secrets are drawn from a restricted alphabet and contain no whitespace:
# base64, hex, base58, or a vendor's own. Prose is not, which is the whole
# discriminator - English text scores 4.0-4.5 bits per character by this
# measure, so entropy ALONE flags every sentence in a config file.
SECRETISH = re.compile(r"^[A-Za-z0-9+/=_.~-]+$")
def words(name):
"""Splits an identifier into lowercase words: camelCase, snake_case and
kebab-case all reduce to the same thing."""
name = re.sub(r"\[\d+\]$", "", name)
parts = re.split(r"[^A-Za-z0-9]+", name)
out = []
for part in parts:
out.extend(w.lower() for w in
re.findall(r"[A-Z]+(?![a-z])|[A-Z][a-z0-9]*|[a-z0-9]+", part))
return out
def suspect_name(path):
"""The leaf, plus its immediate parent.
The parent matters for a gitconfig, where `credential.helper` is a
credential because of the section it sits in. Anything further up is too
broad: a whole subtree named after a feature flag about oauth is not a
subtree of credentials."""
segments = path.split(".")
for seg in segments[-2:]:
if SUSPECT_WORDS & set(words(seg)):
return True
return False
# Shapes that look like secrets to an entropy test and are not. Checked before
# entropy, so a UUID machine id does not cost an operator a prompt.
NOT_SECRET = (
re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"), # uuid
re.compile(r"^[a-z]+://"), # url
re.compile(r"^/"), # absolute path
re.compile(r"^~?/"), # home path
re.compile(r"^\d{4}-\d{2}-\d{2}"), # timestamp
re.compile(r"^[0-9.]+$"), # version/number
)
MIN_ENTROPY_LEN = 32
MIN_ENTROPY_BITS = 4.0
def shannon(value):
if not value:
return 0.0
counts = {}
for ch in value:
counts[ch] = counts.get(ch, 0) + 1
n = float(len(value))
return -sum((c / n) * math.log(c / n, 2) for c in counts.values())
def classify(path, value):
"""Returns (verdict, shape) with verdict in credential|suspect|None.
Never returns any part of `value`.
"""
if not isinstance(value, str) or not value.strip():
return (None, None)
v = value.strip()
for shape, pattern in KNOWN_SHAPES:
if pattern.search(v):
return ("credential", shape)
if any(p.match(v) for p in NOT_SECRET):
return (None, None)
# Both remaining rules require a value that could BE a secret: one token,
# restricted alphabet, no whitespace. Without that, a field called
# "authDescription" holding a sentence is a finding.
if not SECRETISH.match(v):
return (None, None)
if suspect_name(path) and len(v) >= 8:
return ("suspect", "named-field")
if len(v) >= MIN_ENTROPY_LEN and shannon(v) >= MIN_ENTROPY_BITS:
return ("suspect", "high-entropy")
return (None, None)
def walk_json(doc, prefix=""):
if isinstance(doc, dict):
for k, v in doc.items():
yield from walk_json(v, "%s.%s" % (prefix, k) if prefix else str(k))
elif isinstance(doc, list):
# Indexed, so a decision recorded against one element does not silently
# cover a different one later.
for i, v in enumerate(doc):
yield from walk_json(v, "%s[%d]" % (prefix, i))
else:
yield (prefix, doc)
def ini_logical_lines(text):
"""Yields (raw_lines, logical_text) with backslash continuations joined.
A gitconfig value may run across lines, and both reading and removing have
to treat the whole run as one thing. Reading it line by line finds a
credential under a key that does not exist - `password=...` on a
continuation of `helper = !echo \\` parses as its own key - and removing
one line of it leaves the rest behind."""
lines = text.splitlines(True)
i = 0
while i < len(lines):
raws = [lines[i]]
logical = lines[i].rstrip("\n")
while logical.rstrip().endswith("\\") and i + 1 < len(lines):
i += 1
raws.append(lines[i])
logical = logical.rstrip()[:-1] + lines[i].rstrip("\n")
yield (raws, logical.strip())
i += 1
def ini_entries(text):
"""Yields (raw_lines, kind, key, value) where kind is section|pair|other."""
section = ""
for raws, line in ini_logical_lines(text):
if not line or line.startswith("#") or line.startswith(";"):
yield (raws, "other", None, None)
continue
if line.startswith("[") and line.endswith("]"):
inner = line[1:-1].strip()
m = re.match(r'^(\S+)\s+"(.*)"$', inner)
if m:
section = "%s.%s" % (m.group(1), m.group(2))
# The subsection text is part of the key, and a URL with
# userinfo in it is a credential sitting in a section header.
yield (raws, "section", section, m.group(2))
else:
section = inner
yield (raws, "section", section, None)
continue
if "=" not in line:
yield (raws, "other", None, None)
continue
k, _, v = line.partition("=")
key = "%s.%s" % (section, k.strip()) if section else k.strip()
yield (raws, "pair", key, v.strip())
def walk_ini(text):
for _raws, kind, key, value in ini_entries(text):
if kind in ("section", "pair") and value is not None:
yield (key, value)
def delete_json_path(doc, path):
# Mirrors walk_json's construction exactly, including list indices.
parts = re.findall(r"[^.\[\]]+|\[\d+\]", path)
cur = doc
for part in parts[:-1]:
cur = cur[int(part[1:-1])] if part.startswith("[") else cur[part]
last = parts[-1]
if last.startswith("["):
del cur[int(last[1:-1])]
else:
del cur[last]
def drop_ini_paths(text, paths):
"""Removes whole sections for a section-header finding, whole logical
lines otherwise. Line-based rather than via a parser, so a file this
cannot fully model is changed only where it matched - but a match takes
every line of the value with it."""
drop = set(paths)
out = []
in_dropped_section = False
for raws, kind, key, _value in ini_entries(text):
if kind == "section":
# A finding on the header itself takes the section with it: the
# secret is the header, and the keys under it belong to it.
in_dropped_section = key in drop
if in_dropped_section:
continue
out.extend(raws)
continue
if in_dropped_section:
continue
if kind == "pair" and key in drop:
continue
out.extend(raws)
return "".join(out)
def load_list(path):
if not path:
return []
out = []
try:
with open(path) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#"):
out.append(line)
except OSError:
pass
return out
def main():
p = argparse.ArgumentParser()
p.add_argument("--file", required=True)
p.add_argument("--format", required=True, choices=("json", "ini"))
# What this file is called where an operator would recognise it, rather
# than the temp path of the copy being scanned.
p.add_argument("--label", required=True)
# Paths judged safe, and paths already decided. Both are plain lists of
# "<label>\t<path>" so bin/dejima can build them without a parser.
p.add_argument("--known-safe", default=None)
p.add_argument("--decided", default=None)
# Ids to remove from the file, in place. The caller never had the real
# paths, so this is the only way it can ask for one to go.
p.add_argument("--scrub", default=None)
args = p.parse_args()
try:
with open(args.file) as f:
text = f.read()
except OSError as e:
# The filename, never the contents.
print("cannot read %s: %s" % (args.label, e.strerror), file=sys.stderr)
return 2
if args.format == "json":
try:
pairs = list(walk_json(json.loads(text)))
except ValueError:
print("%s is not readable as JSON" % args.label, file=sys.stderr)
return 2
else:
pairs = list(walk_ini(text))
if args.scrub:
wanted = set(load_list(args.scrub))
drop = [path for path, value in pairs
if finding_id(args.label, path) in wanted]
if not drop:
return 0
if args.format == "json":
doc = json.loads(text)
for path in drop:
delete_json_path(doc, path)
out = json.dumps(doc, indent=2) + "\n"
else:
out = drop_ini_paths(text, drop)
with open(args.file, "w") as f:
f.write(out)
return 0
# Two different keys, deliberately.
#
# A judged-safe entry is a "<label>\t<path>" written by hand, and it
# covers the subtree beneath it - `customApiKeyResponses.approved` has to
# answer for `...approved[0]` and every sibling, or an array of the same
# judged-safe thing asks once per element.
#
# A decision is an opaque id and matches ONE path exactly. Broadening
# those the same way would let one answer about one field quietly cover
# fields the operator never saw.
safe = [e for e in load_list(args.known_safe) if "\t" in e]
decided = set(load_list(args.decided))
def judged_safe(entry):
for s_entry in safe:
if entry == s_entry or entry.startswith(s_entry + ".") \
or entry.startswith(s_entry + "["):
return True
return False
for path, value in pairs:
fid = finding_id(args.label, path)
if fid in decided or judged_safe("%s\t%s" % (args.label, path)):
continue
verdict, shape = classify(path, value)
if verdict:
# verdict, id, label, redacted path, shape. No value, and no
# length either - a length is a hint about a secret.
print("%s\t%s\t%s\t%s\t%s"
% (verdict, fid, args.label, redact(path), shape))
return 0
if __name__ == "__main__":
sys.exit(main())
+37
View File
@@ -0,0 +1,37 @@
# Findings judged safe, so the scanner does not ask about them.
#
# Every line is "<label>\t<path>", and every line needs a reason. This file is
# the allowlist half of #291: the scanner refuses to be quiet by default, and
# adding a line here is the deliberate act that makes it quiet about one
# thing. A line with no reason is a line nobody reviewed.
#
# Judge on what the value IS, not on whether leaking it would matter. "It is
# only an email address" is a decision about consequence; this file is for
# decisions about identity.
#
# An entry covers the subtree beneath it, so `a.b` answers for `a.b[0]` and
# `a.b.c`. That is what makes an array of the same judged-safe thing one line
# rather than one per element - and it is why a broad entry is a broad grant.
# Name the narrowest path that covers what you meant.
# Hashes of API keys the operator approved on the host, not the keys. Claude
# Code stores the last 20 characters, so it is a fragment rather than a
# credential - and dropping it re-prompts inside the sandbox for a decision
# already made outside it.
~/.claude.json customApiKeyResponses.approved
~/.claude.json customApiKeyResponses.rejected
# Installation identifiers. Not credentials: they authenticate nothing, and
# they are why a session's own telemetry and cache lookups match the host's.
~/.claude.json userID
~/.claude.json machineID
# The signed-in account's own description - address, organisation, roles.
# Identifying, and deliberately kept: a session's UI reflects the real account,
# which is what makes it recognisable as yours. It grants nothing on its own.
~/.claude.json oauthAccount.emailAddress
~/.claude.json oauthAccount.accountUuid
~/.claude.json oauthAccount.organizationUuid
~/.claude.json oauthAccount.organizationName
~/.claude.json oauthAccount.organizationRole
~/.claude.json oauthAccount.workspaceRole
+43 -2
View File
@@ -128,6 +128,30 @@ harness_plugin_load() {
fi
# shellcheck disable=SC1090
source "$plugin"
# Resolve this harness's key schema now, in the caller's own shell.
#
# A plugin reads its settings through harness_config_get, and its callers
# reach it from inside subshells - a process substitution, a pipeline. A
# subshell inherits this shell's variables, so warming the cache here means
# those never re-derive it; without this they each start cold, because a
# cache filled in a subshell dies with it.
#
# That is a correctness fix and not only a speed one (#284).
# dejima-conversation-ship re-resolves its transcript paths from a SIGTERM
# handler, and bash reports 128+n for commands completing while it is
# delivering a signal - measured, _config_valid_value returned 0 and its
# caller read 143. So a perfectly valid manifest was rejected inside the
# trap, "unknown config key" was printed, and the tail of the session's
# transcript went unshipped. Resolved before any signal is possible, the
# handler answers from cache and has nothing left to fail at.
#
# Failure is ignored on purpose: an unreadable manifest is the caller's to
# report when it asks for a value, in its own words, not this function's to
# turn into a plugin-load error.
if declare -F _provider_keys_field >/dev/null 2>&1; then
_provider_keys_field harness "$name" >/dev/null 2>&1 || true
fi
}
# Calls one hook of the loaded plugin, if it defines it. Absence is normal:
@@ -368,9 +392,26 @@ harness_config_dirs_load() {
DEJIMA_STAGED_CONFIG=()
# host path, and the path under $HOME it is installed at.
# `ensure` asks for a placeholder at that path in the container when the host
# has none, so a harness that needs a file to exist gets one without dejima
# creating it on the operator's machine.
#
# Two kinds, because empty is not always a valid file. `ensure` writes nothing;
# `ensure-json` writes `{}`, and settings.json needs that one - an empty file
# is not valid JSON, and Claude Code discards a settings.json it cannot parse
# wholesale, taking every permission, hook and plugin the operator configured
# with it.
#
# Opt-in per file rather than implied: an empty statusline.sh would be
# installed executable and run as the status line, so that one stays absent.
emit_staged_config() {
local src="$1" rel="$2"
local src="$1" rel="$2" ensure="${3:-}"
[ -n "$src" ] && [ -n "$rel" ] || return 0
case "$ensure" in
''|ensure|ensure-json) ;;
*) echo "Harness plugin emitted an invalid staged-config flag: $ensure" >&2
exit 1 ;;
esac
# The relative path becomes a location under $HOME inside the container.
# Anything that could climb out of it, or that is not relative at all, is
# a plugin bug rather than something to normalise quietly.
@@ -379,7 +420,7 @@ emit_staged_config() {
echo "Harness plugin emitted an invalid staged-config path: $rel" >&2
exit 1 ;;
esac
DEJIMA_STAGED_CONFIG+=("$src"$'\t'"$rel")
DEJIMA_STAGED_CONFIG+=("$src"$'\t'"$rel"$'\t'"$ensure")
}
harness_staged_config_load() {
+46 -2
View File
@@ -83,11 +83,36 @@ fi
# Refusing rather than sanitizing, because the config is operator-written:
# a value that can't be used as intended means they got something wrong, and
# this is the one place they say where their phone notifications go.
#
# The value is not echoed back whole. An ntfy server that wants credentials
# takes them in the URL - https://<user>:<token>@<host>/<topic>, which
# docs/remote-access.md documents - and a misconfiguration should not be the
# thing that writes that token into the journal, where it stays.
redacted_url() {
case "$1" in
*://*@*) printf '%s://<redacted>@%s' "${1%%://*}" "${1#*@}" ;;
*) printf '%s' "$1" ;;
esac
}
require_url() {
case "$2" in
http://*|https://*) ;;
*)
echo "$1 in $(config_path) must be an http:// or https:// URL: $2" >&2
echo "$1 in $(config_path) must be an http:// or https:// URL: $(redacted_url "$2")" >&2
exit 1
;;
esac
# The URL is handed to curl through a config file on stdin rather than as
# an argument (see the push below), and that format is line-oriented with
# its own quoting: a newline in the value would start a second directive,
# and a quote or a backslash would end or escape the string. The `url`
# config type's own character set already excludes all three, so this
# refuses a value that arrived some other way rather than sanitizing one
# that did - the same reading as the scheme check above.
case "$2" in
*[!A-Za-z0-9:/._~?=%@+-]*)
echo "$1 in $(config_path) has a character dejima will not send: $(redacted_url "$2")" >&2
exit 1
;;
esac
@@ -168,12 +193,31 @@ push() {
# stdout is discarded (ntfy answers with a JSON receipt); stderr is not.
# -sS exists precisely to keep transport errors on stderr, and under a
# systemd user unit that is the journal, which is where they belong.
#
# The URL goes in on stdin, not in argv. It is the one value here that can
# carry a credential, and every argument of every process on this host is
# readable by any local account through /proc/<pid>/cmdline - so a token in
# argv is a token published to the machine for as long as the request runs,
# and `ps` is enough to read it. Nothing else on this line is secret: the
# title, the body and the click URL are all composed from host data.
#
# This also retires the `-K/path` hazard the scheme check above was half
# of: a URL that is not a URL can no longer be read as an option, because
# it is no longer in the option list at all. Both checks stay anyway, and
# the second one is now what keeps this format from gaining a directive.
# Redirected from a process substitution rather than piped into. `printf |
# curl` is a pipeline under `set -o pipefail`, so a curl that exits before
# draining stdin leaves printf holding a closed pipe and the pipeline
# reports 141 - a "push failed" line for a push that succeeded. A
# redirection is not a pipeline and has no such status to inherit. It also
# keeps `--config -` in argv rather than a /dev/fd path, which is a fixed
# string a test can compare.
curl -fsS --max-time 10 --retry 2 \
-H "Title: $title" \
-H "Priority: default" \
"${click[@]}" \
-d "$body" \
-- "$NTFY_URL" >/dev/null \
--config - >/dev/null < <(printf 'url = "%s"\n' "$NTFY_URL") \
|| echo "dejima-notify-watch: push failed for $run_id/$event" >&2
}
+277 -38
View File
@@ -28,36 +28,65 @@ MAX_MANIFEST_LINES=200
# filling this on first use meant filling it again in every `$(...)` that
# reached it, which was a fork apiece and hundreds of them per launch.
_PROVIDER_ROOT_CACHE="$(cd "${BASH_SOURCE[0]%/*}/.." && pwd)"
provider_root() {
# Assigns rather than prints, so a caller reaching it in a loop does not pay a
# `$( )` fork per call. provider_root below is the printing form for everyone
# who wants a value in a substitution anyway.
_provider_root_into() {
if [ -n "${DEJIMA_PROVIDER_ROOT:-}" ]; then
printf '%s' "$DEJIMA_PROVIDER_ROOT"
printf -v "$1" '%s' "$DEJIMA_PROVIDER_ROOT"
return 0
fi
# Only the repo path is cached. The test hook is re-read every call, so a
# test that repoints it mid-process still gets the directory it just set.
[ -n "$_PROVIDER_ROOT_CACHE" ] || _PROVIDER_ROOT_CACHE="$(cd "${BASH_SOURCE[0]%/*}/.." && pwd)"
printf '%s' "$_PROVIDER_ROOT_CACHE"
printf -v "$1" '%s' "$_PROVIDER_ROOT_CACHE"
}
_provider_axis_dir() {
case "$1" in
forge) printf 'forges' ;;
workflow) printf 'workflows' ;;
harness) printf 'harnesses' ;;
provider_root() {
local _root
_provider_root_into _root
printf '%s' "$_root"
}
# Same assign-instead-of-print split as _provider_root_into, for the same
# reason: provider_dir is reached once per manifest read.
_provider_axis_dir_into() {
case "$2" in
forge) printf -v "$1" 'forges' ;;
workflow) printf -v "$1" 'workflows' ;;
harness) printf -v "$1" 'harnesses' ;;
# The model API a session talks to, and how its credential is
# presented. Selected per harness rather than in [core], because what
# it configures is the credential proxy rather than the shape of the
# work (#171).
#
# "backend" and not "provider", which is what the industry calls a
# model API: this repo already calls the axes themselves providers, and
# one word for both reads as one thing. A harness's own config keeps
# its own vocabulary - cline writes providers.json with a
# lastUsedProvider key, and bin/harness/cline.sh translates.
backend) printf -v "$1" 'backends' ;;
# Not an axis in the select-one sense the other three are: a launch
# enables zero or more tools, and none is the default. It is here
# because everything else about it is the same - a manifest that is
# data, validated by the same schema machinery, never sourced.
tool) printf 'tools' ;;
*) echo "unknown provider axis: $1" >&2; return 1 ;;
tool) printf -v "$1" 'tools' ;;
*) echo "unknown provider axis: $2" >&2; return 1 ;;
esac
}
_provider_axis_dir() {
local _sub
_provider_axis_dir_into _sub "$1" || return 1
printf '%s' "$_sub"
}
_provider_manifest_name() {
case "$1" in
forge) printf 'forge.conf' ;;
workflow) printf 'workflow.conf' ;;
harness) printf 'harness.conf' ;;
backend) printf 'backend.conf' ;;
tool) printf 'tool.conf' ;;
*) return 1 ;;
esac
@@ -72,6 +101,18 @@ valid_provider_name() {
[[ "$1" =~ ^[a-z0-9][a-z0-9-]*$ ]]
}
# `readlink -m` answers memoized per path, because the containment check below
# resolves the same axis directory once per provider and provider_dir is
# reached from inside each `$( manifest_get ... )`. What is cached is one
# path's resolution, not a verdict: every caller still runs the containment
# comparison and the -d check itself.
declare -gA _PROVIDER_REAL_CACHE=()
_provider_resolve_into() {
[ -n "${_PROVIDER_REAL_CACHE[$2]+set}" ] \
|| _PROVIDER_REAL_CACHE["$2"]="$(readlink -m "$2")"
printf -v "$1" '%s' "${_PROVIDER_REAL_CACHE[$2]}"
}
# Resolved once per root+axis+name. The two readlinks are the containment
# check, and it is called on every manifest read; keying the cache on the
# resolved root rather than just the name is what keeps a test that repoints
@@ -79,19 +120,19 @@ valid_provider_name() {
declare -gA _PROVIDER_DIR_CACHE=()
provider_dir() {
local axis="$1" name="$2" sub root axis_root dir_real id
sub="$(_provider_axis_dir "$axis")" || return 1
_provider_axis_dir_into sub "$axis" || return 1
if ! valid_provider_name "$name"; then
echo "invalid $axis name: $name" >&2
return 1
fi
root="$(provider_root)"
_provider_root_into root
id="$root:$axis:$name"
if [ -n "${_PROVIDER_DIR_CACHE[$id]+set}" ]; then
printf '%s' "${_PROVIDER_DIR_CACHE[$id]}"
return 0
fi
axis_root="$(readlink -m "$root/$sub")"
dir_real="$(readlink -m "$root/$sub/$name")"
_provider_resolve_into axis_root "$root/$sub"
_provider_resolve_into dir_real "$root/$sub/$name"
case "$dir_real/" in
"$axis_root"/*) ;;
*) echo "refusing $axis '$name': it resolves outside $axis_root" >&2
@@ -122,17 +163,28 @@ _config_extra_type_valid() {
esac
}
provider_list() {
local axis="$1" sub entry name
sub="$(_provider_axis_dir "$axis")" || return 1
for entry in "$(provider_root)/$sub"/*/; do
# Fills _PROVIDER_LIST_OUT rather than printing, so the warm-up at the bottom
# of this file can walk all three axes without a `$( )` fork apiece.
declare -ga _PROVIDER_LIST_OUT=()
_provider_list_into() {
local axis="$1" sub root entry name
_PROVIDER_LIST_OUT=()
_provider_axis_dir_into sub "$axis" || return 1
_provider_root_into root
for entry in "$root/$sub"/*/; do
[ -d "$entry" ] || continue
name="$(basename "$entry")"
name="${entry%/}"
name="${name##*/}"
valid_provider_name "$name" || continue
printf '%s\n' "$name"
_PROVIDER_LIST_OUT+=("$name")
done
}
provider_list() {
_provider_list_into "$1" || return 1
[ "${#_PROVIDER_LIST_OUT[@]}" -eq 0 ] || printf '%s\n' "${_PROVIDER_LIST_OUT[@]}"
}
# Manifest schema. Same three-column shape as the config table: key, type,
# default. Types are the config file's own, so one validator set covers both.
_manifest_table() {
@@ -151,6 +203,7 @@ issue_command ?command
merge_block ?command
repo_flags ?list:flag
repo_env ?identifier
repo_paths ?list:repopath
identity_keys ?list:identifier
disclose ?disclosures
keys ?list:keyspec
@@ -189,6 +242,54 @@ autonomy enum:commit|push-branch|open-pr commit
briefing ?relpath
EOF
;;
# credential = none is an endpoint that needs no credential - a model
# the operator serves. There is nothing to withhold, so no proxy runs
# and neither upstream nor proxied_domain applies: the session reaches
# it directly, and where it is comes from the operator's own
# [backend.<name>] base_url rather than from the manifest. Both are
# optional for that reason and bin/dejima requires them where they do
# apply.
#
# api_style is which dialect the endpoint speaks, and it is a property
# of the API rather than of any harness: Anthropic's Messages API and
# an OpenAI-compatible one are different wire formats, so a harness
# that can only speak one has to know which it is being pointed at
# before it writes its own configuration.
#
# A model API and how its credential is presented. Every field here
# is an argument bin/dejima-credential-proxy already takes (#171):
# this axis is what turns "the caller states the arrangement" into
# "a file states it", so adding a backend is adding a directory.
#
# Nothing here is a secret. A backend declares WHERE its credential
# is read from, never what it is - the file it names is read
# host-side per request and never mounted.
#
# WHERE a credential sits is the operator's, so credential_field is
# a `keys` entry rather than a manifest key. A key is not always the
# whole file - Claude Code's console login keeps its key as
# primaryApiKey in ~/.claude.json, beside unrelated app state - and
# the same static arrangement serves a bare key file with the field
# cleared. Two placements of one credential, not two backends.
backend) cat <<'EOF'
upstream ?url
proxied_domain ?hostname
api_style enum:anthropic|openai
credential enum:static|anthropic-oauth|none
auth_header identifier Authorization
auth_format authformat Bearer {token}
strip_headers ?list:identifier
ensure_headers ?list:headerpair
oauth_endpoint ?url
oauth_client_id ?identifier
keys ?list:keyspec
requires ?list:identifier
EOF
;;
# api_styles is which of those dialects this harness can speak. A
# launch pairing it with a backend it has no client for is refused at
# launch rather than failing later with well-formed requests in the
# wrong shape.
harness) cat <<'EOF'
install ?depspec
image_dirs ?list:homepath
@@ -197,8 +298,8 @@ image_env ?list:envpair
command identifier claude
command_path ?path
api_domains ?list:hostname
proxied_api_domain ?hostname
guards ?list:identifier
api_styles ?list:identifier
briefing_args ?args
briefing_path ?homepath
no_confirm_args ?args
@@ -207,6 +308,7 @@ keys ?list:keyspec
resume_command ?template
transcript_update enum:append|rewrite append
briefing ?relpath
assumed_backend ?provider:backend
EOF
;;
*) return 1 ;;
@@ -303,6 +405,42 @@ _provider_valid_value() {
# subcommand or a value.
[[ "$value" =~ ^--?[A-Za-z0-9][A-Za-z0-9-]*$ ]]
;;
repopath)
# A repository named in a path rather than in a flag:
# `<subcommand>:<first path segment>`, as in `api:repos` for
# `gh api repos/<owner>/<name>/...`.
#
# Scoped to a subcommand because the alternative is comparing every
# positional argument that happens to contain two slashes, and a
# false refusal on an issue title is worse than the miss - this is
# a fast-fail, so a wrong yes costs an honest agent a working
# command. The guard applies it only when the subcommand is argv's
# first word.
case "$value" in *:*) ;; *) return 1 ;; esac
local sub="${value%%:*}" seg="${value#*:}"
[[ "$sub" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]] || return 1
[[ "$seg" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]]
;;
authformat)
# How the credential is written into its header. "{token}" rather
# than a scheme name, so a provider wanting a bare key or a prefix
# of its own needs no code - and required, because a format
# without it produces a proxy that starts, answers, and sends
# every request unauthenticated. The proxy checks this too; a
# manifest that cannot be loaded is the better place to find out.
case "$value" in *"{token}"*) ;; *) return 1 ;; esac
_provider_valid_value text "$value"
;;
headerpair)
# NAME=VALUE for ensure_headers, where NAME is a header rather
# than an environment variable - lower case and dashes are the
# norm (anthropic-beta), which is exactly what envpair refuses.
case "$value" in *=*) ;; *) return 1 ;; esac
local hname="${value%%=*}" hvalue="${value#*=}"
[ -n "$hname" ] && [ -n "$hvalue" ] || return 1
_provider_valid_value identifier "$hname" || return 1
_provider_valid_value identifier "$hvalue"
;;
relpath)
# Joined to a provider directory, so no absolute paths and no
# traversal - a manifest may only name a file it ships.
@@ -496,12 +634,45 @@ manifest_validate() {
#
# Failures are ignored: a missing or unreadable directory is not this loop's
# to report, and provider_dir refuses it again when something asks.
for _axis in forge workflow harness; do
for _name in $(provider_list "$_axis" 2>/dev/null); do
provider_dir "$_axis" "$_name" >/dev/null 2>&1 || true
#
# One `readlink -m` for every path rather than two per provider. It takes
# several operands and prints one resolved path per line, so the warm-up costs
# a single process instead of one per directory - which is most of what
# sourcing this file costs, and is paid by every `bin/dejima` invocation and
# every test that drives the library in a fresh shell. The batch only fills
# the resolution cache; provider_dir still does its own containment check
# against what it finds there.
_provider_warm_dirs() {
local axis sub root name path i=0
local -a axes=() names=() paths=() lines=()
_provider_root_into root
for axis in forge workflow harness backend; do
_provider_axis_dir_into sub "$axis" || continue
paths+=("$root/$sub")
_provider_list_into "$axis" || continue
for name in "${_PROVIDER_LIST_OUT[@]}"; do
axes+=("$axis")
names+=("$name")
paths+=("$root/$sub/$name")
done
done
done
unset _axis _name
mapfile -t lines < <(readlink -m -- "${paths[@]}" 2>/dev/null)
# A path containing a newline would desync the one-line-per-operand
# pairing. Nothing shipped can produce one - names are validated and the
# root comes from `pwd` - but a mismatched count means the pairing cannot
# be trusted, so the batch is dropped rather than cached wrong. provider_dir
# then resolves each path itself, exactly as it would have.
if [ "${#lines[@]}" -eq "${#paths[@]}" ]; then
for path in "${paths[@]}"; do
_PROVIDER_REAL_CACHE["$path"]="${lines[$i]}"
i=$((i + 1))
done
fi
for i in "${!names[@]}"; do
provider_dir "${axes[$i]}" "${names[$i]}" >/dev/null 2>&1 || true
done
}
_provider_warm_dirs
_manifest_parse() {
local axis="$1" name="$2" dir="$3" id="$4" file line key value type line_count
@@ -596,13 +767,44 @@ manifest_get() {
# `wc -l`, both to avoid a subshell/external-process fork per manifest line:
# this runs once per key in a [forge.<name>]/[harness.<name>] section, on
# every config_validate pass that has not already been answered from cache.
# Cached, and that is a correctness fix as well as a speed one (#284).
#
# Speed: this is reached once per key of a [forge.<name>]/[harness.<name>]
# section on every config_validate pass, and each miss re-reads the manifest
# and revalidates the whole keys list - a fork per element for the keyspec
# parse.
#
# Correctness: dejima-conversation-ship re-resolves its transcript paths from
# a SIGTERM handler, and bash clobbers $? with 128+n when it delivers a pending
# signal at a command boundary. Measured: _config_valid_value returned 0 and
# its caller read 143, so the `|| return 1` chain below failed for a manifest
# that is perfectly valid, the shipper reported "unknown config key" and the
# last lines of the session's transcript went unshipped. Anything that has
# already been resolved once is answered from here instead, so the signal
# handler does no work that a spurious status can fail.
#
# Keyed on the resolved directory rather than the name, like every other cache
# here, so two roots cannot answer for each other.
#
# SUCCESSES ONLY. A remembered refusal was the first shape of this and it is
# the wrong one: it turns one transient failure into a permanent one, and the
# symptom is indistinguishable from the bug this exists to fix - a lookup that
# worked at startup failing for the rest of the process. Nothing here is
# expensive enough to be worth remembering a "no" for.
declare -gA _PROVIDER_KEYS_CACHE=() _PROVIDER_KEYS_SEEN=()
_provider_keys_field() {
local axis="$1" name="$2" dir file line key value="" seen=0 type n=0
local axis="$1" name="$2" dir file line key value="" seen=0 type n=0 id
# 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
id="$axis:$dir"
if [ -n "${_PROVIDER_KEYS_SEEN[$id]+set}" ]; then
printf '%s' "${_PROVIDER_KEYS_CACHE[$id]}"
return 0
fi
file="$dir/$(_provider_manifest_name "$axis")"
[ -f "$file" ] || return 1
type="$(_manifest_type "$axis" keys)" || return 1
@@ -620,10 +822,41 @@ _provider_keys_field() {
seen=1
done < "$file"
[ "$seen" = 1 ] || value="$(_manifest_default "$axis" keys)"
# Core's own key on every backend section, appended rather than declared
# by each manifest. Whether to withhold a credential is dejima's question,
# not the backend author's, and a backend that forgot to declare it would
# silently lose the setting.
#
# No default, deliberately: unset means "whatever [credentials] proxy
# says", which is a third state that on/off cannot carry. See
# backend_proxy_enabled.
[ "$axis" != backend ] || value="${value:+$value }proxy:?bool"
_provider_valid_value "$type" "$value" || return 1
_PROVIDER_KEYS_CACHE[$id]="$value"
_PROVIDER_KEYS_SEEN[$id]=ok
printf '%s' "$value"
}
# Is the credential proxy on for this backend?
#
# [backend.<name>] proxy, falling back to [credentials] proxy. Per backend
# because the global switch has the wrong blast radius: an operator with one
# backend the proxy cannot serve had to disable withholding for every backend,
# including the ones it serves perfectly.
#
# Deliberately not config_get_bool, which reports an UNSET optional key as
# off. Unset here means "inherit", and reading it as "disabled" would turn the
# credential proxy off for every backend the moment this key existed.
backend_proxy_enabled() { # <backend-name>
local v=""
[ -n "${1:-}" ] || return 1
if config_type "backend.$1.proxy" >/dev/null 2>&1; then
v="$(config_get "backend.$1" proxy)"
fi
[ -n "$v" ] || v="$(config_get credentials proxy)"
[ "$v" = on ]
}
# A section is known only when a provider directory for its name actually
# exists - provider_dir carries the same name/containment checks
# _config_extra_type_valid already relies on for the provider: config types.
@@ -632,6 +865,7 @@ _config_extra_section_known() {
case "$section" in
forge.*) axis=forge; name="${section#forge.}" ;;
harness.*) axis=harness; name="${section#harness.}" ;;
backend.*) axis=backend; name="${section#backend.}" ;;
# An identity is the operator's own name for a set of credentials, so
# there is no directory to check it against - only that it is a name
# at all. Unlike a provider, an unused one is not a misconfiguration:
@@ -687,6 +921,7 @@ _config_extra_spec() {
case "$want" in
forge.*) axis=forge ;;
harness.*) axis=harness ;;
backend.*) axis=backend ;;
identity.*)
rest="${want#identity.}"
key="${rest#*.}"
@@ -872,7 +1107,7 @@ identity_validate() {
# "once" that a session could not reach, and a deprecation nobody sees
# until removal is not a deprecation.
if config_is_set core ssh_key; then
printf ' %s[core] ssh_key is deprecated%s - it is [identity.default] ssh_key\n' \
printf '\n %s[core] ssh_key is deprecated%s - it is [identity.default] ssh_key\n' \
"${C_WARN:-}" "${C_RESET:-}" >&2
printf ' under its old name.\n' >&2
if [ -n "$name" ]; then
@@ -888,8 +1123,10 @@ identity_validate() {
# force it answers with THAT identity's key - and printing it as the
# thing to move would name a key the operator is not being asked to
# touch.
printf ' [identity.default]\n ssh_key = %s\n\n' \
printf ' [identity.default]\n ssh_key = %s\n' \
"$(_config_get_from_file core ssh_key)" >&2
# Worth a pause before the build scrolls it away - see bin/dejima.
DEJIMA_STARTUP_NOTICE=1
fi
[ -n "$name" ] || return 0
@@ -1274,10 +1511,12 @@ the user's machine, with
What this means:
- The workspace directory is bind-mounted read-write and is the real
project. ~/shared is also a real, persistent host directory (not part of
the project) - use it to hand the user a file (a screenshot, a temp
script) without touching the project checkout, or to pick up something
they left there for you. Your harness keeps its own state on the host too,
project. ~/shared is also a real, persistent host directory (this
project's own, and not part of its checkout) - use it to hand the user a
file (a screenshot, a temp script) without touching the project checkout,
or to pick up something they left there for you. It outlives this session
and every session against this project shares it, so treat what is in it
as readable by all of them. Your harness keeps its own state on the host too,
so this session's transcript outlives the container - the work is
recoverable on the host with the command at the end of this
briefing.{{writable_mounts}} Everything else is disposable: installed system
@@ -1433,8 +1672,6 @@ If a task genuinely needs something else the sandbox can't provide -
broader network access than a domain or two, host-level or system changes,
GUI/hardware access - stop and explain clearly to the user what you need
and why, instead of trying to work around the firewall or sandbox yourself.
They can continue this exact session outside the container by running on
the host:
BRIEFING_EOF
}
@@ -1788,9 +2025,11 @@ briefing_compose() {
# The harness fragment closes the briefing: it supplies both the resume
# command closing_a has just promised and the sentence about where the
# session history it resumes from actually lives. Both are the harness's
# own facts - Claude Code resumes from a bind-mounted ~/.claude, Cline
# from a per-project state directory named in the command itself - and
# core stating either one meant stating Claude Code's.
# own facts, INCLUDING whether there is anything to say. Claude Code
# resumes from a bind-mounted ~/.claude and says so; Cline cannot be
# resumed outside dejima at all, and a core sentence promising it could
# was Claude Code's fact stated as everyone's - which left a Cline session
# able to send its operator somewhere that does not work.
#
# $requirements and $project_doc (the missing-dependency report and
# .dejima.md) stay hook-computed and hook-appended after this - they read
+186
View File
@@ -0,0 +1,186 @@
#!/bin/bash
# Where a project's ~/shared lives on the host, and what is in it.
#
# `[core] share_dir` holds one directory per project, and a launch mounts only
# that project's at ~/shared. This is the host side of that: where to put a
# file so the next session against a project finds it, what every project is
# holding, and how to move files left over from when share_dir was mounted
# whole.
#
# Nothing here is a security control. The boundary is the mount bin/dejima
# sets up; this is the operator's view of what is on the other side of it.
#
# Usage: dejima-share <path|list|adopt> [<path>]
set -euo pipefail
case "${1:-}" in
-h|--help)
cat <<'DEJIMA_SHARE_HELP'
dejima-share - the host side of a project's ~/shared
Usage: dejima-share <path|list|adopt> [path]
path [path] print the host directory a launch against `path` mounts at
~/shared, creating it if it does not exist yet - so a file
copied there is waiting for the next session
list every project's share directory, with its size and when it
was last written, plus anything left loose in share_dir
adopt [path] move whatever is loose in share_dir's top level into `path`'s
share directory, after showing it and asking
path defaults to the current directory.
A launch mounts <share_dir>/projects/<slug> at ~/shared, where slug is the
project's absolute path with every non-alphanumeric character replaced by a
dash - the same key the audit and epitaph stores use. Only that directory
crosses into the container, so one project's session cannot read another's
handoff files.
Files that were in share_dir's top level before this split are still there and
are no longer mounted anywhere. `adopt` is for moving them somewhere a session
will see them; it never overwrites.
Nothing here deletes anything by itself, and dejima never prunes share_dir. It
grows until you clear it out - `list` is what tells you what has gone stale.
See docs/configuration.md.
DEJIMA_SHARE_HELP
exit 0 ;;
esac
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# The provider library rather than the config one directly: [core] harness and
# the other provider-typed keys resolve against the manifests, so config
# validation refuses an ordinary config without it.
# shellcheck source=dejima-provider-lib.sh
source "$SCRIPT_DIR/dejima-provider-lib.sh"
# shellcheck source=dejima-slug-lib.sh
source "$SCRIPT_DIR/dejima-slug-lib.sh"
config_validate || exit 1
action="${1:-}"
[ -n "$action" ] || { echo "Usage: dejima-share <path|list|adopt> [<path>]" >&2; exit 1; }
SHARE_ROOT="$(config_get core share_dir)"
PROJECTS_ROOT="$(share_projects_root "$SHARE_ROOT")"
# A workspace argument becomes a mount path, so it is resolved the way
# bin/dejima resolves the one it is about to mount rather than used as typed.
# A path that does not exist is a typo, and answering it with a share
# directory nothing will ever mount is worse than refusing.
resolve_workspace() {
local given="${1:-$PWD}"
[ -d "$given" ] || { echo "no such directory: $given" >&2; exit 1; }
(cd "$given" && pwd)
}
# Whatever is in share_dir's top level that is not the per-project root. Every
# one of these was visible to every session before the split and is visible to
# none now.
loose_entries() {
find "$SHARE_ROOT" -mindepth 1 -maxdepth 1 ! -name projects -printf '%f\n' \
2>/dev/null | sort
}
case "$action" in
path)
ws="$(resolve_workspace "${2:-}")"
dir="$(share_dir_for "$SHARE_ROOT" "$ws")"
# Created here rather than only at launch, because the point of asking
# is usually to put something in it before the launch happens.
mkdir -p "$dir"
printf '%s\n' "$dir"
;;
list)
if [ ! -d "$PROJECTS_ROOT" ]; then
echo "No project has a share directory yet ($PROJECTS_ROOT does not exist)."
else
found=0
while IFS= read -r d; do
[ -n "$d" ] || continue
found=1
# du and date rather than a stat of the directory itself: the
# directory's own mtime changes when an entry is added or
# removed and not when one is written, which would report a
# share nobody has touched in months as fresh.
size="$(du -sh "$PROJECTS_ROOT/$d" 2>/dev/null | cut -f1)"
newest="$(find "$PROJECTS_ROOT/$d" -type f -printf '%T@\n' 2>/dev/null \
| sort -rn | head -1)"
if [ -n "$newest" ]; then
when="$(date -d "@${newest%.*}" '+%Y-%m-%d' 2>/dev/null || echo '?')"
else
when="empty"
fi
printf '%-10s %-12s %s\n' "${size:-?}" "$when" "$d"
done < <(find "$PROJECTS_ROOT" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' \
2>/dev/null | sort)
[ "$found" = 1 ] || echo "No project has a share directory yet."
fi
loose="$(loose_entries)" || true
if [ -n "$loose" ]; then
echo
echo "Loose in $SHARE_ROOT, mounted into no session:"
while IFS= read -r e; do
[ -n "$e" ] || continue
printf ' %s\n' "$e"
done <<< "$loose"
echo
echo "Move them with 'dejima-share adopt <project path>', or delete them."
fi
echo
echo "Nothing prunes this. Clear out what you no longer need: everything"
echo "in a project's share is readable by every future session against it."
;;
adopt)
ws="$(resolve_workspace "${2:-}")"
dest="$(share_dir_for "$SHARE_ROOT" "$ws")"
loose="$(loose_entries)" || true
if [ -z "$loose" ]; then
echo "Nothing loose in $SHARE_ROOT - every entry is already under projects/."
exit 0
fi
echo "Moving into $ws's share ($dest):"
while IFS= read -r e; do
[ -n "$e" ] || continue
if [ -e "$dest/$e" ]; then
printf ' %-30s SKIP - %s already has one\n' "$e" "$dest"
else
printf ' %s\n' "$e"
fi
done <<< "$loose"
echo
# One question, after showing everything, the way dejima-project
# approve asks - and read from stdin for the same reason it does, so a
# non-interactive caller answering no is the default rather than an
# error about /dev/tty. A move is not undoable by this tool, and the
# files are the operator's.
printf 'Move them? [y/N] '
read -r reply || reply=n
case "$reply" in
y|Y) ;;
*) echo "Nothing moved."; exit 0 ;;
esac
mkdir -p "$dest"
moved=0
while IFS= read -r e; do
[ -n "$e" ] || continue
# Never overwrites: a name that already exists in the destination
# is left where it is and reported, because the two files are both
# the operator's and this cannot know which one they meant.
if [ -e "$dest/$e" ]; then
echo "Skipped $e - $dest already has one" >&2
continue
fi
mv -n "$SHARE_ROOT/$e" "$dest/$e"
moved=$((moved + 1))
done <<< "$loose"
echo "Moved $moved entr$([ "$moved" = 1 ] && echo y || echo ies) into $dest"
;;
*)
echo "Usage: dejima-share <path|list|adopt> [<path>]" >&2
exit 1 ;;
esac
+19 -5
View File
@@ -1,12 +1,15 @@
# 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.
# Shared library: the ~/.claude/projects slug scheme, which slugs belong to a
# given workspace, and the share_dir layout keyed by that slug. Sourced by
# bin/dejima (to decide what to mount), bin/dejima-epitaph (to decide whose
# transcripts to keep) and bin/dejima-share. 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.
# does not expose is a hole that nothing reports. The share layout is here for
# the same reason: bin/dejima mounts a directory and dejima-share tells an
# operator where to put files in it, and those two naming different places
# would be an operator dropping a file where nothing will ever read it.
# 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
@@ -14,6 +17,17 @@
# runs inside the container, the other is a separate library).
ws_slug() { printf '%s' "$1" | sed 's/[^a-zA-Z0-9]/-/g'; }
# Every per-project share directory lives under this one subdirectory of
# `[core] share_dir` rather than directly in it, which is what makes "left
# over from before the split" a decidable question: anything at share_dir's
# top level other than this directory predates the layout, because nothing
# writes there any more. It is also the directory to mount over sshfs - one
# entry per project, with no loose files mixed in.
share_projects_root() { printf '%s/projects' "${1%/}"; }
# The host directory mounted at ~/shared for a launch against $2.
share_dir_for() { printf '%s/%s' "$(share_projects_root "$1")" "$(ws_slug "$2")"; }
# The slugs belonging to $1: its own, plus one per live git worktree nested
# underneath it. One per line, sorted, no duplicates.
#
+112 -122
View File
@@ -31,10 +31,16 @@
# 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.
# Where dejima's own hooks live inside the container. Baked into the image
# (see docker/Dockerfile) rather than reached through the ~/.claude/hooks
# mount, because that mount is the operator's configuration and dejima should
# not have to write to it to make its own sandbox work.
DEJIMA_HOOK_DIR=/usr/local/lib/dejima/hooks
harness_register_notify() {
local settings="$1" tmp
tmp="$(mktemp "${TMPDIR:-/tmp}/dejima-settings-remote.XXXXXX.json")"
if jq '.hooks.Stop = ((.hooks.Stop // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/dejima-notify.sh done"}]}])' \
if jq '.hooks.Stop = ((.hooks.Stop // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "'"$DEJIMA_HOOK_DIR"'/dejima-notify.sh done"}]}])' \
"$settings" > "$tmp"; then
mv "$tmp" "$settings"
else
@@ -107,30 +113,16 @@ harness_state_mounts() {
# 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"
local repo="$1" 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
# Registers nothing. Every hook dejima uses is baked into the image and
# registered per launch into the sandbox's own --settings overlay, so
# installing dejima writes nothing to the operator's Claude Code config.
#
# What is left is taking back what older versions of this function put
# there. The hook is kept rather than deleted because "what a harness
# needs on the host" is a real question a future one may answer
# differently; for Claude Code the answer is now nothing.
# 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
@@ -141,42 +133,18 @@ harness_register_guards() {
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
# No settings.json is created here either: a file dejima writes nothing
# into is a file it has no business bringing into existence.
[ -f "$settings" ] || return 0
# 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
# registration it was renamed from, so the merge guard's own registration
# was skipped and it 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" \
@@ -191,34 +159,34 @@ harness_register_guards() {
"$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
# An upgrade has to take back what an older install.sh put here, or the
# claim above is only true of a fresh one. Removed by name and only when
# the symlink points into THIS repo: an operator's own file at one of
# these names is theirs.
local moved dest
for moved in sandbox-context.sh git-push-guard.sh merge-guard.sh dejima-notify.sh \
deny-dangerous-bash.sh; do
dest="$HOME/.claude/hooks/$moved"
if [ -L "$dest" ] && [ "$(readlink -f "$dest")" = "$(readlink -f "$repo/hooks/$moved")" ]; then
echo "Removing: $moved from ~/.claude/hooks (the sandbox registers its own now)"
rm -f "$dest"
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"
# And its registration, matched the same endswith way _register does.
local ev
for ev in SessionStart PreToolUse Stop; do
if jq -e --arg s "/$moved" \
"[.hooks.${ev}[]?.hooks[]?.command // empty] | any(endswith(\$s))" \
"$settings" >/dev/null 2>&1; then
echo "Removing: $moved's $ev registration from $settings"
local mv_tmp; mv_tmp=$(mktemp)
jq --arg s "/$moved" \
".hooks.${ev} = ((.hooks.${ev} // [])
| map(.hooks = ((.hooks // []) | map(select((.command // \"\") | endswith(\$s) | not))))
| map(select((.hooks | length) > 0)))" \
"$settings" > "$mv_tmp" && mv "$mv_tmp" "$settings"
fi
done
done
echo
return 0
}
@@ -379,14 +347,11 @@ _plugin_version_mounts() {
harness_prepare_config() {
local overlay="$1" settings="$HOME/.claude/settings.json" settings_error
# install.sh normally creates settings.json, and many hosts have no
# CLAUDE.md at all. Created empty rather than left absent because
# harness_staged_config below emits both unconditionally and core skips
# staging a file that does not exist - so without this the session would
# silently start without them. statusline.sh is deliberately NOT created:
# an empty one would be staged executable and run as the status line.
[ -f "$settings" ] || echo '{}' > "$settings"
[ -f "$HOME/.claude/CLAUDE.md" ] || : > "$HOME/.claude/CLAUDE.md"
# Nothing is created on the host. A settings.json or CLAUDE.md the
# operator does not have is not one dejima should make on their machine so
# that it has something to copy - harness_staged_config asks for an empty
# one in the CONTAINER instead, which is where it is needed.
# Claude Code ignores a settings.json it cannot parse, wholesale and with
# only a startup warning, so a stray edit on the host costs the sandbox
@@ -395,7 +360,8 @@ harness_prepare_config() {
# symptom is an unexpected bypass-mode warning rather than anything
# mentioning settings. The file is staged verbatim, so nothing else parses
# it before the session reads it - check here, where the fix is at hand.
if ! settings_error="$(jq . "$settings" 2>&1 >/dev/null)"; then
# Absent is not unparseable: there is simply nothing to stage.
if [ -f "$settings" ] && ! settings_error="$(jq . "$settings" 2>&1 >/dev/null)"; then
echo "$settings is not valid JSON:" >&2
echo " $settings_error" >&2
echo "Claude Code would ignore it entirely and the sandbox would start with" >&2
@@ -410,7 +376,38 @@ harness_prepare_config() {
# --settings layer (the manifest's settings_args) above
# ~/.claude/settings.json, which holds only what the sandbox overrides;
# the operator's hooks, permissions and plugins still apply from there.
#
# And dejima's own hooks, registered HERE rather than in the operator's
# ~/.claude/settings.json. Claude Code merges this layer with theirs, so
# their hooks still run - proven by the one dejima deliberately leaves on
# the host, deny-dangerous-bash.sh, which fires inside a container while
# being registered only there.
#
# Registering them here is what lets install.sh stop writing to a config
# that is not dejima's. The scripts are baked into the image, so a session
# cannot unlink a guard the way it could one staged into a directory it
# owns.
jq -n '{sandbox: {enabled: false}}' > "$overlay"
_register_overlay_hook() { # <event> <matcher> <script>
local event="$1" matcher="$2" script="$3" tmp
tmp="$(mktemp "${TMPDIR:-/tmp}/dejima-overlay.XXXXXX.json")" || return 1
if jq --arg m "$matcher" --arg c "$DEJIMA_HOOK_DIR/$script" \
".hooks.${event} = ((.hooks.${event} // []) +
[{\"matcher\": \$m, \"hooks\": [{\"type\": \"command\", \"command\": \$c}]}])" \
"$overlay" > "$tmp"; then
mv "$tmp" "$overlay"
else
rm -f "$tmp"
echo "warning: could not register $script; it will not run this session." >&2
fi
}
# R3: without this a session is not told it is in a sandbox at all, which
# is why it is registered whether or not any guard is declared.
_register_overlay_hook SessionStart "startup|resume|clear|compact" sandbox-context.sh
harness_has_guard push-guard && _register_overlay_hook PreToolUse Bash git-push-guard.sh
harness_has_guard merge-guard && _register_overlay_hook PreToolUse Bash merge-guard.sh
harness_has_guard bash-deny && _register_overlay_hook PreToolUse Bash deny-dangerous-bash.sh
return 0
}
harness_staged_config() {
@@ -418,10 +415,13 @@ harness_staged_config() {
# and what a dangling symlink means, are core's to decide - filtering
# here would skip a dangling one silently, which is the case that must
# refuse rather than quietly leave the session without the file.
local f
for f in settings.json CLAUDE.md statusline.sh; do
emit_staged_config "$HOME/.claude/$f" ".claude/$f"
done
# settings.json and CLAUDE.md are asked to exist even when the host has
# none: Claude Code reads both, and an empty one reads the same as a file
# the operator never made. statusline.sh is not - an empty one would be
# installed executable and run as the status line.
emit_staged_config "$HOME/.claude/settings.json" ".claude/settings.json" ensure-json
emit_staged_config "$HOME/.claude/CLAUDE.md" ".claude/CLAUDE.md" ensure
emit_staged_config "$HOME/.claude/statusline.sh" ".claude/statusline.sh"
}
# Where this workspace's transcripts are, and which store key each belongs
@@ -601,35 +601,25 @@ harness_transform_transcript() {
' 2>/dev/null
}
# Which of the hooks install.sh registers are actually in the operator's
# settings, and which are not. Prints the missing ones, one per line, and
# returns 1 if there are any - so the launch can name what is not in force
# rather than assert that nothing is.
# harness_guards_registered is deliberately not defined.
#
# Every hook this harness would install is checked, not just the briefing:
# the realistic failure (install.sh never run with a harness set) is
# all-or-nothing, but a hand-edited settings.json missing one guard is a
# session quietly running without it, which is the case worth catching.
# It read the operator's own ~/.claude/settings.json and named any of dejima's
# hooks missing from it, because that is where install.sh used to put them and
# "install.sh never run with a harness set" was a real way to get a session
# with no briefing and no guards.
#
# dejima-notify.sh is deliberately absent: bin/dejima registers it per
# session into a throwaway layer, and it is never in the operator's file.
# Nothing is written there now: the hooks are baked into the image and
# registered per launch into dejima's own --settings overlay, so an absent
# registration in the operator's file is the correct state rather than a
# broken one. Left defined, it named all four as not in force on every launch
# of a host that had never run the old installer - and told the operator to
# run ./install.sh, which strips exactly those registrations, making the
# message permanent.
#
# Reads the operator's real settings rather than dejima's own layer, which
# is composed fresh each launch and always looks right.
harness_guards_registered() {
local settings="$HOME/.claude/settings.json" missing=0
_registered() {
[ -f "$settings" ] || return 1
jq -e --arg s "/$2" "[.hooks.$1[]?.hooks[]?.command // empty]
| any(endswith(\$s))" "$settings" >/dev/null 2>&1
}
_registered SessionStart sandbox-context.sh \
|| { printf 'the session briefing\n'; missing=1; }
harness_has_guard push-guard && { _registered PreToolUse git-push-guard.sh \
|| { printf 'push-guard\n'; missing=1; }; }
harness_has_guard merge-guard && { _registered PreToolUse merge-guard.sh \
|| { printf 'merge-guard\n'; missing=1; }; }
harness_has_guard bash-deny && { _registered PreToolUse deny-dangerous-bash.sh \
|| { printf 'bash-deny\n'; missing=1; }; }
return "$missing"
}
# There is nothing to put in its place. What the overlay carries is composed
# fresh each launch from the manifest, so reading it back would be asking
# dejima whether it did what it just did; which guards the harness cannot host
# at all is the manifest's, and harness_report_guards already says it.
#
# The hook itself stays in the plugin interface. What a harness needs wired on
# the host is a real question, and a future one may answer it differently.
+208 -69
View File
@@ -40,18 +40,17 @@ harness_briefing_state_dir() {
# Which endpoints a session may be pointed at, and why the answer is not "any".
#
# Absent base_url means Anthropic: Cline is configured with the real hostname
# and a placeholder key, and dejima's in-container terminator and host-side
# proxy supply the real credential, exactly as they do for Claude Code. The
# credential never enters the container.
# The selected backend says where the model is. A proxied one carries its own
# upstream and dejima's terminator stands in front of it, so the credential
# never enters the container. A backend with `credential = none` is a model
# the operator serves, carrying its own [backend.<name>] base_url, and it is
# reached directly because there is nothing to withhold.
#
# A base_url means a self-hosted endpoint, and it has to be one reachable
# without a credential - the Docker host, or something on the sandbox's own
# network. That restriction is R5 and not a network one: a hosted provider
# would need an API key inside the container, and dejima has no proxy that
# could hold one back on its behalf. An endpoint that needs no key has nothing
# to withhold, which is why an open-weight model served locally is supported
# today and api.openai.com is not.
# That second kind has to be an endpoint that genuinely needs no credential.
# The restriction is R5 and not a network one: a hosted API pointed at this way
# would need a key inside the container, and dejima has no proxy holding one
# for it. An endpoint that needs no key has nothing to withhold, which is why a
# model served locally is supported and api.openai.com reached this way is not.
#
# Two ways an endpoint qualifies, and neither is about the URL's syntax.
#
@@ -66,17 +65,15 @@ harness_briefing_state_dir() {
# what the container can reach at all, so an endpoint failing this check is
# one the session could not have connected to anyway. Requiring a literal
# address instead was too strict by half: a model server on someone's own
# network normally has a name, and refusing it made the local-endpoint path -
# the reason this harness is provider-agnostic at all - unusable for the
# ordinary case.
# network normally has a name, and refusing it made this path - the reason
# this harness is backend-agnostic at all - unusable for the ordinary case.
#
# What neither branch is doing is protecting the model credential. dejima
# writes a placeholder into providers.json on this path whatever the endpoint
# What this is not doing is protecting the model credential. dejima writes a
# placeholder into providers.json on every proxied path whatever the endpoint
# is, so no real credential enters the container either way. What this decides
# is whether the configuration can WORK: an endpoint that needs a key would
# get the placeholder and fail, and saying so at launch beats an auth error
# three layers down. Pointing it somewhere is the operator's call, bounded by
# an allowlist entry they made deliberately.
# is whether the configuration can WORK: an endpoint that needs a key would get
# a no-credential marker and fail, and saying so at launch beats an auth error
# three layers down.
_cline_host_local() {
local url="$1" host a b
host="${url#*://}"
@@ -130,7 +127,7 @@ _cline_host_allowlisted() {
return 1
}
# The provider configuration the session runs with, and the three conditions
# The backend configuration the session runs with, and the three conditions
# that make a launch wrong: no model, a credential mode with no path through,
# and an endpoint whose key dejima would have to put in the container.
#
@@ -146,7 +143,7 @@ _cline_host_allowlisted() {
# bounds where a repointed endpoint could reach. What rewriting buys is that a
# repointing cannot persist into the next session.
harness_prepare_config() {
local state model base_url settings
local state model settings
state="$(_cline_state_dir "$WORKSPACE_DIR")" || {
echo "harness.cline: state_dir is not set, so there is nowhere to keep" >&2
echo "this project's Cline state. Set it in [harness.cline]." >&2
@@ -162,61 +159,98 @@ harness_prepare_config() {
echo " [harness.cline]" >&2
echo " model = claude-haiku-4-5-20251001" >&2
echo "" >&2
echo "\`cline auth\` outside the sandbox lists what your provider offers." >&2
echo "\`cline auth\` outside the sandbox lists what your backend offers." >&2
return 1
fi
base_url="$(harness_config_get base_url)"
local ctx
ctx="$(harness_config_get context_window)"
# The Anthropic path is the credential proxy's path, and it only works in
# the shape that was actually tested: the proxy on, terminating TLS for
# api.anthropic.com in-container, with Cline configured for the real
# hostname and a placeholder key.
# WHERE THE MODEL IS is the backend's, and only the backend's. One
# setting, so there is nothing for a second one to disagree with.
#
# With the proxy off, Claude Code mounts the operator's real credential -
# a downgrade this repository documents and accepts. There is no
# equivalent here: the credential Cline would need is in the operator's
# own providers.json, which is exactly the file dejima declines to mount,
# and writing a placeholder anyway would produce an auth failure whose
# cause is three layers away. The loopback transport is the same story
# from the other end - it hands the harness a base URL and a token through
# the environment, which Cline does not read for provider selection
# A self-served endpoint is `backend = self-hosted`, carrying its own
# [backend.self-hosted] base_url. That placement is what lets an operator
# keep a local model configured while using a hosted API, and switch
# between them by changing one word.
local backend
backend="$(harness_config_get backend)"
if [ -z "$backend" ]; then
echo "harness.cline: no [harness.cline] backend is set, so nothing says" >&2
echo "where the model is." >&2
echo "" >&2
echo " [harness.cline]" >&2
echo " backend = openrouter a hosted API, key held on the host" >&2
echo " backend = self-hosted a model you serve, with no key at all" >&2
echo "" >&2
echo "There is deliberately no default: one dejima could change later would" >&2
echo "move your session to another endpoint without your config changing." >&2
return 1
fi
local kind base_url=""
kind="$(manifest_get backend "$backend" credential)" || return 1
if [ "$kind" = none ]; then
base_url="$(config_get "backend.$backend" base_url)" || return 1
fi
# The proxy is what keeps a credential out of the container, and it only
# works in the shape that was actually tested: the proxy on, terminating
# TLS in-container, with Cline configured for the endpoint's real hostname
# and a placeholder key.
#
# With the proxy off, claude-code mounts the operator's real credential - a
# downgrade this repository documents and accepts. There is no equivalent
# here: the credential Cline would need is in the operator's own
# providers.json, which is exactly the file dejima declines to mount, and
# writing a placeholder anyway would produce an auth failure whose cause is
# three layers away. The loopback transport is the same story from the
# other end - it hands the harness a base URL and a token through the
# environment, which Cline does not read for provider selection
# (CLINE_PROVIDER and CLINE_MODEL were tried and ignored).
#
# Both refuse rather than start something that will fail confusingly, and
# both have the same way out: point base_url at a model you serve.
if [ -z "$base_url" ]; then
# A backend with no credential is unaffected: there is nothing to withhold
# and no proxy in the path.
if [ "$kind" != none ]; then
local why=""
[ "$(config_get_bool credentials proxy)" = "1" ] \
|| why="[credentials] proxy is off"
backend_proxy_enabled "$backend" \
|| why="the proxy is off for this backend"
[ -n "$why" ] || [ "$(config_get credentials transport)" = "tls" ] \
|| why="[credentials] transport is loopback"
if [ -n "$why" ]; then
echo "harness.cline: $why, and cline has no way to reach Anthropic" >&2
echo "without the TLS terminator the proxy puts in front of it." >&2
echo "harness.cline: $why, and cline has no way to reach a proxied" >&2
echo "backend without the TLS terminator the proxy puts in front of it." >&2
echo "Its credential lives in the providers.json dejima declines to" >&2
echo "mount, so there is nothing to fall back to - unlike claude-code," >&2
echo "which mounts the real credential in that mode." >&2
echo "" >&2
echo "Either turn the proxy back on with the default tls transport," >&2
echo "or set [harness.cline] base_url to a model you serve yourself." >&2
echo "or select a backend you serve yourself." >&2
return 1
fi
fi
if [ -n "$base_url" ] && ! _cline_host_local "$base_url"; then
echo "harness.cline: refusing base_url $base_url." >&2
echo "It is not a host-local endpoint, so it would need an API key" >&2
echo "inside the container, and dejima has no proxy that can hold one" >&2
echo "back for that provider - the model credential would stop being" >&2
echo "something the sandbox never sees." >&2
# An endpoint reached with no credential has to be one that needs none, and
# that is an R5 check rather than a network rule: a hosted API pointed at
# this way would want a key inside the container, and the model credential
# would stop being something the sandbox never sees.
#
# Still here rather than in core because cline is the only harness with a
# direct path today. It belongs in core the moment a second one has one.
if [ "$kind" = none ] && ! _cline_host_local "$base_url"; then
echo "harness.cline: refusing [backend.$backend] base_url $base_url." >&2
echo "That backend needs no credential, so the endpoint has to be one" >&2
echo "that needs none: a model you serve. This is not host-local, so it" >&2
echo "would want a key inside the container." >&2
echo "" >&2
echo "Supported today: leave base_url unset for Anthropic through" >&2
echo "dejima's own credential proxy, or point it at a model you serve" >&2
echo "yourself, which needs no key. That means a loopback or RFC 1918" >&2
echo "address, or a host you have allowlisted:" >&2
echo "For a hosted API, select a backend for it instead - the key is then" >&2
echo "held on the host and injected per request:" >&2
echo "" >&2
echo " [harness.cline]" >&2
echo " backend = openrouter" >&2
echo "" >&2
echo "For one you serve, that means a loopback or RFC 1918 address, or a" >&2
echo "host you have allowlisted:" >&2
echo "" >&2
echo " dejima-allow --project <path> <host>" >&2
echo "" >&2
@@ -246,25 +280,45 @@ harness_prepare_config() {
local tmp
tmp="$(mktemp "$settings/.providers.XXXXXX")" || return 1
if [ -n "$base_url" ]; then
jq -n --arg m "$model" --arg u "$base_url" \
# Which of Cline's own provider types to write, from the selected
# backend's api_style rather than from a second setting an operator has
# to keep in step. Anthropic's Messages API and an OpenAI-compatible one
# are different wire formats, and Cline has a client for each.
local style endpoint key
style="$(manifest_get backend "$backend" api_style)" || return 1
if [ "$kind" = none ]; then
endpoint="$base_url"
key="dejima-no-credential-needed"
else
# The endpoint's REAL hostname, not a loopback one. The terminator
# answers for it inside the container and the host-side proxy holds the
# credential, which is what keeps the session indistinguishable from
# one talking to the API directly.
endpoint="$(manifest_get backend "$backend" upstream)" || return 1
# The placeholder is what Cline sends; the host-side proxy replaces it
# with the real credential on the way out. Same arrangement as Claude
# Code's faked .credentials.json, confirmed end to end against the API.
key="sandbox-credential-proxy-placeholder"
fi
if [ "$style" = anthropic ]; then
# Cline's native Anthropic client, which addresses the API by name and
# so needs no baseUrl: entrypoint.sh has already pointed that name at
# the terminator.
jq -n --arg m "$model" --arg k "$key" \
--arg t "$now" --arg c "$ctx" \
'{version: 1, lastUsedProvider: "anthropic", modes: {},
providers: {anthropic: {
settings: ({provider: "anthropic", model: $m, apiKey: $k}
+ (if $c == "" then {} else {contextWindow: ($c|tonumber)} end)),
updatedAt: $t, tokenSource: "manual"}}}' > "$tmp"
else
jq -n --arg m "$model" --arg u "$endpoint" --arg k "$key" \
--arg t "$now" --arg c "$ctx" \
'{version: 1, lastUsedProvider: "openai-compatible", modes: {},
providers: {"openai-compatible": {
settings: ({provider: "openai-compatible", model: $m,
baseUrl: $u, apiKey: "dejima-no-credential-needed"}
+ (if $c == "" then {} else {contextWindow: ($c|tonumber)} end)),
updatedAt: $t, tokenSource: "manual"}}}' > "$tmp"
else
# The placeholder is what Cline sends; the host-side proxy replaces it
# with the real credential on the way out. Same arrangement as Claude
# Code's faked .credentials.json, confirmed end to end against the API.
jq -n --arg m "$model" \
--arg t "$now" --arg c "$ctx" \
'{version: 1, lastUsedProvider: "anthropic", modes: {},
providers: {anthropic: {
settings: ({provider: "anthropic", model: $m,
apiKey: "sandbox-credential-proxy-placeholder"}
baseUrl: $u, apiKey: $k}
+ (if $c == "" then {} else {contextWindow: ($c|tonumber)} end)),
updatedAt: $t, tokenSource: "manual"}}}' > "$tmp"
fi || { rm -f "$tmp"; return 1; }
@@ -278,6 +332,91 @@ harness_prepare_config() {
# nothing inside the container has to be told where its state is - the source
# is keyed by dejima's workspace slug and only the target follows the harness,
# which is what emit_mount_at is for.
# The operator's own configuration, brought in from the host.
#
# Read-only, and that is the whole point rather than caution: everything here
# is either an instruction the agent follows or a program cline runs. A
# session that could rewrite them would be choosing what happens on the HOST
# the next time cline starts outside a container - persistence for a prompt
# injection, in files nobody re-reads before the next run.
#
# `scan` for the same reason claude-code's directories are scanned: on a Linux
# host these are routinely symlinks into a dotfiles checkout, and the target
# has to be mounted for the link to resolve inside the container. Core refuses
# a target that overlaps a guarded store.
#
# ~/.agents/rules is deliberately absent. It reads like the obvious companion
# to ~/.agents/skills and cline does not load it - checked by planting a file
# in it and one in ~/Documents/Cline/Rules and asking `cline config` which it
# had: only the second appeared. Mounting a directory the harness never reads
# would be a promise dejima cannot keep.
harness_config_dirs() {
emit_config_dir "$HOME/.agents/skills" ro scan
# Not confirmed loaded by that same probe, unlike skills. Mounted because
# it is the documented default for --hooks-dir and because the cost of
# being wrong is an empty directory, where the cost of leaving it out is a
# hook that silently does not run.
emit_config_dir "$HOME/.agents/hooks" ro scan
emit_config_dir "$HOME/.cline/hooks" ro scan
}
# AGENTS.md is instructions the agent follows, so a session that could write
# the host's copy would be choosing what the next session is told. Staged
# rather than mounted: the copy lands root-owned and read-only, and a file
# that is a symlink into a dotfiles checkout arrives as its real content
# rather than as a link the host can still swap.
harness_staged_config() {
emit_staged_config "$HOME/.agents/AGENTS.md" ".agents/AGENTS.md"
# Global rules, from both host locations into the one directory cline
# actually reads.
#
# cline loads global rules from Documents/Cline/Rules and nowhere else -
# ~/.agents/rules reads like its companion and is not read, checked by
# planting a file in each and asking `cline config` which it had. So
# ~/.agents/rules is supported by staging its files INTO the directory
# that is read, rather than by mounting it where it sits and having
# nothing load it.
#
# File by file rather than as a directory mount, because dejima writes its
# own briefing into that same directory: a read-only mount over it would
# leave nowhere to put the briefing.
local d f base briefing seen=" "
# The briefing's own name, from the manifest rather than repeated here.
# dejima wins that collision, and entrypoint.sh already delivers it after
# the staged files - so staging a host file it is about to overwrite would
# only add a mount nobody reads.
briefing="$(basename "$(manifest_get harness cline briefing_path)")"
# Documents first, so a name present in both wins there: it is cline's own
# location for this, and where an operator editing rules through cline is
# writing. Two sources staged to one destination would otherwise be two
# bind mounts at the same container path.
for d in "$HOME/Documents/Cline/Rules" "$HOME/.agents/rules"; do
[ -d "$d" ] || continue
# .md and .txt are what cline reads out of a rules directory.
for f in "$d"/*.md "$d"/*.txt; do
[ -f "$f" ] || continue
base="$(basename "$f")"
[ "$base" = "$briefing" ] && continue
case "$seen" in *" $base "*) continue ;; esac
seen="$seen$base "
emit_staged_config "$f" "Documents/Cline/Rules/$base"
done
done
}
# Writable copies, discarded with the container.
#
# cline rewrites both of these as it runs - its settings TUI toggles them - so
# a read-only copy would fail the write rather than the persistence, and the
# persistence is the part that matters. cline_mcp_settings.json names MCP
# servers, which are commands cline executes: a session that could write the
# host's copy would be choosing what runs there next time.
harness_seed() {
emit_seed_from "$HOME/.cline/settings.json" ".cline/settings.json"
emit_seed_from "$HOME/.cline/cline_mcp_settings.json" ".cline/cline_mcp_settings.json"
}
harness_state_mounts() {
local dir
dir="$(_cline_state_dir "$1")" || return 0
+13 -2
View File
@@ -273,7 +273,7 @@ COPY docker/ulogd.conf /etc/ulogd-sandbox.conf
# Into $VIMRUNTIME/plugin, not /etc/xdg/nvim - see the file's own comment.
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/credential-proxy-forwarder.js /usr/local/bin/credential-proxy-forwarder.js
COPY docker/harness-wrapper.sh /usr/local/bin/dejima-harness-wrapper
# The two guards that do not need the harness's help. git-guard shadows git on
# PATH; forge-guard is linked over a forge CLI by entrypoint.sh when a workflow
@@ -287,6 +287,17 @@ COPY docker/forge-guard.sh /usr/local/lib/dejima/forge-guard.sh
# entrypoint.sh, which renders the briefing before the session starts - to
# hand to the harness as an argument, or to write to a file it reads.
COPY hooks/dejima-briefing-render.sh /usr/local/lib/dejima/dejima-briefing-render.sh
# dejima's own hooks, for the same reason and one more: they are registered
# into the sandbox's --settings overlay rather than into the operator's
# ~/.claude/settings.json, so nothing has to be written to a host config to
# make the sandbox work. Baked rather than mounted from the checkout because a
# guard whose script can change under a running session is not one.
#
COPY hooks/deny-dangerous-bash.sh /usr/local/lib/dejima/hooks/deny-dangerous-bash.sh
COPY hooks/sandbox-context.sh /usr/local/lib/dejima/hooks/sandbox-context.sh
COPY hooks/git-push-guard.sh /usr/local/lib/dejima/hooks/git-push-guard.sh
COPY hooks/merge-guard.sh /usr/local/lib/dejima/hooks/merge-guard.sh
COPY hooks/dejima-notify.sh /usr/local/lib/dejima/hooks/dejima-notify.sh
# 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
@@ -312,7 +323,7 @@ RUN bash /tmp/install-forge-bins.sh /tmp/forges /usr/local/lib/dejima/forge /usr
# and describes what a page should look like instead of going and seeing.
RUN ln -s /usr/local/share/npm-global/lib/node_modules /node_modules
RUN chmod +x /usr/local/bin/dns-proxy.sh /usr/local/bin/init-firewall.sh /usr/local/bin/log-shipper.sh /usr/local/bin/entrypoint.sh /usr/local/bin/dejima-harness-wrapper /usr/local/lib/dejima/git-guard.sh /usr/local/lib/dejima/forge-guard.sh /usr/local/lib/dejima/dejima-briefing-render.sh && \
RUN chmod +x /usr/local/bin/dns-proxy.sh /usr/local/bin/init-firewall.sh /usr/local/bin/log-shipper.sh /usr/local/bin/entrypoint.sh /usr/local/bin/dejima-harness-wrapper /usr/local/lib/dejima/git-guard.sh /usr/local/lib/dejima/forge-guard.sh /usr/local/lib/dejima/dejima-briefing-render.sh /usr/local/lib/dejima/hooks/*.sh && \
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
@@ -1,6 +1,6 @@
// TCP front door for the host-side credential proxy, which listens on a
// bind-mounted unix socket (no host TCP exposure - see
// bin/dejima-anthropic-proxy). Deliberately no HTTP awareness in either
// bin/dejima-credential-proxy). Deliberately no HTTP awareness in either
// mode: bytes in, bytes out.
//
// tls (default): terminates TLS for api.anthropic.com on :443, which
@@ -22,14 +22,14 @@ const net = require("net");
const tls = require("tls");
const fs = require("fs");
const socketPath = process.env.SANDBOX_ANTHROPIC_PROXY_SOCKET;
const transport = process.env.SANDBOX_ANTHROPIC_TRANSPORT || "tls";
const socketPath = process.env.SANDBOX_CREDENTIAL_PROXY_SOCKET;
const transport = process.env.SANDBOX_CREDENTIAL_TRANSPORT || "tls";
const port = parseInt(
process.env.SANDBOX_ANTHROPIC_PROXY_PORT || (transport === "tls" ? "443" : "18300"),
process.env.SANDBOX_CREDENTIAL_PROXY_PORT || (transport === "tls" ? "443" : "18300"),
10,
);
if (!socketPath) {
console.error("anthropic-proxy-forwarder: SANDBOX_ANTHROPIC_PROXY_SOCKET not set");
console.error("credential-proxy-forwarder: SANDBOX_CREDENTIAL_PROXY_SOCKET not set");
process.exit(1);
}
@@ -43,11 +43,11 @@ const pipe = (client) => {
let server;
if (transport === "tls") {
const certPath = process.env.SANDBOX_ANTHROPIC_TLS_CERT;
const keyPath = process.env.SANDBOX_ANTHROPIC_TLS_KEY;
const certPath = process.env.SANDBOX_CREDENTIAL_TLS_CERT;
const keyPath = process.env.SANDBOX_CREDENTIAL_TLS_KEY;
if (!certPath || !keyPath) {
console.error(
"anthropic-proxy-forwarder: SANDBOX_ANTHROPIC_TLS_CERT/SANDBOX_ANTHROPIC_TLS_KEY not set",
"credential-proxy-forwarder: SANDBOX_CREDENTIAL_TLS_CERT/SANDBOX_CREDENTIAL_TLS_KEY not set",
);
process.exit(1);
}
+15 -4
View File
@@ -34,7 +34,13 @@ services:
# $SANDBOX_IMAGE lets bin/dejima point a run at a derived,
# per-project image instead, without this build ever producing one
# under that name itself - see bin/dejima.
image: ${SANDBOX_IMAGE:-dejima:${SANDBOX_USER:-node}}
# localhost/ so this can never resolve to Docker Hub: Docker reads a first
# component containing a dot or colon, or exactly "localhost", as a
# registry host, and a bare `dejima:node` is `docker.io/library/dejima:node`
# to anything that pushes (#53). bin/dejima holds the same prefix in
# SANDBOX_IMAGE_REPO and passes it in; this default is for a compose
# invocation by hand.
image: ${SANDBOX_IMAGE:-localhost/dejima:${SANDBOX_USER:-node}}
# Run tini as PID 1 so orphaned processes get reaped. Without it PID 1 is
# the session's own command (claude, or nvim under SANDBOX_NVIM_CONFIG),
# which never reaps: every process-substitution child the test suite and
@@ -131,10 +137,11 @@ services:
# The names this harness needs reachable, and the one its credential
# proxy stands in front of - see docker/fixed-domains.sh.
SANDBOX_HARNESS_DOMAINS: ${SANDBOX_HARNESS_DOMAINS:-}
SANDBOX_HARNESS_PROXIED_DOMAIN: ${SANDBOX_HARNESS_PROXIED_DOMAIN:-}
SANDBOX_BACKEND_PROXIED_DOMAIN: ${SANDBOX_BACKEND_PROXIED_DOMAIN:-}
SANDBOX_HARNESS_BRIEFING_ARGS: ${SANDBOX_HARNESS_BRIEFING_ARGS:-}
SANDBOX_HARNESS_BRIEFING_PATH: ${SANDBOX_HARNESS_BRIEFING_PATH:-}
SANDBOX_HARNESS_SEED: ${SANDBOX_HARNESS_SEED:-}
SANDBOX_HARNESS_STAGED_EMPTY: ${SANDBOX_HARNESS_STAGED_EMPTY:-}
# 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}
@@ -162,8 +169,12 @@ services:
# empty - and unset by entrypoint.sh - otherwise.
ANTHROPIC_BASE_URL: ${SANDBOX_ANTHROPIC_BASE_URL:-}
ANTHROPIC_AUTH_TOKEN: ${SANDBOX_ANTHROPIC_AUTH_TOKEN:-}
SANDBOX_ANTHROPIC_PROXY_SOCKET: ${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}
SANDBOX_ANTHROPIC_TRANSPORT: ${SANDBOX_ANTHROPIC_TRANSPORT:-}
# The placeholder an API-billing session authenticates with. The real key
# never enters the container; the host-side proxy swaps this for it per
# request, exactly as it does for a subscription login's token.
ANTHROPIC_API_KEY: ${SANDBOX_ANTHROPIC_API_KEY:-}
SANDBOX_CREDENTIAL_PROXY_SOCKET: ${SANDBOX_CREDENTIAL_PROXY_SOCKET:-}
SANDBOX_CREDENTIAL_TRANSPORT: ${SANDBOX_CREDENTIAL_TRANSPORT:-}
volumes:
# Mounted at the same absolute path as on the host (rather than a
# fixed /workspace) so Claude Code's project/session-history keying
+52 -21
View File
@@ -235,6 +235,36 @@ if [ -d /etc/dejima-harness ]; then
done < <(find /etc/dejima-harness -type f -print0)
fi
# Files the harness needs to exist whether or not the operator has one.
#
# Created HERE rather than on the host. A harness that reads a settings file
# still reads one when it is empty, and dejima creating that file on the
# operator's machine - so there is something to copy - is a write to their
# configuration for dejima's own convenience. Root-owned and read-only, the
# same as anything else staged, so the container's copy is not a channel back.
for _spec in ${SANDBOX_HARNESS_STAGED_EMPTY:-}; do
_kind="${_spec%%:*}"
_rel="${_spec#*:}"
case "$_rel" in
/*|*..*)
echo "entrypoint: refusing staged-config path '$_rel': it must be" >&2
echo "relative to the sandbox user's home and must not contain '..'." >&2
exit 1 ;;
esac
_dest="$SANDBOX_HOME/$_rel"
[ -e "$_dest" ] && continue
mkdir -p "$(dirname "$_dest")"
# An empty file is not valid JSON, and a harness that discards a config it
# cannot parse would lose everything in it rather than nothing.
if [ "$_kind" = ensure-json ]; then
printf '{}\n' > "$_dest"
else
: > "$_dest"
fi
chown root:root "$_dest"
chmod 444 "$_dest"
done
# The harness's own writable state. Unlike the staged config above, these are
# owned by the sandbox user and stay writable: they are what the harness
# rewrites as it runs - a plugin registry, a catalogue, an install marker -
@@ -348,11 +378,11 @@ if [ -n "${SANDBOX_HARNESS_BRIEFING_ARGS:-}" ]; then
fi
# TCP front door for the host-side credential proxy (see bin/dejima and
# bin/dejima-anthropic-proxy): the proxy listens on a bind-mounted unix
# socket, and anthropic-proxy-forwarder.js pipes bytes into it. Only started
# bin/dejima-credential-proxy): the proxy listens on a bind-mounted unix
# socket, and credential-proxy-forwarder.js pipes bytes into it. Only started
# when this session actually has a proxy socket.
#
# In the default tls transport the harness's proxied_api_domain is pointed at
# In the default tls transport the backend's proxied_domain is pointed at
# loopback here and the forwarder terminates TLS for it, so the harness runs
# with no base-URL override and no placeholder token - it is indistinguishable
# from a stock install, which is the whole point (see docs/security.md). The hosts
@@ -360,40 +390,40 @@ fi
# extra_hosts: under [credentials] proxy = off nothing listens on :443, and
# an unconditional entry would black-hole the real API on exactly the path
# that has to keep working.
SANDBOX_ANTHROPIC_TRANSPORT="${SANDBOX_ANTHROPIC_TRANSPORT:-tls}"
export SANDBOX_ANTHROPIC_TRANSPORT
SANDBOX_CREDENTIAL_TRANSPORT="${SANDBOX_CREDENTIAL_TRANSPORT:-tls}"
export SANDBOX_CREDENTIAL_TRANSPORT
# Checked here rather than in bin/dejima because every launch reaches this
# script, including a bare `docker run` (see test/runtime.bats). The two halves
# branch on opposite polarity - bin/dejima treats anything but `loopback` as
# tls, this script and the forwarder treat anything but `tls` as loopback - so
# a typo would configure them for different transports and fail every API call
# with nothing pointing back at the cause.
case "$SANDBOX_ANTHROPIC_TRANSPORT" in
case "$SANDBOX_CREDENTIAL_TRANSPORT" in
tls|loopback) ;;
*)
echo "FATAL: SANDBOX_ANTHROPIC_TRANSPORT is set to '$SANDBOX_ANTHROPIC_TRANSPORT'." >&2
echo "FATAL: SANDBOX_CREDENTIAL_TRANSPORT is set to '$SANDBOX_CREDENTIAL_TRANSPORT'." >&2
echo "The only valid values are 'tls' (the default) and 'loopback'; they are" >&2
echo "case-sensitive. Not starting the session - an unrecognised value would" >&2
echo "otherwise be read as one transport here and the other by bin/dejima." >&2
exit 1
;;
esac
if [ -n "${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}" ]; then
if [ -n "${SANDBOX_CREDENTIAL_PROXY_SOCKET:-}" ]; then
forwarder_port=18300
# Which name the terminator answers for is the selected harness's, from
# `proxied_api_domain` in its manifest - this image is shared by every
# harness and must name none of them. Empty here means a harness declared
# a credential proxy it gave no endpoint for: the terminator would come up
# answering for nothing, the session would reach the real API directly,
# and the credential the proxy exists to withhold would be the one in use.
proxied_domain="${SANDBOX_HARNESS_PROXIED_DOMAIN:-}"
# `proxied_domain` in its manifest - this image is shared by every
# backend and must name none of them. Empty here means a proxy socket was
# mounted with no endpoint declared for it: the terminator would come up
# answering for nothing, the session would reach the real API directly, and
# the credential the proxy exists to withhold would be the one in use.
proxied_domain="${SANDBOX_BACKEND_PROXIED_DOMAIN:-}"
if [ -z "$proxied_domain" ]; then
echo "FATAL: a credential proxy socket is mounted, but the selected harness" >&2
echo "declares no proxied_api_domain for the terminator to stand in front of." >&2
echo "FATAL: a credential proxy socket is mounted, but the selected" >&2
echo "backend declares no proxied_domain for the terminator." >&2
echo "Not starting the session - traffic would reach the API unintercepted." >&2
exit 1
fi
if [ "$SANDBOX_ANTHROPIC_TRANSPORT" = "tls" ]; then
if [ "$SANDBOX_CREDENTIAL_TRANSPORT" = "tls" ]; then
forwarder_port=443
# Generated here, in the container, rather than on the host: the key
# never touches host disk and there is no host state to clean up. It
@@ -401,7 +431,7 @@ if [ -n "${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}" ]; then
# unprivileged - and worthless to anyone else, since the only thing
# that trusts this anchor is this container, which is already reading
# that traffic in the clear.
tls_dir=/etc/dejima-anthropic-tls
tls_dir=/etc/dejima-credential-tls
mkdir -p "$tls_dir"
# set -e aborts the script on openssl's nonzero exit either way, so a
# failure here never proceeds to chown/export a missing cert - but
@@ -427,8 +457,8 @@ if [ -n "${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}" ]; then
chown "$SANDBOX_USER" "$tls_dir/key.pem" "$tls_dir/cert.pem"
chmod 600 "$tls_dir/key.pem"
chmod 644 "$tls_dir/cert.pem"
export SANDBOX_ANTHROPIC_TLS_CERT="$tls_dir/cert.pem"
export SANDBOX_ANTHROPIC_TLS_KEY="$tls_dir/key.pem"
export SANDBOX_CREDENTIAL_TLS_CERT="$tls_dir/cert.pem"
export SANDBOX_CREDENTIAL_TLS_KEY="$tls_dir/key.pem"
# /etc/hosts wins over DNS in getaddrinfo, so this bypasses the DNS
# proxy entirely - which is why fixed-domains.sh leaves the name off
# the allowlist whenever there is a proxy socket.
@@ -438,7 +468,7 @@ if [ -n "${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}" ]; then
# the single most ordinary variable a corporate environment sets.
export NODE_EXTRA_CA_CERTS="$tls_dir/cert.pem"
fi
gosu "$SANDBOX_USER" node /usr/local/bin/anthropic-proxy-forwarder.js &
gosu "$SANDBOX_USER" node /usr/local/bin/credential-proxy-forwarder.js &
# Node's own startup (V8 init, module load, then finally binding the
# listen socket) isn't instant - without this wait, a request that
# arrives right after this script proceeds (an automated test's
@@ -473,6 +503,7 @@ fi
# behavior byte-for-byte.
[ -n "${ANTHROPIC_BASE_URL:-}" ] || unset ANTHROPIC_BASE_URL
[ -n "${ANTHROPIC_AUTH_TOKEN:-}" ] || unset ANTHROPIC_AUTH_TOKEN
[ -n "${ANTHROPIC_API_KEY:-}" ] || unset ANTHROPIC_API_KEY
# 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
+4 -4
View File
@@ -51,11 +51,11 @@ fi
# SaaS name: dns-proxy.sh exempts the extra list from stop-dns-rebind, and
# init-firewall.sh downgrades a failed lookup there to a warning.
#
# Which name this is comes from the harness (proxied_api_domain); the rule
# Which name this is comes from the provider (proxied_domain); the rule
# about when to allowlist it is dejima's, and stays here.
if [ -z "${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}" ] \
&& [ -n "${SANDBOX_HARNESS_PROXIED_DOMAIN:-}" ]; then
FIXED_DOMAINS+=("$SANDBOX_HARNESS_PROXIED_DOMAIN")
if [ -z "${SANDBOX_CREDENTIAL_PROXY_SOCKET:-}" ] \
&& [ -n "${SANDBOX_BACKEND_PROXIED_DOMAIN:-}" ]; then
FIXED_DOMAINS+=("$SANDBOX_BACKEND_PROXIED_DOMAIN")
fi
# GitHub itself is allowlisted by IP range (see github-domains ipset,
+64 -9
View File
@@ -28,8 +28,10 @@
# to a forge here.
# DEJIMA_FORGE_REPO the "<owner>/<name>" of the repository the session
# was launched against, and DEJIMA_FORGE_REPO_FLAGS /
# DEJIMA_FORGE_REPO_ENV the spellings this forge's CLI
# accepts for naming a different one.
# DEJIMA_FORGE_REPO_ENV / DEJIMA_FORGE_REPO_PATHS the
# spellings this forge's CLI accepts for naming a
# different one: a flag, an environment variable, and a
# path segment under a named subcommand.
# DEJIMA_FORGE_REAL_DIR where the real CLIs live, behind the shadow.
set -u
@@ -44,7 +46,8 @@ fi
# Outside a dejima container, or with nothing to do, this is a pass-through.
if [ -z "${DEJIMA:-}" ] ||
{ [ -z "${DEJIMA_MERGE_BLOCK:-}" ] && [ -z "${DEJIMA_DISCLOSE:-}" ] &&
[ -z "${DEJIMA_FORGE_REPO_FLAGS:-}" ] && [ -z "${DEJIMA_FORGE_REPO_ENV:-}" ]; }; then
[ -z "${DEJIMA_FORGE_REPO_FLAGS:-}" ] && [ -z "${DEJIMA_FORGE_REPO_ENV:-}" ] &&
[ -z "${DEJIMA_FORGE_REPO_PATHS:-}" ]; }; then
exec "$real" "$@"
fi
@@ -120,7 +123,8 @@ fi
# Refused rather than skipped when the launch resolved no repository at all: a
# workspace with no origin has nothing for --repo to legitimately name, and
# "cannot confirm" is not a reason to let it through.
if [ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ] || [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ]; then
if [ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ] || [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ] ||
[ -n "${DEJIMA_FORGE_REPO_PATHS:-}" ]; then
refuse_repo() {
echo "Blocked by dejima's forge guard: this session was launched against ${DEJIMA_FORGE_REPO:-no repository}, and $1 names '$2'. Act on the repository the sandbox mounts." >&2
exit 1
@@ -154,15 +158,33 @@ if [ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ] || [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ];
;;
"$flag"=*) value="${arg#*=}" ;;
*)
# A one-dash flag may carry its value with no
# separator - `-Rowner/name` - which two-dash flags do
# not do.
# One-dash flags only; a two-dash flag carries its
# value with `=` or as the next argument, both above.
case "$flag" in
--*) continue ;;
-?) ;;
*) continue ;;
esac
case "$arg" in "$flag"?*) value="${arg#"$flag"}" ;; *) continue ;; esac
# Short flags cluster, so the letter can sit anywhere
# in one: `-Rowner/name`, `-wR owner/name` and
# `-wRowner/name` all name a repository and none of
# them is `-R` as its own argument (#243). Split at the
# letter the way getopt does - what follows it in the
# same argument is the value, and nothing following it
# means the next argument is.
letter="${flag#-}"
# A cluster, not a value that happens to start with a
# dash: one dash, then the letter somewhere in a run of
# letters. Without the second check, `--search foo-R/x`
# style arguments would be mined for flag letters.
case "$arg" in -[!-]*) ;; *) continue ;; esac
rest="${arg#-}"
case "$rest" in *"$letter"*) ;; *) continue ;; esac
case "${rest%%"$letter"*}" in *[!A-Za-z]*) continue ;; esac
value="${rest#*"$letter"}"
if [ -z "$value" ]; then
[ "${#args_left[@]}" -gt 0 ] || continue
value="${args_left[0]}"
fi
;;
esac
[ -n "$value" ] || continue
@@ -170,6 +192,39 @@ if [ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ] || [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ];
done
done
fi
# A repository named in a path rather than in a flag - `gh api
# repos/<owner>/<name>/issues` reaches any repository the token does,
# without a --repo anywhere in it (#243).
#
# Only when the declared subcommand is argv's first word. The alternative
# is inspecting every positional that contains two slashes, which would
# refuse `gh issue create --title repos/a/b` - and a false refusal costs an
# honest agent a working command, which is the wrong way for a fast-fail to
# be wrong.
if [ -n "${DEJIMA_FORGE_REPO_PATHS:-}" ] && [ "$#" -gt 0 ]; then
read -ra repo_paths <<< "$DEJIMA_FORGE_REPO_PATHS"
for spec in "${repo_paths[@]}"; do
[ "$1" = "${spec%%:*}" ] || continue
segment="${spec#*:}"
for arg in "$@"; do
case "$arg" in -*) continue ;; esac
# gh substitutes {owner} and {repo} from the checkout, so a
# path carrying them names this repository by construction and
# comparing the literal braces would refuse it.
case "$arg" in *'{'*) continue ;; esac
path="${arg#/}"
case "$path" in "$segment"/*/*) ;; *) continue ;; esac
path="${path#"$segment"/}"
owner="${path%%/*}"
path="${path#*/}"
name="${path%%/*}"
[ -n "$owner" ] && [ -n "$name" ] || continue
same_repo "$owner/$name" \
|| refuse_repo "the path '$arg'" "$owner/$name"
done
done
fi
fi
# --- the authorship disclosure ---------------------------------------------
+211 -45
View File
@@ -31,14 +31,21 @@ config file behind them.
| `[core] update_check` | `on` | at startup, say when this checkout is behind the branch it tracks. One `ls-remote` to your own remote, at most hourly, never fetching; `off` makes dejima contact nothing of its own accord. The answer is cached in `~/.local/share/dejima/update-check`, which is safe to delete |
| `[ui] banner` | `on` | the three-line summary at the top of a launch - project and branch, the selection, and the switches whose state changes what a session can do. `off` prints nothing. Never drawn when output is redirected |
| `[core] firewall` | `on` | egress firewall; `off` disables it (debugging only), also settable per launch with `--no-firewall` |
| `[core] share_dir` | `~/.local/share/dejima/shared` | host-visible read/write scratch dir, mounted at `~/shared` |
| `[core] share_dir` | `~/.local/share/dejima/shared` | host-visible read/write scratch dir. One directory per project underneath it, and only that project's is mounted at `~/shared`. `dejima-share` is the host side of it |
| `[core] ssh_key` | unset | **deprecated** - the pre-identity spelling of `[identity.default] ssh_key`. Still read when the config has no `[identity.default]`; refused when it has one, since the two say the same thing |
| `[core] known_hosts` | `~/.ssh/known_hosts` | known-hosts file used for git-over-ssh |
| `[harness.claude-code] backend` | *(none - must be set)* | which model API this harness talks to, and so which credential the proxy injects. A directory under `backends/`, each shipping its own `[backend.<name>]` keys. Per harness rather than global, because running Claude Code against Anthropic and Cline against something else is the ordinary case. No default, for the reason `[core] harness` has none: one that could change later would move a session to another endpoint without its config changing. Unset, dejima writes `anthropic` into your config once and says so - a migration rather than a default, so nothing it decides later can move you. Also settable per launch with `--backend`. See [providers.md](providers.md#model-backends) |
| `[harness.claude-code] projects_dir` | `~/.claude/projects` | where Claude Code session transcripts live |
| `[harness.claude-code] file_history_dir` | `~/.claude/file-history` | Claude Code's edit-undo record, mounted per session rather than whole |
| `[harness.claude-code] statusline_path` | `~/.claude/statusline.sh` | statusline script path |
| `[harness.cline] model` | *(none - must be set)* | model id the endpoint serves; no default, because any shipped one goes stale with the next Cline release. Also settable per launch with `--model` |
| `[harness.cline] base_url` | unset | OpenAI-compatible endpoint. Unset means Anthropic through dejima's credential proxy. Accepts a loopback or RFC 1918 address, or a host you have allowlisted. Also settable per launch with `--base-url` |
| `[harness.cline] backend` | *(none - must be set)* | as above, and the risk is concrete here: Cline supports many backends and has one of its own. Nothing is written into your config for you either, unlike Claude Code: there is no one endpoint every existing Cline session was already using, so any value dejima recorded would be a guess. Also settable per launch with `--backend` |
| `[backend.anthropic] credential_file` | `~/.claude/.credentials.json` | the host file this backend's credential is read from, per request. A path, never a secret, and never mounted into the container |
| `[backend.self-hosted] base_url` | *(none - must be set)* | where you serve the model. A loopback or RFC 1918 address, or a host you have allowlisted - it has to be an endpoint needing no credential, since there is none to withhold. On the backend rather than the harness, so a local endpoint stays configured while a hosted API is selected |
| `[backend.anthropic-api-key] credential_file` | `~/.claude.json` | where your Anthropic API key is. Defaulted to where Claude Code's console login puts it, so the common case needs nothing set; point it at a file of your own if you hold the key yourself. Read host-side per request and never mounted, and the key is stripped from the copy of `~/.claude.json` dejima stages |
| `[backend.anthropic-api-key] credential_field` | `primaryApiKey` | which value inside that file is the key, as dot-separated object names. Clear it (`credential_field =`) when the file is nothing but the key |
| `[backend.<name>] proxy` | *(inherits `[credentials] proxy`)* | whether the credential proxy runs for this backend. Per backend so that one backend the proxy cannot serve is not a reason to stop withholding credentials from the ones it can. Off means this backend's real credential is given to the session, and dejima says so at launch |
| `[backend.openrouter] credential_file` | *(none - must be set)* | your OpenRouter API key, in a file. Read host-side per request and never mounted; the container only ever holds a placeholder. No default, because a key's location is yours and guessing at one would point dejima at a file it has no reason to think exists |
| `[harness.cline] model` | *(none - must be set)* | model id the endpoint serves, exactly as that endpoint spells it - a hosted catalogue namespaces by vendor (`z-ai/glm-5.3`) and may qualify a variant (`deepseek/deepseek-r1:free`). No default, because any shipped one goes stale with the next Cline release. Also settable per launch with `--model` |
| `[harness.cline] context_window` | unset | tokens Cline may use before compacting. Needed for a model it has no catalogue entry for, which is every self-served one. Also settable per launch with `--context-window` |
| `[harness.cline] state_dir` | `~/.local/share/dejima/cline` | per-project Cline state; one directory per workspace |
| `[forge.gitea] ssh_host` | unset | Gitea/Forgejo SSH hostname: skip if you only push to GitHub |
@@ -333,7 +340,9 @@ you don't have one.
### Cline
Almost nothing carries over, and that is the design rather than a gap.
Your own configuration carries over the same way Claude Code's does - the
table under [What comes in from your host](#what-comes-in-from-your-host) is
the list. Your *sessions* do not, and that is the design rather than a gap.
Cline keeps one `sessions.db` indexing every session it has ever run, with
the project a session belonged to as a field inside the record rather than a
@@ -344,21 +353,33 @@ under `[harness.cline] state_dir` (default `~/.local/share/dejima/cline`),
mounted at `~/.cline/data` in the container. Sessions persist across runs of
the same project and are invisible to every other project.
Because that directory is dejima's rather than yours, resuming on the host
needs `CLINE_DATA_DIR` set to it. The exact command is at the end of every
session's briefing.
Because that directory is dejima's rather than yours, resuming on the host is
not a supported path - see
[Sessions, and where they live](#sessions-and-where-they-live) below. Relaunch
dejima on the same project instead, which is what the briefing tells the
session to offer.
The provider configuration is written fresh by dejima at each launch and is
not yours either. Two shapes are supported:
The model configuration is written fresh by dejima at each launch and is not
yours either. Where the model is comes from `[harness.cline] backend` and
nowhere else, so there is no second setting for it to disagree with, and two
shapes are supported:
- **`base_url` unset** - Anthropic, reached through dejima's own credential
proxy exactly as Claude Code is. What is written into the container is the
placeholder `sandbox-credential-proxy-placeholder`; the real credential
stays on the host.
- **`base_url` set to a model you serve yourself** - either a loopback or
RFC 1918 address, or a host you have allowlisted with `dejima-allow`.
- **A backend the proxy serves** (`anthropic`, `anthropic-api-key`,
`openrouter`) - reached through dejima's own credential proxy exactly as
Claude Code is. What is written into the container is the placeholder
`sandbox-credential-proxy-placeholder` and the endpoint's real hostname; the
credential stays on the host. Which of Cline's own provider types dejima
writes comes from the backend's `api_style`: its native Anthropic client for
one, its OpenAI-compatible client for the other.
- **A backend with no credential** (`self-hosted`, carrying its own
`[backend.self-hosted] base_url`) - a model you serve, at either a loopback
or RFC 1918 address or a host you have allowlisted with `dejima-allow`.
Nothing needs a credential, so there is nothing to withhold.
Keeping the address on the backend rather than on the harness is what lets you
have a local model and a hosted API both configured and switch between them by
changing one word.
A name qualifies through the allowlist rather than through how it is spelled,
because that is the same store deciding whether the container can reach the
endpoint at all: one that fails the check is one the session could not have
@@ -366,13 +387,13 @@ connected to anyway. A model server on your own network normally has a name,
so requiring a literal address would have made this path unusable for the
ordinary case.
Anything else is refused at launch - a hosted provider's API key would have to
go inside the container, and dejima has no proxy that could hold one back on
its behalf. Note what the check is *not* doing: dejima writes a placeholder
into the container on this path whatever the endpoint is, so no real
credential enters it either way. What the check decides is whether the
configuration can work at all, which is worth saying at launch rather than as
an auth error three layers down. See
Anything else is refused at launch - a hosted API pointed at through a
no-credential backend would want its key inside the container, and dejima has
no proxy that could hold one back on its behalf. Note what the check is *not*
doing: dejima writes a placeholder into the container on the proxied path
whatever the endpoint is, so no real credential enters it either way. What the
check decides is whether the configuration can work at all, which is worth
saying at launch rather than as an auth error three layers down. See
[development.md](development.md#worked-example-cline).
If the endpoint serves a model Cline has no catalogue entry for - which is every
@@ -408,6 +429,96 @@ 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.
#### Sessions, and where they live
This project's Cline state - sessions, transcripts, tasks - is kept on the host
in `~/.local/share/dejima/cline/<project>` and mounted at `~/.cline/data`. It
survives the container being replaced, so **launching dejima again on the same
project picks up where the last session left off.**
A directory per project rather than one shared: Cline indexes every session it
has ever run in a single database, so sharing one would put every project's
history in every project's context - the same boundary `~/.claude/projects`
gets, for the same reason.
**Cline sessions are not importable or exportable.** A session started by
running `cline` on your own machine is in `~/.cline/data` and is not visible
inside dejima; a session started inside dejima is in the per-project directory
and is not offered back to a host-side Cline. There is no import, no export,
and no merge - see
[#301](https://gitbox.branderson.io/dejima/dejima/issues/301) for why that was
looked at and not built.
The session briefing tells a Cline agent this, so it will not offer to hand
you a session to resume outside the sandbox. Claude Code can be resumed on the
host and its briefing says so; the claim lives in each harness's own briefing
fragment rather than in the shared preamble, because only one of them can make
it.
#### What comes in from your host
The same shape as Claude Code's, and for the same reason: your own
configuration should reach a session, and nothing the session writes should
reach your machine.
| Host path | How it arrives | Why |
| --- | --- | --- |
| `~/.agents/skills/` | read-only mount | skills are instructions the agent follows |
| `~/.agents/hooks/`, `~/.cline/hooks/` | read-only mount | hooks are programs cline runs |
| `~/.agents/AGENTS.md` | read-only copy | instructions; a copy also survives it being a symlink into a dotfiles checkout |
| `~/.agents/rules/*.md`, `*.txt` | read-only copy, into `Documents/Cline/Rules/` | see below |
| `~/Documents/Cline/Rules/*.md`, `*.txt` | read-only copy | the location cline actually reads |
| `~/.cline/settings.json`, `~/.cline/cline_mcp_settings.json` | writable copy, discarded | cline rewrites both as it runs |
| `~/.cline/data/` | this project's own state directory | see above |
Read-only is the point rather than caution. A session that could rewrite a
hook, a skill or an MCP server definition would be choosing what happens on
**your** machine the next time you start cline outside a container - which is
persistence for a prompt injection, in files nobody re-reads before the next
run. The two settings files are copies rather than read-only mounts because
cline rewrites them itself; the copy is discarded with the container.
The read-only directories are scanned for symlinks, so a `skills/` that is a
link into a dotfiles checkout brings its target too. dejima refuses a target
that overlaps one of its own guarded stores.
**Global rules come from two places and land in one.** cline loads them from
`~/Documents/Cline/Rules` and nowhere else - `~/.agents/rules` reads like the
obvious companion to `~/.agents/skills` and is not loaded, which you can see
by planting a file in each and opening `cline config`. So dejima copies the
files from *both* host locations into the one directory cline reads, rather
than mounting `~/.agents/rules` where nothing would look at it.
They are copied file by file rather than mounted as a directory, because
dejima writes its own briefing into that same directory and a read-only mount
over it would leave nowhere to put it.
Two collisions have fixed answers. A name present in **both** host locations
is taken from `Documents/Cline/Rules`, since that is cline's own location for
rules and where it writes when you edit them through cline. A host file named
`00-dejima.md` is skipped: that is the briefing's name, and dejima's wins.
## What dejima says before it starts
Some launches print a notice first: an environment variable that is no longer
read, a deprecated key, a guard that is not registered, a line dejima wrote
into your config. Everything after that is the image build, which on a cold
cache is thousands of lines - so when a notice was printed, dejima waits for
Enter before starting it.
The wait happens only when there was something to read, and only when a
terminal is attached. An unattended launch never blocks on it.
`--no-pause` skips the wait and still prints the notice. It is for starting
several sessions at once - a tmuxinator layout, a script - where every one of
them has a terminal and none of them wants a keypress. It is a flag rather
than a config key on purpose: it describes the terminal in front of one
launch, not a standing preference.
A long list of entries left in the shared directory is sampled rather than
printed whole, for the same reason: it is a standing condition you clear once,
and it should not push the things about this run off the screen.
## Identities
**Several accounts on one machine, and none of them yours.** An
@@ -605,12 +716,20 @@ 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.
store, the epitaph store, every project's share, `~/.local/bin`, dejima's own
checkout, the project mount (already read-write, so listing it again is a
mistake worth surfacing), and **everything the selected harness keeps on the
host** - its config directories, the files staged and seeded from them, and
the state directories its manifest declares. A writable mount over any of
those would let a session approve its own domains, delete the audit findings
naming it, read another project's transcripts, 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.
The harness half of that list is derived from what the harness declares, not
written down in dejima, so it covers whichever one you selected - `~/.claude`
for Claude Code, `~/.agents` and `~/.cline` for Cline - and covers a harness
added later without anyone extending a list.
Sessions are told which directories these are, so "everything else is
disposable" stays true in the briefing rather than becoming a trap.
@@ -662,11 +781,41 @@ pick up from somewhere else, run dejima inside tmux and use
## 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.
`~/shared` is a read-write bind mount of one project's directory under
`[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.
**One directory per project, not one for the host.** A launch mounts
`<share_dir>/projects/<slug>` at `~/shared`, where the slug is the project's
absolute path with every non-alphanumeric character replaced by a dash - the
same key the audit and epitaph stores use. So a session sees its own project's
handoff files and no other project's, and nothing an agent has been told
changes, because inside the container the path is still `~/shared`.
`dejima-share` is the host side of that:
```sh
dejima-share path # the host directory for this project, created if new
dejima-share path ~/code/x # …for another project
dejima-share list # every project's share, with size and last write
dejima-share adopt # move files left loose in share_dir into this project's
```
**To hand a file *to* a session,** put it in `dejima-share path` before
launching. That is what makes the inbound direction work without a mount that
every project can read.
**From another machine,** `sshfs` puts a project's share in your own file
manager over the SSH access you already have. Read
[Mounting the box's files](remote-access.md#mounting-the-boxs-files) before
setting that up: every byte and every filename in there was written by a
sandboxed session, and a desktop that mounts the directory indexes,
thumbnails and previews what it finds without waiting to be asked. Nothing
dejima runs is in that path, so the sanitising it does for its own output does
not reach there.
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 -
@@ -680,10 +829,20 @@ being rewritten to point at `~/shared`.
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.
**Clear it out periodically.** The per-project split bounds which sessions can
read what you left, and bounds nothing about how long. A project's share
persists by design, so anything in it is readable by every future session
against that project: a credential dropped in there to get one job done, and
then forgotten, is a credential you have handed to every session against that
project since. Nothing prunes `share_dir` - `dejima-share list` prints each
project's size and the date of its last write, and deleting is yours.
**Files from before the split.** `share_dir` used to be mounted whole, so
anything at its top level was visible to every session. Those files are still
there and are now mounted into nothing; dejima says so at every launch while
that is true, rather than moving an operator's files on its own.
`dejima-share adopt <project>` moves them into one project's share after
showing what it will move and asking, and it never overwrites.
## Reading what a session left behind
@@ -782,14 +941,21 @@ 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.
Every session is told where it is running: a disposable, firewalled container
it should stay within, and if something genuinely needs to break out - broader
network, host changes, GUI/hardware - it should stop and ask rather than work
around the firewall. Whether the session can be resumed unrestricted on the
host afterwards is the harness's own answer, and each harness's briefing
fragment gives it: Claude Code's names `claude --resume`, Cline's says plainly
that its sessions do not leave the sandbox.
*How* the briefing reaches a session is per harness too, because they do not
agree on a mechanism. Claude Code takes it as a system prompt through the flag
its manifest names; Cline takes no such flag and registers no hook, so
`entrypoint.sh` renders it into a file the harness reads, root-owned and
read-only. A harness doing neither gets it through a `SessionStart` hook. What
the briefing *says* is none of theirs - one script composes it for all three
routes. See [development.md](development.md).
When GitHub is a push destination this session - `--allow-github-push`, or
`forge = github` under a pushing workflow - the briefing also tells the
+57 -33
View File
@@ -64,8 +64,10 @@ 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`.
`install.sh` writes nothing to `~/.claude`. Every hook dejima uses is baked
into the image at `/usr/local/lib/dejima/hooks` and registered per launch into
the `--settings` overlay, which Claude Code merges with your
`~/.claude/settings.json` rather than replacing it.
## Tests
@@ -151,11 +153,23 @@ 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
bash test/run-confined.sh # every confined suite - the pull-request gate
bash test/run-confined.sh --changed # only the suites your diff calls for
bats test/audit.bats # one suite - the suites are independent
SKIP_BUILD=1 bats test/runtime.bats # the one suite needing Docker; reuse the current image
```
`test/run-confined.sh` runs the files concurrently and, within each file, the
tests too, via bats' own `--jobs`. Adding `--jobs $(nproc)
--no-parallelize-across-files` to a bare `bats` invocation gets the same effect
for a single suite; both halves of that are needed, because bats otherwise
requires GNU parallel for `--jobs` and only the across-files half actually does.
`--changed` is for iterating. It maps the diff to suites through `suites_for()`
in that script, prints which lane it ran, and says plainly that it is not the
gate - a change is tested when the whole set has run, not when the selected
subset has.
`runtime.bats` is the only suite that needs a Docker daemon: it is the one
that builds the image and boots real containers to assert against.
Everything else, `audit.bats`, `ci-runner.bats`, `config.bats`,
@@ -189,10 +203,12 @@ it has to argue about.
### 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.
Support is per harness and deliberate. A supported harness gets everything
dejima has to give: its state scoped per project, its config carried in
read-only, its audit surfaces watched, and its guards installed wherever it
can host one. So a harness is not a manifest someone drops in - and where it
cannot host a protection, the launch says so rather than leaving the gap
unnamed.
These are the requirements. A harness that fails one of the first four cannot
be supported, and that is a decision about the harness rather than a gap in
@@ -342,20 +358,22 @@ that command lives in the harness's briefing fragment rather than in
`resume_command`: briefing substitution is one pass, so a placeholder inside
a substituted value is never resolved.
*Provider-agnostic is not the same as endpoint-agnostic.* Cline can be pointed
anywhere; dejima cannot follow it everywhere. With `base_url` unset it runs
against Anthropic through dejima's own credential proxy and the key never
enters the container. Pointed at someone else's hosted API there is no proxy
that could hold a key back, so the key would have to go in - which is R5
failing, not a network restriction. The plugin therefore accepts `base_url`
only for a model the operator serves themselves - a loopback or RFC 1918
address, or a host they have allowlisted - where an open-weight model needs no
credential at all and R5 has nothing to protect. The allowlist is what
qualifies a name, because it is the same store deciding what the container can
reach: an endpoint failing the check is one the session could not have
connected to anyway, and a model server on someone's own network normally has
a name rather than a literal address. A provider-generic proxy would widen
this to hosted providers; nobody has written one (#171).
*Backend-agnostic is not the same as endpoint-agnostic.* Cline can be pointed
anywhere; dejima cannot follow it everywhere. A backend the proxy serves is
fine whatever its dialect - the proxy takes the credential's kind and header
names as arguments, so `openrouter` works the way `anthropic` does and the key
never enters the container either way. What dejima cannot follow is a hosted
API reached *without* a backend: there is no proxy holding a key back on its
behalf, so the key would have to go in, which is R5 failing rather than a
network restriction.
So the `credential = none` path is only accepted for a model the operator
serves themselves - a loopback or RFC 1918 address, or a host they have
allowlisted - where the endpoint needs no credential at all and R5 has nothing
to protect. The allowlist is what qualifies a name, because it is the same
store deciding what the container can reach: an endpoint failing the check is
one the session could not have connected to anyway, and a model server on
someone's own network normally has a name rather than a literal address.
*Conversation logging works; epitaphs do not, and only one of those is
Cline's fault.* Cline's transcripts are
@@ -487,9 +505,9 @@ harness-specific path resolving to nothing is the honest answer.
| Seeded files | that a harness reading a credential from a fixed path needs something at it, since an absent file and an empty one are not reliably the same to it. The manifest's `image_files`, created empty and 0600 in that harness's layer, where a mounted credential shadows it - rather than by `entrypoint.sh`, which runs in every image including the base one alone | `harnesses/<name>/harness.conf` |
| Image environment | anything the harness wants set for every session of its own - for Claude Code, `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1`. The manifest's `image_env`, in the same layer | `harnesses/<name>/harness.conf` |
| Command | there is no default anywhere shared. `SANDBOX_CMD` comes from the manifest's `command` via `bin/dejima`, and `entrypoint.sh` refuses an empty one rather than naming a harness a given image may not contain | `harnesses/<name>/harness.conf`, `docker/entrypoint.sh` |
| Proxied endpoint | which name the in-container TLS terminator answers for, and what `/etc/hosts` pins to loopback. The manifest's `proxied_api_domain`; a credential proxy with no domain declared is refused rather than started answering for nothing | `harnesses/<name>/harness.conf`, `docker/entrypoint.sh` |
| Egress allowlist | `platform.claude.com` always, `api.anthropic.com` only when no credential proxy is in front of it. `api_domains` and `proxied_api_domain` in the manifest | `harnesses/<name>/harness.conf` |
| 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` |
| Proxied endpoint | which name the in-container TLS terminator answers for, and what `/etc/hosts` pins to loopback. The BACKEND's `proxied_domain`, not the harness's: a harness reaches whichever endpoint it is configured for, so the name belongs to the endpoint. A proxied backend with no domain declared is refused rather than started answering for nothing | `backends/<name>/backend.conf`, `docker/entrypoint.sh` |
| Egress allowlist | `platform.claude.com` always, `api.anthropic.com` only when no credential proxy is in front of it. `api_domains` on the harness, `proxied_domain` on the backend | `harnesses/<name>/harness.conf`, `backends/<name>/backend.conf` |
| Credentials | which substitute the harness accepts, and where it reads it from: a well-formed fake `~/.claude/.credentials.json` the entrypoint seeds for Claude Code, a placeholder key in `settings/providers.json` for Cline, a placeholder in an environment variable on the API-key path. The proxy itself is neither harness's - what the credential is, which header carries it and whether it refreshes are the backend's, passed as arguments | `bin/dejima-credential-proxy`, `docker/credential-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`. In the plugin, gated per declared guard | `bin/harness/claude-code.sh` |
| Briefing delivery | **by which of three routes**, not whether. Claude Code takes the briefing as a system prompt, through the flag its manifest names in `briefing_args`, because a `SessionStart` payload past roughly nine kilobytes is persisted and replaced with a preview - so the hook route silently under-delivered a briefing carrying a project's `.dejima.md`. Cline takes no such flag and registers no hook, so it declares a `briefing_path` and reads a file `entrypoint.sh` writes root-owned and read-only. A harness doing neither still gets it through the hook. What the briefing *says* is none of theirs: composing it host-side is core's, and the container-side part - this container's id, the declared-versus-installed gap, the project's `.dejima.md` - is one script every route calls | `hooks/dejima-briefing-render.sh`, `hooks/sandbox-context.sh`, `docker/entrypoint.sh` |
@@ -539,13 +557,19 @@ because absent is visible to the operator and broken is not.
`claude-code` is no exception to that: it is declared by its own manifest and
built into its own layer like any other. The base image carries nothing of any
harness's - not its package, not the directories it keeps state in, not its
environment, and not its name as a default command. What is left on the
harness axis is the credential proxy, which is Claude Code's shape throughout
and is the R5 row above.
environment, and not its name as a default command.
The credential proxy is no longer on that list either. Which header carries
the credential, what shape it takes, what must not travel beside it and
whether it can be refreshed are all arguments the selected backend supplies,
so a second harness reaches a proxied backend without a branch of its own -
which is the R5 row above, and is confirmed rather than argued: Cline runs a
real completion through the same terminator and the same host-side proxy.
`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.
`harness.conf` gets its parsing and validation tests. `config.bats`,
`static.bats`, `project.bats` and `conversation.bats` each carry a second
harness's cases beside Claude Code's, which is the shape a third one should
follow. What is still missing is the end of the ladder: `runtime.bats` boots
only `claude-code`, so nothing in `test/` runs a second harness end to end in
a real container, and a green confined run says nothing about that.
+148 -15
View File
@@ -3,16 +3,20 @@
[← dejima README](../README.md)
dejima's forge (Gitea/Forgejo/...), workflow (worktree-and-PR, or something
else) and harness (Claude Code, or another agent CLI) behaviour are not
hardcoded into core. Each is a directory under `forges/`, `workflows/` or
`harnesses/`, selected by `[core] forge`, `[core] workflow` and
`[core] harness` in `~/.config/dejima/config`. Shipped today:
else), harness (Claude Code, or another agent CLI) and model backend
(Anthropic, OpenRouter, something you serve) behaviour are not hardcoded into
core. Each is a directory under `forges/`, `workflows/`, `harnesses/` or
`backends/`. Shipped today:
| Axis | Directory | Selector | Ships |
| --- | --- | --- | --- |
| forge | `forges/` | `[core] forge` | `forges/gitea`, `forges/github`, `forges/none` |
| workflow | `workflows/` | `[core] workflow` | `workflows/worktree-pr`, `workflows/branch-push`, `workflows/local-only` |
| harness | `harnesses/` | `[core] harness` | `harnesses/claude-code` |
| harness | `harnesses/` | `[core] harness` | `harnesses/claude-code`, `harnesses/cline` |
| backend | `backends/` | `[harness.<name>] backend` | `backends/anthropic`, `backends/anthropic-api-key`, `backends/openrouter`, `backends/self-hosted` |
The backend is selected per harness rather than in `[core]`, because it is the
harness that talks to a model API - see [Model backends](#model-backends).
See [Shipped providers](#shipped-providers) below for what each one is for.
@@ -44,7 +48,7 @@ elsewhere needs to change.
## The manifest file
Each provider directory has exactly one manifest file, named for its axis:
`forge.conf`, `workflow.conf`, `harness.conf`. Format is the same
`forge.conf`, `workflow.conf`, `harness.conf`, `backend.conf`. Format is the same
`key = value` shape as the main config file:
- One `key = value` pair per line; surrounding whitespace is trimmed.
@@ -76,6 +80,7 @@ value never does:
| `export` | `NAME=key` or `NAME=@key` for the `env_export` forge key - see [Forge](#forge) below. |
| `template` | Prose that may contain `{{placeholder}}` markers; the placeholder names are checked against the briefing whitelist at composition time, not here. |
| `keyspec` | One element of a `keys` list - see [The `keys` field](#the-keys-field-a-providers-own-config-schema) below. |
| `repopath` | `<subcommand>:<first path segment>` for the `repo_paths` forge key - where a repository appears in a path rather than behind a flag. |
| `list:TYPE` | Space-separated (not colon-separated, unlike a config-file list) elements, each checked against `TYPE`. |
A leading `?` on any type (`?hostname`, `?list:identifier`, ...) makes the
@@ -123,9 +128,10 @@ operator can set, resolved the same way as any other config key
| `pr_command` | `?command` | (empty) | The PR-management command name the forge's briefing prose and `env_export`/`merge_block` refer to. |
| `issue_command` | `?command` | (empty) | The issue-management command name, same idea. |
| `merge_block` | `?command` | (empty) | The command prefix refused inside the sandbox - only when the workflow's `block_merge = on`. Enforced by `docker/forge-guard.sh`, which shadows the named CLI on PATH and compares argv, so it holds for a harness that cannot intercept a tool call; `hooks/merge-guard.sh` applies the same rule to a command string, for the harnesses that can. Matched as a run of consecutive non-flag arguments, so a flag before the subcommand does not slip past it. |
| `repo_flags` | `?list:flag` | (empty) | The option names this forge's CLI accepts for naming a repository other than the one the session was launched against - `--repo -R` for `gh`. `docker/forge-guard.sh` refuses a value that is not that repository, in every spelling (`--repo x`, `--repo=x`, and a one-dash flag with its value attached). Fast-fail, not a boundary, for the same reason `merge_block` is not one: the session holds the token. Leave it empty for a CLI that resolves the repo itself and refuses another host, as the gitea ones do - a forge is not required to be aimable. |
| `repo_flags` | `?list:flag` | (empty) | The option names this forge's CLI accepts for naming a repository other than the one the session was launched against - `--repo -R` for `gh`. `docker/forge-guard.sh` refuses a value that is not that repository, in every spelling of the flag itself: `--repo x`, `--repo=x`, `-R x`, `-Rx`, and the letter inside a short-flag cluster (`-wR x`, `-wRx`). Fast-fail, not a boundary, for the same reason `merge_block` is not one: the session holds the token. Leave it empty for a CLI that resolves the repo itself and refuses another host, as the gitea ones do - a forge is not required to be aimable. |
| `identity_keys` | `?list:identifier` | (empty) | Which of this forge's own `keys` name an **account** rather than the forge itself - the credential paths, which differ per session, where an API URL does not. One declaration, two rules: a key named here is one an `[identity.<name>]` section may set, and one a project may not declare in its own `.dejima/`. Core keeps no list of its own, so a forge whose credential is spelled differently needs no change here. The identity inherits the type from this forge's `keys` entry, not a second copy of it. |
| `repo_env` | `?identifier` | (empty) | The environment variable this forge's CLI reads for the same thing - `GH_REPO`. Declared separately because it takes no argument and would otherwise be the way around every flag above. |
| `repo_paths` | `?list:repopath` | (empty) | A repository this forge's CLI names in a path rather than in a flag, as `<subcommand>:<first segment>` - `api:repos` for `gh api repos/<owner>/<name>/...`. Scoped to a subcommand on purpose: comparing every positional argument that contains two slashes would refuse an issue title that happens to look like one, and a false refusal costs an honest agent a working command. Paths carrying `gh`'s own `{owner}`/`{repo}` placeholders are left alone, since those resolve to this repository by construction. |
| `disclose` | `?disclosures` | (empty) | Which of this forge's commands send a body the session authored, as comma-separated `kind:command prefix` entries - `pr:gh pr create, issue:gh issue create, ...`. `docker/forge-guard.sh` prepends a marker to the body of a matching command, so a PR, issue or comment an agent wrote says so. The three kinds are `pr`, `issue` and `comment`; the wording of each marker is core's, not the manifest's, so that dejima says one thing about agent authorship whichever forge is selected. Unlike `merge_block` this does not depend on the workflow: what a workflow permits is a policy, who wrote the text is a fact. |
| `keys` | `?list:keyspec` | (empty) | This forge's own `[forge.<name>]` config schema - see above. |
| `requires` | `?list:identifier` | (empty) | Which of this forge's own config keys an operator is expected to set for it to actually work - checked at launch by two refusal rules (partly-configured, and none-configured-with-a-fixed-host) when the workflow pushes; see [Validation](#validation). A key with a non-empty schema default never counts as missing for either check. |
@@ -226,10 +232,10 @@ A workflow's `briefing.md` is plain Markdown plus two marker forms that
| `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. |
| `api_domains` | `?list:hostname` | (empty) | Hostnames this harness's own machinery must reach - its update host, its telemetry endpoint. They join the *fixed* allowlist rather than the extra one, which `dns-proxy.sh` exempts from stop-dns-rebind and `init-firewall.sh` treats as non-fatal when it fails to resolve. |
| `proxied_api_domain` | `?hostname` | (empty) | The model API endpoint the credential proxy stands in front of. Allowlisted **only when the proxy is off**: with it on, the session reaches the API through the forwarder, so leaving the name unresolvable defeats a bypass that looks it up by DNS. Which name it is comes from here; when to allowlist it is dejima's rule. |
| `api_styles` | `?list:identifier` | (empty) | Which backend `api_style`s this harness has a client for. `claude-code` speaks `anthropic`; `cline` speaks both, which is what makes it the backend-agnostic one. Empty means unchecked. |
| `guards` | `?list:identifier` | (empty) | Which of dejima's guard rails this harness can host: `bash-deny`, `push-guard`, `merge-guard`, `notify`. A name outside that set is refused rather than treated as an unknown guard, since a typo would otherwise turn a guard off silently. The ones a harness does not declare are named at launch - see "What a harness must be able to do" in [development.md](development.md). |
| `briefing_args` | `?args` | (empty) | The flag that carries the briefing to a harness taking it on the command line - for Claude Code, `--append-system-prompt`. `dejima-harness-wrapper` reads the rendered briefing and passes it as a single value, so every invocation carries it and not only the entrypoint's. Set, the session-start hook stops carrying the briefing and reduces to the session id, which is the one thing that does not exist until the harness starts. |
| `briefing_path` | `?homepath` | (empty) | Where this harness reads its briefing from, relative to `$HOME`, for a harness that has no session-start hook to be handed it through. Set, `entrypoint.sh` renders the briefing into that path root-owned and read-only before the session starts, and refuses to start it at all if that fails. Empty for a harness that registers a hook instead, which is how Claude Code gets it. |
| `briefing_path` | `?homepath` | (empty) | Where this harness reads its briefing from, relative to `$HOME`, for a harness that has no session-start hook to be handed it through. Set, `entrypoint.sh` renders the briefing into that path root-owned and read-only before the session starts, and refuses to start it at all if that fails. Empty for a harness that takes the briefing on the command line (`briefing_args`, which is how Claude Code gets it) or that can be handed it through a session-start hook. |
| `no_confirm_args` | `?template` | (empty) | The flags that make the harness run without confirming each tool call. Required in practice: dejima refuses to launch a harness that names none, because unattended operation is the premise the container is built on (R1 in [development.md](development.md)). |
| `settings_args` | `?template` | (empty) | How this harness is told to read dejima's own settings layer, mounted at `/etc/dejima-settings.json`. Empty for a harness that takes its overrides some other way. |
| `keys` | `?list:keyspec` | (empty) | This harness's own `[harness.<name>]` config schema - same grammar as a forge's `keys`. |
@@ -260,6 +266,74 @@ 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`.
## Model backends
`backends/<name>/backend.conf`. Which model API a session talks to, and how the
credential proxy presents the credential to it.
**"Backend" and not "provider",** which is what the industry usually calls a
model API. This document already calls forge, workflow and harness "provider
axes", and one word for both reads as one thing. A harness keeps its own
vocabulary: `cline` writes a `providers.json` with a `lastUsedProvider` key,
and `bin/harness/cline.sh` translates.
| Key | Type | Default | Meaning |
| --- | --- | --- | --- |
| `upstream` | `?url` | (empty) | Where the proxy forwards to. Required unless `credential` is `none`, which runs no proxy. |
| `api_style` | `enum:anthropic\|openai` | (required) | Which dialect the endpoint speaks. A property of the API rather than of any harness: Anthropic's Messages API and an OpenAI-compatible one are different wire formats. A harness declares which it can speak in `api_styles`, and a launch pairing the two wrongly is refused rather than sending well-formed requests in a shape the endpoint cannot parse. |
| `proxied_domain` | `?hostname` | (empty) | The name the in-container TLS terminator answers for, and what `/etc/hosts` pins to loopback. Required unless `credential` is `none`. On this axis rather than the harness's: a harness reaches whichever endpoint it is configured for, so the name belongs to the endpoint. Declared on the harness, the terminator would answer for one API while the session talked to another, and the credential the proxy exists to withhold would be the one in use. |
| `credential` | `enum:static\|anthropic-oauth\|none` | (required) | Whether there is a credential, and whether it expires. `static` is read and sent; `anthropic-oauth` is refreshed against `oauth_endpoint` and the rotated token written back to the host file, which is what keeps a host-side login working rather than being signed out by a sandbox run; `none` is a model you serve, with nothing to withhold and so no proxy at all. |
| `auth_header` | `identifier` | `Authorization` | Which header carries it. |
| `auth_format` | `authformat` | `Bearer {token}` | How it is written. Must contain `{token}`, so a backend wanting a bare key or a prefix of its own needs no code; a format without it produces a proxy that starts, answers, and sends every request unauthenticated. |
| `strip_headers` | `?list:identifier` | (empty) | Headers that must not travel beside the injected one. `x-api-key` next to an `Authorization` is two answers to the same question, and which one an API prefers is not dejima's to guess. The auth header is never stripped from under itself. |
| `ensure_headers` | `?list:headerpair` | (empty) | `NAME=VALUE` that has to appear in a comma-list header, merged into whatever the client sent and left alone when already present. Anthropic's OAuth beta is the one shipped example. |
| `oauth_endpoint` | `?url` | (empty) | Required by the `anthropic-oauth` kind, meaningless to `static`. |
| `oauth_client_id` | `?identifier` | (empty) | Same. |
| `keys` | `?list:keyspec` | (empty) | This backend's own `[backend.<name>]` config schema, exactly as a forge's works. A backend with a credential is expected to declare `credential_file`. |
| `requires` | `?list:identifier` | (empty) | Which of those must be set for a launch to proceed. |
### What pairs with what
Compatibility is not a list anyone maintains: it is the backend's `api_style`
against the harness's `api_styles`, checked at launch. This table is what those
declarations currently work out to.
| Backend | `claude-code` | `cline` | Credential | You configure |
| --- | --- | --- | --- | --- |
| `anthropic` | yes | yes | OAuth, refreshed and written back | nothing, once you have logged in on the host |
| `anthropic-api-key` | yes | yes | a static key, held on the host | nothing, if Claude Code signed you in; otherwise `[backend.anthropic-api-key] credential_file` |
| `openrouter` | no | yes | a static key, held on the host | `[backend.openrouter] credential_file` |
| `self-hosted` | no | yes | none, and so no proxy at all | `[backend.self-hosted] base_url` |
`claude-code` speaks Anthropic's Messages API and nothing else, so the two
OpenAI-compatible backends are refused with it - at launch, naming both sides,
rather than by sending well-formed requests in a shape the endpoint cannot
parse. `cline` has a client for each dialect, which is what makes it the
backend-agnostic one.
Adding a backend does not change this table by hand: a new directory whose
`api_style` is one a harness already declares works with that harness the day
it lands.
**Which backend a launch uses is the harness's setting**, `[harness.<name>]
backend`, overridable for one launch with `--backend`. Per harness because
that is how it is used: one harness against Anthropic and another against
something else, on the same machine.
No harness ships a default for it, for the reason `[core] harness` has none. A
harness may instead declare `assumed_backend`: what it was talking to before
the key existed, which dejima writes into the operator's config the first time
it finds the key unset, and says so. That is a migration rather than a default
- recorded once in the operator's own file, so a later change to dejima cannot
move a session whose config never changed. A harness only states it where the
value is not a guess; one whose past endpoint depended on other settings
declares nothing, and an unset backend is then a refusal.
**Nothing here is a secret.** A backend declares *where* its credential is
read from - `credential_file` is a path - never what it is. The file it names
is read host-side per request and is never mounted into the container, which
is the property the whole arrangement exists for.
## Tool
Not an axis: a launch selects one forge, one workflow and one harness, and
@@ -440,7 +514,11 @@ every launch:
| workflow | `branch-push` | worktree → push, stops at the branch |
| workflow | `local-only` | **default** - commits locally, pushes nothing |
| harness | `claude-code` | hosts every guard; **not a default** - `[core] harness` must be set |
| harness | `cline` | provider-agnostic, so it can be pointed at an open-weight model served locally; hosts no guards, and needs `[harness.cline] model` set and a CPU with AVX2 |
| harness | `cline` | backend-agnostic, so it can be pointed at an open-weight model served locally; hosts no guards, and needs `[harness.cline] model` set and a CPU with AVX2 |
| backend | `anthropic` | Anthropic on a subscription login; the OAuth token is refreshed and written back to the host |
| backend | `anthropic-api-key` | Anthropic on API billing, whether the key came from Claude Code's console login (`primaryApiKey` in `~/.claude.json`, the default) or a file you hold yourself |
| backend | `openrouter` | a hosted OpenAI-compatible API; a static key, held on the host |
| backend | `self-hosted` | a model you serve; no credential and so no proxy, and the endpoint must be host-local |
### Harness integration maturity
@@ -459,7 +537,7 @@ over, which is a kind of coverage no test suite substitutes for.
**Cline is new, and has barely been used.** Its integration was built
deliberately and is tested, but a test only covers what someone thought to
check. The first run against a live model immediately turned up a bug no test
had: Cline silently discards a provider entry with no `updatedAt` and falls back
had: Cline silently discards a `providers.json` entry with no `updatedAt` and falls back
to its own hosted endpoint, which presents as a connection failure to a host
nobody configured. The first real session turned up a second, in a component
Cline does not own at all - the conversation shipper passed its payload as a
@@ -475,6 +553,41 @@ can be pointed at any endpoint, including a model you serve yourself - and
expect to be the person who finds the next crack.
### Model backend maturity
The same caveat the harnesses and forges get, and for the same reason: four
backends in one table invites the reading that each got the same amount of
use, and they did not.
**`anthropic` is the one dejima was built around.** The credential proxy, the
in-container terminator, the refresh-and-write-back - all of it was designed
against a subscription login and generalised afterwards. It has been used for
real work continuously, which is the coverage no test substitutes for.
**`anthropic-api-key` works and has not been leaned on.** It was verified end
to end on a real machine: the model answers, billing shows as API usage, the
key stays on the host and the container holds only a placeholder. That is one
confirmed launch rather than months of use, and the difference matters for the
same reason it does above.
**`openrouter` is at the same level.** Built from the same manifest shape,
exercised against a fake upstream and through a full launch, and confirmed
against the real endpoint. What has not happened yet is a long tail of
sessions through it, so expect the rough edges that only volume finds.
**`self-hosted` runs no proxy at all**, which makes it the simplest of the
four and the one with least to go wrong in dejima: there is no credential to
withhold, so the session reaches the endpoint directly and dejima's only job
is refusing an address that is not host-local. It has been run against a local
model. What it does not get is any of the credential machinery's protection,
because there is nothing to protect.
If you are choosing with no other reason to prefer one, and you have a
subscription, `anthropic` is the path with the most miles on it. The other
three are supported in the same sense the second harness is: built
deliberately, tested, and newer than the thing they sit beside.
### Forge integration maturity
The same caveat the harnesses get, for the same reason: two forges listed in
@@ -509,13 +622,33 @@ knowing about because they are what a second forge costs:
- **`gh` can be aimed past the sandboxed repository, and is stopped.** The
gitea CLIs refuse an origin that is not on the configured host, so they can
only act on the repo the sandbox was launched against. `gh` takes `--repo`
on nearly every subcommand and reads `GH_REPO`, so this forge declares both
(`repo_flags`, `repo_env`) and `docker/forge-guard.sh` refuses a value that
is not the launched-against repository. Fast-fail, not a boundary, exactly
as `merge_block` is: the session holds the token and could call the API
on nearly every subcommand, reads `GH_REPO`, and names repositories in `gh
api` paths, so this forge declares all three (`repo_flags`, `repo_env`,
`repo_paths`) and `docker/forge-guard.sh` refuses a value that is not the
launched-against repository. Fast-fail, not a boundary, exactly as
`merge_block` is: the session holds the token and could call the API
directly. What actually bounds it is the token's scope, so a per-repository
fine-grained PAT is the expected shape of `push_token_file`.
**What it sees, and what it does not.** Worth stating both, because a guard
described only by what it catches reads like a boundary. It compares, case
-insensitively: `--repo x` and `--repo=x`; `-R x` and `-Rx`; the same letter
inside a short-flag cluster, `-wR x` and `-wRx`; `GH_REPO`; and
`gh api repos/<owner>/<name>/...`, with or without a leading slash. A launch
that resolved no repository at all refuses every one of those rather than
waving them through.
It does not see a GraphQL `mutation` sent through `gh api graphql`, a
repository named in an `--jq` expression or a `-F`/`-f` field, an API path
under a segment other than `repos/`, or `gh` invoked through something else
that passes the repository along. Nor can it know that the workspace's
`origin` is the repository you meant: it compares against whatever origin
the checkout has, so a workspace pointing somewhere unexpected shifts what
counts as "this repository" rather than being caught by this. That list is
not exhaustive either, and it is not meant to be - widening it further would
mean parsing each subcommand's flags, which is the coupling this guard
exists to avoid. The token's scope is what bounds the ones it misses.
So: if you have a choice, `gitea` is the better-worn path. Choose `github`
because that is where your repositories are, which is a good reason - and
expect to be the person who finds the next crack.
+197 -8
View File
@@ -798,13 +798,20 @@ what the outer proxy sends.
## Notifications
`dejima` registers `hooks/dejima-notify.sh` as a `Stop` hook for a
remote-enabled session, into the throwaway settings override layer it already
generates - never into your real `~/.claude/settings.json`. Your own hooks
keep running alongside it, since Claude Code merges hooks across layers rather
than replacing. The layer is mounted read-only, so a session cannot
unregister its own hook. The hook writes one empty file named `done` into
this run's marker directory: no content, no stdin, no network.
**Only for a harness that declares the `notify` guard**, because a
notification needs a session-lifecycle signal to hang off and not every
harness has one. Claude Code does; Cline does not, and a remote-enabled Cline
session gets attach and the web terminal but no push. The launch says which
guards a harness is going without, so that is visible rather than a
notification that silently never arrives.
Where it is declared, `dejima` registers `hooks/dejima-notify.sh` as a `Stop`
hook for a remote-enabled session, into the throwaway settings override layer
it already generates - never into your real `~/.claude/settings.json`. Your
own hooks keep running alongside it, since Claude Code merges hooks across
layers rather than replacing. The layer is mounted read-only, so a session
cannot unregister its own hook. The hook writes one empty file named `done`
into this run's marker directory: no content, no stdin, no network.
**One push per stop, and not a second one a minute later.** The hook and the
watcher both understand a `waiting` event as well, and Claude Code's
@@ -934,7 +941,12 @@ the next one is worth checking.
with a 404 usually means `ntfy_url` names the server but no topic; a 401
or 403 means the instance wants credentials, which the URL can carry as
`https://<user>:<token>@<host>/<topic>` - nothing here sends an auth
header of its own.
header of its own. That URL is handed to `curl` on stdin rather than as an
argument, because every argument of every process on a host is readable by
any local account through `/proc/<pid>/cmdline`, and a token in a URL is a
token in `ps` output for as long as the request runs. Worth knowing if you
reach for `curl -sv "$ntfy_url"` yourself in the next step: that one does
put it there, briefly, on a machine you are already logged into.
5. **Can this host reach the ntfy server at all?** `curl -sv "$ntfy_url"`
from the host, not from a session. A host-level egress filter this machine
does not control drops the push with nothing in any log here, and every
@@ -1005,3 +1017,180 @@ from shrinking the desktop's view, and the option's only scopes are the
window - which a grouped session *shares* with yours, so setting it there
would permanently overwrite your choice with nothing to restore afterwards -
and global. An operator who deliberately set `smallest` keeps it.
## Mounting the box's files
The front doors above get you a terminal. The other half of working from
another machine is the files - the screenshots, specs and scratch output a
session leaves behind - and `sshfs` puts those directories in your own file
manager and editor over the SSH access you already have.
Read the next section before setting it up. What it costs is not obvious from
the fact that it is only a filesystem mount, and it is not a cost dejima can
carry for you: nothing dejima runs is in the path.
### What mounting it costs you
**Every byte and every name in those directories was written by a sandboxed
session.** That is what they are for - a handoff directory a session cannot
write to is not a handoff directory - so this is not a defect to fix but the
surface to know about. dejima sanitises session-authored bytes before
printing them to your terminal, in `dejima-audit` and `dejima-epitaph`,
because that is the one surface it controls. A file manager on another
machine is not that surface, and nothing carries the sanitising there.
**Mounting removes the deciding.** Copying a file off the box when you want
it is a decision per file. A mount, and especially one a login unit brings up
for you, means the directory is simply present whenever you are logged in -
and a desktop does not wait to be asked. A file indexer walks new directories and
opens what it finds to extract text from PDFs, archives and office documents.
A thumbnailer decodes images and video frames. A previewer parses on
selection. Those are image and document parsers, running as you, unsandboxed,
reached with no click. dejima's whole premise is that the session may be
hostile; none of that software was written with that assumption about a
directory on your own disk. This is the [threat
model](threat-model.md#ranked-by-what-is-actually-likely)'s fifth-ranked item
with its one bound removed - it is still the operator's side of the handoff,
but no person decides per file any more.
**Names are session-chosen too, and a desktop renders names.** Control
characters, bidi overrides, and a long name whose visible tail is not its real
extension all survive into a file listing. The rule for a terminal applies
unchanged to a file manager: what is displayed is not evidence of what it is.
**Opening the directory as a project is code execution.** Editors act on
configuration they find beside the files - `.envrc` for direnv, a `.vscode`
directory, a project-local `exrc`, a `.git/config` that any later `git`
command run in that directory honours. A session that can write the directory
chooses those files as freely as it chooses the screenshots. Pull files out of
the mount; do not point a project-opening tool at it.
**The mount is read-write, and your desktop uses that.** Thumbnail caches,
trash directories, editor swap and undo files land in the share, where every
later session reads them. An editor swap file can hold buffer content from
the file you had open, and deleting something in a file manager usually moves
it to a trash directory inside the same mount rather than removing it.
**Symlinks resolve on your machine.** `sshfs` passes a symlink through as a
symlink, so one a session planted resolves against *your* filesystem: a
backup job, an indexer, `cp -r` or `rsync -L` pointed at the mount can be led
out of it and into your own files. `-o follow_symlinks` inverts that by
resolving them on the box instead, which hands you the box's files rather
than yours. Neither is what you want, so do not add that option and do not
point recursive tools at the mount.
**What it does not add is a route inward.** A session cannot see the mount,
cannot cause one, and gains nothing from the fact that one exists. The ssh
credential involved already gets you a shell on that box, so it is not a new
grant either. Everything above is about where session-authored bytes end up,
which is a machine that appears nowhere in dejima's blast-radius table.
**Keeping it small** is most of the answer, and unmounting is the biggest
lever: none of the above happens to a directory that is not currently there.
Then mount the handoff directories rather than the box's home, add `-o ro` for
one you only ever pull from, exclude the mount point from your indexer's and
your backup tool's scope, and [clear `share_dir` out
periodically](configuration.md#shared-scratch-space) - which now means stale
files on two machines rather than one.
### Setting it up
**Use your ordinary key, not the front-door one.** A `command=` in
`authorized_keys` replaces whatever the client asked to run, and a
`subsystem sftp` request is not an exception, so a client offering the phone
key gets `dejima-attach` where it expected the SFTP protocol and fails
without explaining itself. Do not relax that key to make this work: the
forced command is the whole of what makes it safe to leave on a phone. The
key that already gets you a normal shell on the box is the right one, and it
grants nothing new here - you had the shell.
**Find the host path first.** `~/shared` is where the *container* sees it; on
the host it is one project's directory under `[core] share_dir`, which defaults
to `~/.local/share/dejima/shared` and is not named `shared` at all.
`dejima --print-config | grep share_dir` on the box prints the resolved root,
and `dejima-share list` prints what is under it. Mount `<share_dir>/projects`
to get one directory per project with no loose files mixed in, or a single
project's directory - `dejima-share path <project>` - to keep the mount to the
one you actually pull from.
Anything in `[mounts] read_write` needs no lookup, because those mount at the
host path they name: `~/specs` in the container is `~/specs` on the box.
```sh
sshfs box:.local/share/dejima/shared/projects ~/mnt/dejima-shared \
-o IdentityFile=/home/<you>/.ssh/id_ed25519,IdentitiesOnly=yes \
-o PasswordAuthentication=no \
-o reconnect,ServerAliveInterval=15,ServerAliveCountMax=3 \
-o Compression=no
```
`IdentitiesOnly=yes` stops ssh offering every key your agent holds ahead of
the one you named, which can exhaust the server's `MaxAuthTries` and fail as
though the key were wrong. `PasswordAuthentication=no` makes an unattended
mount fail immediately rather than block on a prompt nobody is there to
answer. The keepalives are what make `reconnect` worth having: they give up
after about 45 seconds, where a laptop that slept or changed network
otherwise leaves every `ls` hanging on the kernel's TCP timeout.
`Compression=no` because screenshots and PDFs are already compressed.
Two additions worth knowing rather than copying. `-o
Ciphers=aes128-gcm@openssh.com` is faster when both ends have AES-NI, and
slower when they do not - ssh's default puts `chacha20-poly1305` first
precisely because it wins on hardware without it. And `-o idmap=user` if your
account has a different UID on the two machines, or every file arrives owned
by whoever holds that number locally.
A `Host box` block in `~/.ssh/config` carrying the identity and keepalive
options keeps all of this to `sshfs box:... ~/mnt/...`, which is worth doing
once rather than pasting the long form wherever you need it.
**Mount it when you want it, and `fusermount3 -u` when you are done.** Two
commands is not much friction, and it buys back the thing the section above
says a mount costs: while it is unmounted there is no directory for an
indexer to walk or a previewer to open, so the window in which your machine
acts on session output is one you opened on purpose. Every automatic form
below trades that away. There is deliberately no `/etc/fstab` line here -
those run as root, which means a key root can read, root's `~/.ssh/config`
instead of yours, and `allow_other` before your own account can see inside
the mount, at which point every account on the machine can too. That is a
worse deal in every direction than typing `sshfs`.
### On login
**Prefer mounting on demand.** This is here because a mount you have to
remember is a mount you stop using, not because it is the better answer: an
always-present directory is exactly the case the cost section is about. Worth
it for a directory you genuinely work out of, like `~/specs`; not worth it
for one you look at twice a week.
A **user** service runs as you, so the key in your home directory works
without being made readable by anyone else. In
`~/.config/systemd/user/dejima-shared.service`:
```ini
[Unit]
Description=dejima shared directory
Wants=network-online.target
After=network-online.target
[Service]
ExecStart=/usr/bin/sshfs -f box:.local/share/dejima/shared %h/mnt/dejima-shared \
-o IdentityFile=%h/.ssh/id_ed25519,IdentitiesOnly=yes \
-o PasswordAuthentication=no \
-o reconnect,ServerAliveInterval=15,ServerAliveCountMax=3
ExecStop=/bin/fusermount3 -u %h/mnt/dejima-shared
Restart=on-failure
RestartSec=10
[Install]
WantedBy=default.target
```
`-f` is the load-bearing flag: without it `sshfs` daemonises, systemd watches
the parent exit and calls the mount a failure. `fusermount3` is the libfuse3
name, `fusermount` the libfuse2 one. Then
`systemctl --user enable --now dejima-shared`, and one unit per directory -
`%h` expands to your home, so the same file with two paths changed covers
`~/specs`.
`systemctl --user` needs `loginctl enable-linger <you>` if you want the mount
to survive you logging out, the same requirement `dejima-notify-watch` has.
+5 -1
View File
@@ -57,7 +57,11 @@ doing any work.
hostile?** The field says no for untrusted code, and tiers gVisor and
per-workload VM kernels above it. dejima's answer is that escape moves a
layer - true where the layer exists. See
[Which deployment this describes](threat-model.md#which-deployment-this-describes).
[Which deployment this describes](threat-model.md#which-deployment-this-describes)
and [Why a container, and not a microVM](threat-model.md#why-a-container-and-not-a-microvm),
which is where the reasoning is written down so a sweep can contradict it.
The live half of this question is question 5: whether a current escape
clears this configuration's floor.
2. **Does a name-based egress allowlist hold?** It holds against an agent that
must resolve a name. It does not hold against exfiltration through an
allowed domain or against DNS query-name channels, both of which are listed
+260 -29
View File
@@ -277,6 +277,9 @@ a line added to a manifest widens the egress boundary on the next run.
container: it names a tarball URL, the sha256 checked against it and the
layout it is unpacked with, all consumed by the image build - so a session
that edits one chooses what the next launch downloads and installs as root.
`backends/*/backend.conf` names the upstream every request is forwarded to and
the host path the credential is read from, so an edit there redirects where a
real credential is sent.
An ordinary edit is a diff you are about to read and is not reported; one git
cannot see - hidden by `.gitignore`, `.git/info/exclude`, `assume-unchanged`
@@ -288,29 +291,31 @@ can make; it is one the audit reports. See
- Running the harness with its permission prompts off removes your chance to
review tool calls before they run. Only point this at trusted repositories.
- The mounted project itself has **no write protection**. `deny-dangerous-
bash.sh`, `git-push-guard.sh` and `merge-guard.sh` are regex
pattern-matching on the literal Bash command string - a fast-fail guard
against an overeager agent doing something obviously destructive, not a
boundary against one that's actively trying to get around them (a
different scripting language, an encoded command, editing `.git/config`
directly all sail right through).
Nothing here stops a compromised or badly-instructed session from
modifying or deleting the real working copy it's mounted against.
- The mounted project itself has **no write protection**. The guards are
fast-fail checks against an overeager agent doing something obviously
destructive, not a boundary against one actively trying to get around them.
`deny-dangerous-bash.sh` is regex pattern-matching on the literal Bash
command string, so a different scripting language, an encoded command or
editing `.git/config` directly all sail right through. The push and merge
guards shadow `git` and the forge CLI on `PATH` and read argv, so quoting
and pipelines stop mattering - but a session that means to get round one can
call `/usr/bin/git` directly. Nothing here stops a compromised or
badly-instructed session from modifying or deleting the real working copy
it's mounted against.
- That same read-write access to the mounted project is also a
host-code-execution vector, not just a data-destruction one: a
compromised session could plant `.git/hooks/post-commit` (or
`pre-push`), set `core.hooksPath`, or write a
`.claude/settings.local.json` with a malicious hook command, into the
project. Any of those run unsandboxed on the host the next time you run
`git` or open Claude Code in that repo - no network egress required, so
`git` or open your harness in that repo - no network egress required, so
the firewall doesn't see it either. Same underlying persistence pattern
as the `~/.claude/hooks` and `~/.claude.json` risks above, just through
as the harness config risks above, just through
the project mount instead of the config mount. "Only point this at
trusted repositories" (above) covers input trust; this is about the
sandbox writing a persistence payload into an otherwise-trusted repo.
The workspace's own `.git/hooks` directory is mounted read-only
(same pattern as the `~/.claude` code directories below), so planting a hook
(same pattern as the harness's own code directories below), so planting a hook
file directly is closed - accepted breakage: installing hooks
*in-session* (`husky`, `pre-commit install`) fails, which is the
point. `core.hooksPath` and `.claude/settings.local.json` aren't
@@ -495,6 +500,16 @@ approximation of its name.
## One project's session cannot reach another's
The property is the same for every harness; the mechanism is not, because it
depends on how the harness keys its own state. Claude Code stores a directory
per project and dejima mounts a slice of it. Cline stores one flat database
indexing every session it has ever run, with the project as a field inside the
record, so there is no slice to take - dejima gives each workspace a data
directory of its own instead. Both reach the same place: a session sees its
own project's history and no other's.
### Claude Code: a slice of your own tree
- `~/.claude/projects` (session transcripts and per-project memory) is
**not** mounted as a tree. A session gets the workspace's own slug
directory plus one for each live git worktree nested under it,
@@ -548,6 +563,31 @@ approximation of its name.
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.
### Cline: a directory dejima owns, one per workspace
- `~/.cline/data` is **not** mounted. Cline indexes every session it has ever
run in one `sessions.db`, with the project a session belonged to as a field
inside the record rather than a directory above it, so there is no
per-project subtree to slice: mounting your data directory would put every
project's transcripts in every session, which is this section's whole
subject.
A launch instead creates `<[harness.cline] state_dir>/<workspace slug>` on
the host and mounts *that* at `~/.cline/data`. Every session under it belongs
to this project by construction rather than by a filter that could be wrong,
which is why Cline clears this requirement more cleanly than Claude Code
rather than less.
The cost is that the directory is dejima's rather than yours: a session
started inside the sandbox is not offered back to a host-side Cline, and one
you started on the host is not visible inside. There is no import or export
([#301](https://gitbox.branderson.io/dejima/dejima/issues/301)), and the
briefing tells the session so, so it will not offer you a resume command that
cannot work.
### Whichever harness is selected
- 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
@@ -555,8 +595,177 @@ approximation of its name.
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.
- `[core] share_dir` is **not** mounted whole. A launch mounts
`<share_dir>/projects/<slug>` at `~/shared`, so a session sees its own
project's handoff files and no other project's - the same slicing as
`projects/` above, applied to the directory that was the remaining way
around it. A handoff file is session-authored by design, and one project's
session writing a plausible-looking note that a session in another project
then picks up is #104's channel by a different route.
The per-project split bounds *which* sessions, not *how long*: a project's
share persists, so everything in it is readable by every later session
against that project. Nothing prunes it, which is why
[docs/configuration.md](configuration.md#shared-scratch-space) says to clear
it out and `dejima-share list` prints what each project is holding.
`<share_dir>/projects` is in the guarded list, so an operator cannot hand
the whole of it back through `[mounts] readonly` or `[mounts] read_write`.
The session's own share arrives at `~/shared` without going through either.
## The model API credential never enters the container
### Four arrangements, and what a session can take from each
Which one a launch is in comes from the selected backend's `credential` kind
and from whether the proxy is on. The differences are worth stating together,
because only the last one puts a durable credential where a session can read
it.
| Arrangement | In the container | What a compromised session can take | What it can still do |
| --- | --- | --- | --- |
| **Proxied, refreshing** (`credential = anthropic-oauth`) | A fake credential file whose secrets are placeholders. The real one is read host-side per request. | The placeholder. | Use the API as you, through the proxy, while the session runs. |
| **Proxied, static key** (`credential = static`) | A placeholder key in the harness's own model configuration, and the endpoint's real hostname. | The placeholder. | The same. |
| **A backend with no credential** (`credential = none`, a model you serve) | Nothing but the endpoint's address. It needs no credential, so there is none to withhold. | Nothing. | Reach that endpoint, which is why it has to be one you allowlisted. |
| **No proxy** (`[credentials] proxy = off`, or a Claude Code login the selected backend cannot serve) | The real credential file, mounted. | **The credential itself**, durable and usable offline. | Everything the credential allows, for as long as it is valid. |
**The first three keep the credential out; the fourth does not.** That is the
whole of the difference, and it is why `proxy = off` is described here as a
kill switch rather than as an option.
**Both Anthropic billing models are in the first three.** A subscription login
is `backends/anthropic`, whose OAuth token the proxy refreshes and writes back;
a console key is `backends/anthropic-api-key`, a static credential against the
same endpoint presented as `x-api-key` - one backend whether the key came from
Claude Code's own login or a file you hold. Either way the credential is held on
the host and injected per request. On the API-key path the container gets
`ANTHROPIC_API_KEY=sandbox-credential-proxy-placeholder` and no credential file
at all.
What lands in the fourth row is `proxy = off`, and a login whose shape no
selected backend serves.
**The proxy is switched per backend, not globally.** `[backend.<name>] proxy`
falls back to `[credentials] proxy` when unset. One backend whose credential
the proxy cannot serve is then an opt-out for that backend, rather than a
reason to stop withholding credentials from the ones it serves. Turning it off
for a backend prints what the session is being given, because it is a
downgrade the operator chose rather than a setting that happens to be off.
**Everything dejima copies in is scanned for credential-shaped values it does
not recognise, and an unknown one is a question rather than a surprise.** The
scanner reports a redacted path, a shape name, the harness and the backend -
never the value, and never a real path, since a gitconfig can carry userinfo
in a section header. **Finding it is best effort**: this recognises common
credential shapes and can miss an unusual one. Removing one it has found is
not - a value it names is removed whole, including one split across
continuation lines.
The choice is yours: keep it out for this launch, let it in for this launch,
let it in every launch, or stop. Only the third persists, in
`credential-scan.decisions` beside your config, keyed by field rather than by
value so rotating a credential does not re-ask a question you already
answered. A standing *scrub* is available there too but is not offered at the
prompt: removing something the harness turns out to need would break every
launch after, with the cause several layers from the symptom. With nothing
attached to answer, the launch stops rather than carrying it in.
This is the general form of the two fixes below, which are denylists and so
only know what was already found. See
[#291](https://gitbox.branderson.io/dejima/dejima/issues/291).
#### What the scan does not catch
It recognises the *shape* of a credential, not its meaning, so it is a
backstop against the class of leak that has actually happened here twice -
not a guarantee that nothing crosses. Specifically:
- **Values containing whitespace.** Both heuristic rules require one token
from a restricted alphabet, because the alternative flags every sentence in
a config file: English prose scores 4.0-4.5 bits per character by the same
entropy measure a random key does. People do put spaces in passphrases they
chose themselves, and one of those would go straight through. It is a
deliberate trade and it fits these files, which hold credentials issued to a
machine rather than typed by a person.
- **Short or low-entropy secrets in fields not named like one.** A token that
is eight lowercase letters in a field called `nickname` is indistinguishable
from a nickname.
- **Anything in a format it cannot parse.** JSON and INI only. An unparseable
file is copied unchecked, and says so at launch rather than being silently
trusted or silently blocked.
- **Anything dejima does not copy.** This covers the files dejima *stages* -
`~/.claude.json` and `~/.gitconfig`. It does not read your read-write
mounts, the workspace, or anything else you have pointed a session at, and a
credential you leave in one of those is not something dejima is looking for.
The value of it is not that it is exhaustive. It is that a credential shape
dejima has never seen becomes a question at launch instead of a copy nobody
noticed for a year.
**Two host files are copied into a container, and both are filtered.**
`~/.gitconfig` can hold a `credential.helper` that prints a token, an
`http.<url>.extraHeader` carrying an Authorization line, or userinfo inside a
`url.<...>.insteadOf` key; none of that is part of what the sandbox is
granted, so the copy drops it and says so. The general problem - that these
filters are denylists, and a harness's authentication is not a stable
interface - is [#291](https://gitbox.branderson.io/dejima/dejima/issues/291).
**`~/.claude.json` is staged, not mounted, and the staging strips a
credential.** That file mixes app state with configuration the copy exists to
carry, and a Claude Code signed in with a console API token keeps its key
there as a top-level `primaryApiKey` - not in `.credentials.json`, which stays
`{}` for that login. The copy drops that key, and `backends/anthropic-api-key`
reads it host-side per request instead. Without it the proxy would
withhold one file while dejima copied another, on every launch and whatever
backend was selected.
**The residual the first two share is use-while-running.** A session that has
been turned can spend your quota, ask the model anything, and read the answers
- through the proxy, without ever holding the credential. That is inherent to
the sandbox working at all: the session has to be able to reach the model, and
anything it can reach it can misuse. What the proxy buys is that the misuse
stops when the session does, and nothing durable leaves.
**Refreshing and static differ in what a leak elsewhere costs, not in what the
container sees.** Neither enters it. But an OAuth access token is short-lived
and the proxy rotates it, while an API key is typically long-lived and
account-wide - so the host-side file is worth more in the static case, and a
compromise of the *host* is correspondingly worse. Scope the key at the
backend where you can, the same argument
[docs/providers.md](providers.md) makes for a repository-scoped forge token.
**The proxied domain is not in the egress allowlist while the proxy is on.**
`docker/fixed-domains.sh` adds it only when there is no proxy, so a session
that tries to reach the API by name finds it unresolvable and goes through the
terminator instead. Worth being precise about the limit: this defeats a lookup,
not a route. The API's address stays reachable as an IP literal for as long as
any other allowlisted name resolves to it.
**A hosted OpenAI-compatible backend is the second arrangement, and it is
what `backends/openrouter` is.** The key sits in a file on the host, named by
`[backend.openrouter] credential_file` and read per request; the container
gets `sandbox-credential-proxy-placeholder` and the endpoint's real hostname,
which the in-container terminator answers for. Nothing about the arrangement
is visible to the session, which is the same property the Anthropic path has
and the reason both are worth having rather than one.
Worth naming what a *hosted* backend adds over Anthropic's OAuth, since it is
not the container that changes: an API key is typically long-lived and
account-wide where an access token is short-lived and rotated, so the host-side
file is worth more. Scope the key at the backend where it offers that, the
same argument this repository makes for a repository-scoped forge token.
**A backend you serve yourself is the strongest posture available**, and it is
worth saying so plainly rather than treating it as the degraded one. A session
pointed at a model on your own network has no credential anywhere in the
picture, so there is nothing to place, nothing to withhold and nothing to
steal. What it costs is that the model is yours to run.
It is a backend like any other - `backends/self-hosted`, with
`credential = none` and its address in `[backend.self-hosted] base_url`. That
placement is deliberate: an operator who serves a model locally and also uses a
hosted API keeps both configured and switches by changing one word, rather than
having to unset one setting to stop it overriding another.
**This assumes the host has one.** dejima authenticates nothing: the proxy
holds a credential the harness wrote on the host, so a machine where the
harness has never signed in has nothing for it to hold. The proxy then does
@@ -567,11 +776,19 @@ prerequisite. Sign in on the host first; see the README's
Refreshing an expired credential is the same: on the host, not from inside a
session, where the credential deliberately never goes.
### The refreshing OAuth path, in detail
The `backends/anthropic` arrangement, at the level of what the proxy actually
does. The machinery is not Anthropic's - the credential's kind, which header
carries it, what must not travel beside it are all arguments a backend
supplies - but it is worth reading through one backend rather than in the
abstract, and this is the one with the most miles on it.
- The Anthropic OAuth credential (`~/.claude/.credentials.json`) never
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
(`bin/dejima-credential-proxy`) on a per-session unix socket and mounts a
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
@@ -579,7 +796,7 @@ session, where the credential deliberately never goes.
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
(`credential-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
@@ -588,7 +805,13 @@ session, where the credential deliberately never goes.
`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
`Authorization`, and `anthropic-beta` if the OAuth value isn't there.
Neither of those is a name the proxy holds: which header carries the
credential, what shape it takes, what must not travel beside it and which
comma-list value has to be present are all arguments, and `bin/dejima`
supplies the Anthropic ones (#171). The credential kind is an argument too -
a refreshing OAuth file, or a static key that is read and sent - so a
backend that cannot be refreshed is not a branch here. 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
@@ -617,7 +840,7 @@ session, where the credential deliberately never goes.
after an upgrade, as `UNABLE_TO_VERIFY_LEAF_SIGNATURE`,
`ERR_TLS_CERT_ALTNAME_INVALID`, or a pin-specific error. What
distinguishes pinning from a merely broken certificate is that
`curl --cacert /etc/dejima-anthropic-tls/cert.pem https://api.anthropic.com/v1/messages`
`curl --cacert /etc/dejima-credential-tls/cert.pem https://api.anthropic.com/v1/messages`
still succeeds from inside the container while Claude Code does not: the
certificate is fine, the client is refusing it. **This is not a reason to
set `[credentials] proxy = off`.** That kill switch mounts the real,
@@ -646,7 +869,13 @@ session, where the credential deliberately never goes.
real credential into the container. Rung one is a supported, tested mode,
not an improvised workaround; if you reach the third, treat it as a
temporary state rather than a new baseline.
## What the container sees of `~/.claude`
## What the container sees of Claude Code's `~/.claude`
One harness's inventory, because there is no shared one to give: what a
harness keeps where, and which of it is safe to mount, is the harness's own
answer and each plugin gives it. Cline's is the table in
[configuration.md](configuration.md#what-comes-in-from-your-host); the
reasoning below is what both are built on.
- `~/.claude` is **not** shared with the container as a whole. Its contents
are enumerated in `docker-compose.yml`, in three classes, and anything not
@@ -669,10 +898,10 @@ session, where the credential deliberately never goes.
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
hook - including by unlinking one of `install.sh`'s hook symlinks and
dropping a plain file in its place, since the dynamic per-target mounts
only protect symlink targets that resolve *outside* `~/.claude`, never
this directory itself - tamper with plugin code, or drop a malicious
hook - including by unlinking one of your own hook symlinks and dropping a
plain file in its place, since the dynamic per-target mounts only protect
symlink targets that resolve *outside* `~/.claude`, never this directory
itself - tamper with plugin code, or drop a malicious
skill/command/agent/output-style file. Any of those would then run or
load **unsandboxed on the host** the next time Claude Code starts
there, turning a single compromised sandbox session into persistent
@@ -917,11 +1146,13 @@ 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)
- `registry.npmjs.org`, plus whatever the selected harness declares as its own
(`platform.claude.com` for Claude Code; Cline declares none, so a session
falling back to Cline's hosted endpoints fails to connect, which is the
right outcome); `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); and the selected backend's API endpoint when
the credential proxy is off for it
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.
@@ -1035,9 +1266,9 @@ 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.
The session briefing 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.
### The firewall bounds the container, not the harness
+163 -7
View File
@@ -79,8 +79,29 @@ The ordering is the point. Nothing above the last item requires an exploit.
Item 5 is not an argument for making the handoff harder; a session that cannot
ask is a session that works around you instead. It is an argument for being
able to answer, afterwards, what that actually did to the machine - which is
the layer below this repository's, and the reason the sister project is
looking at detection rather than at more enforcement.
the layer below this repository's, and the reason `dejima-guard` exists and
looks at detection rather than at more enforcement.
**The handoff directories are item 5 with the person taken out.** `[core]
share_dir` and everything in `[mounts] read_write` exist so that session
output survives the container, so every byte and every filename in them is
session-authored by design. On the box that is bounded by how rarely anyone
looks. Mount one on a workstation - over sshfs, or by any sync that runs on
its own - and the directory is simply present, at which point a desktop opens
what appears in it without being asked: an indexer parsing documents and
archives to extract text, a thumbnailer decoding images, a previewer, an
editor honouring project configuration it finds beside the files. Each is a
parser running as the operator, outside the container, reached with no
decision per file. Item 5's last sentence is that a person decides; here
nobody does.
It stays item 5 rather than becoming a row of its own, because the shape is
the same: the session initiates none of it. It cannot see the mount, cannot
cause one, and the ssh credential involved already gave that operator a shell
on the box. What changes is where session-authored bytes get opened, and the
machine they get opened on is not a row in the table below.
[remote-access.md](remote-access.md#what-mounting-it-costs-you) has the
specifics and what shrinks them.
## Blast radius by layer
@@ -122,8 +143,31 @@ can see.
## Where dejima's boundary ends
dejima runs inside the machine it is protecting the rest of, which is the
whole reason there are two other repositories. It cannot bound its own host's
network position, and it cannot bound a runner it never touches.
whole reason there are three other repositories. It cannot bound its own host's
network position, it cannot bound a runner it never touches, and it cannot tell
anyone that something changed after the session ended.
### The stack, and what each piece buys
**Only the first row is required.** Everything under it is what the layer
below the container costs, and every one of them can be substituted or left
out - the last two are categories rather than products.
| Piece | Runs on | What it buys | Without it |
| --- | --- | --- | --- |
| **dejima** | the devbox | The container boundary: what a session reaches on disk, and deny-by-default egress. | An agent with your home directory and the open internet. |
| **dejima-devbox-hardener** | the same devbox, enforced from outside the guest | Bounds where an escape *spreads*: LAN reach cut to what is declared, and root in the VM cannot flush the rule set. | An escape reaches the LAN, and the row below the container is unbounded. |
| **dejima-guard** | a different machine | Notices the devbox changed: file hashes, enabled units and listening ports, diffed against a baseline held off the box. Detection, not enforcement. | Anything a session leaves behind survives unremarked, because nobody is looking at the devbox between sessions. |
| **self-hosted CI + dejima-ci-hardener** | a runner VM | A runner that executes a session's pull request without that being LAN access, and a rollback that expires what an escape gained. | CI runs attacker-controlled code with the network position of whatever host it sits on. |
| **or third-party hosted CI** | someone else's machine | The same isolation, bought rather than run - and on their network rather than yours. | - |
| **ntfy, or any push service** | anywhere both can reach | The channel dejima's "session needs input" and the guard's alerts both arrive on. | A session that stopped to ask, and an alert that fired, both wait for someone to think to look. |
| **Uptime Kuma, or any uptime monitor** | off the devbox | The dead man's switch: it is what notices the *guard* has stopped reporting. | Silence is indistinguishable from health, which is the failure mode every watcher has. |
The last row is the one most easily skipped and the one that decides whether
the rest is real. A watcher that has died looks exactly like a watcher with
nothing to report.
### Each project's own boundary
**[dejima-devbox-hardener](https://gitbox.branderson.io/dejima/dejima-devbox-hardener)**
covers the two VM rows: what an escape spreads to, rather than whether it can
@@ -138,14 +182,40 @@ Nothing there tries to stop the code running. Its
[threat model](https://gitbox.branderson.io/dejima/dejima-ci-hardener/src/branch/main/docs/threat-model.md#blast-radius-by-layer)
has the two tiers and where each one's boundary sits.
Neither is required to run dejima, and the container row holds without them.
What they change is what the row below it costs.
**[dejima-guard](https://gitbox.branderson.io/dejima/dejima-guard)** watches a
devbox from a machine that is not the devbox and alerts on what changed. It
enforces nothing: everything it reports has already happened. It collects over
SSH, diffs against a baseline held on the watching machine, and alerts from
there. That placement is the point, since a baseline stored on the box being
watched is a baseline the thing you are watching for can edit, and it
complements devbox-hardener rather than overlapping it - one bounds where a
compromise spreads, the other notices that one happened.
**It is the only piece here that adds a machine holding credentials rather than
taking capability away.** Every other row removes something: what a session
reaches, where an escape spreads, what a runner can talk to. This one
introduces a host holding an SSH key to the devbox and a token for the alert
channel, and its exposure is bounded by the hardening of a machine this stack
does not describe or require anything of. That is the thing to weigh before
running it.
**Its detection ceiling is root on the watched box.** The collector runs on the
devbox, so root there can return whatever it likes, including the baseline
unchanged. What the guard catches is accidents, non-root compromise, and
payloads that did not think to look, which is most of them. The heartbeat
proves the guard ran, not that it saw the truth.
[Its own `SECURITY.md`](https://gitbox.branderson.io/dejima/dejima-guard/src/branch/main/SECURITY.md)
has the rest, including the ways it is deliberately narrower than dejima.
None of the three is required to run dejima, and the container row holds
without them. What they change is what the row below it costs, and whether
anyone finds out.
**A survey of comparable projects found none with rows below the container at
all** - their boundary is between the agent and the host, and what is under
the host is outside their threat models. The same survey found nothing
treating a forge or a CI runner as part of an agent's blast radius, which is
what the second sister project exists for: a session that can open a pull
what dejima-ci-hardener exists for: a session that can open a pull
request can execute code on a machine dejima never touches.
Recorded as an observation from one day of secondary sources rather than as a
@@ -193,6 +263,80 @@ repository's behalf.
**What this repository can do about that is say so**, which is the whole of
this section. Nothing in dejima can bound its own host.
## Why a container, and not a microVM
The most frequent question about this project.
The short answer is that a microVM is the stronger boundary and nothing here
disputes it. Container escape is the least likely of the six threats above and
the only one that needs an exploit at all, and the layer it lands on is already
bounded by a control enforced from outside the guest. The effort went there
rather than into a harder container.
**There is already a VM here**, which is what the usual framing misses. It is
put as container versus microVM, as though the alternative were a container
with nothing underneath. On the deployment this document describes, dejima runs
inside a devbox VM whose rule set is enforced from the hypervisor. So the choice
is not one boundary against two. It is an outer VM enforced from outside the
guest, against that plus an inner one managed by software running on the box it
is protecting - inside the blast radius of the row it is meant to bound. A
boundary enforced from outside is worth more than one enforced by the thing
inside it, and that is most of the answer.
**The field's ordering is not in dispute.** gVisor's syscall interposition and
a per-workload VM kernel are both stronger than a namespaced container.
Firecracker is what AWS runs Lambda on, for the reason that applies here too:
untrusted code, multi-tenant, on shared hardware. The disagreement is about
where marginal effort goes, not about which primitive is stronger.
### What a stronger runtime would and would not change
It would not touch the five threats ranked above escape. Two of them would get
*harder*, because both are enforced by how the container is configured rather
than by what isolates it:
- **One project's session cannot reach another's**, because each path crossing
the boundary is named individually rather than mounting a tree
([#104](https://gitbox.branderson.io/dejima/dejima/issues/104)). Under a VM
each becomes a filesystem daemon.
- **The container sees an agent socket, never your key.** That is a live host
socket, and a VM has no equivalent to hand it one.
One would get easier: egress rules on a tap device sit where the guest holds no
capability to touch them. That is available without changing the runtime, which
is what [#305](https://gitbox.branderson.io/dejima/dejima/issues/305) does.
**And "swap the runtime" understates the work**, because several controls are
implemented *in* Docker rather than merely hosted by it. The dependency
approval is a derived image build; the tooling reaches live sessions through
it; the capability floor is an argument about Docker's model. Porting them is a
rewrite of the parts that *are* the security argument, in a project that says
at the top that none of it has been reviewed by anyone else.
### What would change the answer
The decision is a judgement about effort, not a door held shut, and these are
the things that would reopen it:
- **Kata Containers is the cheap path if this reasoning is wrong.** It is an
OCI runtime, so the compose file, the mounts and the tooling survive, and it
can use Firecracker underneath. A per-launch switch rather than a fork.
- **Pointing dejima at projects chosen by someone else.** That contradicts an
assumption stated at the top of this document, and reorders the ranked list.
- **A container escape in the wild that clears this configuration's floor**:
non-root session user, `no-new-privileges`, `cap_drop: ALL` and a short list
added back. The class recurs, and whether a live one clears that floor is
asked on a schedule in
[research-review.md](research-review.md#the-standing-questions) rather than
assumed either way.
What is done instead is depth below the container, which is
[the stack](#the-stack-and-what-each-piece-buys): two of the three layers under
dejima are enforced where the guest cannot reach them, and the third bounds a
compromise in time. A sandbox that reaches for a microVM and stops there has
nothing underneath it, so an escape from the VM ends its argument. Here it is a
row with controls of its own.
## What this repository does not claim
- **That the egress allowlist bounds what reaches the model.** It bounds what
@@ -206,12 +350,24 @@ this section. Nothing in dejima can bound its own host.
belongs in that harness's assessment.
[security.md](security.md#the-firewall-bounds-the-container-not-the-harness)
has the evidence.
- **That a session cannot use the model API as you while it runs.** It can.
The credential proxy keeps the credential on the host, so nothing durable
leaves and the misuse stops when the session does - but a session that has
been turned can spend your quota and read whatever it asks for, through the
proxy, without ever holding the credential. That is inherent to the sandbox
working at all. [security.md](security.md#four-arrangements-and-what-a-session-can-take-from-each)
has the four arrangements and what each one puts in the container, including
the one that puts the credential itself there.
- **That escape is impossible.** It is a row in the table with controls of its
own, not an outcome that has been designed away.
- **That the hooks stop a hostile agent.** They stop an unlucky one.
- **That a finding from the exit-time audit was caused by the session.** It
reports that a surface changed while the session ran. A concurrent session
or the operator's own editing lands in the same diff.
- **That what a session leaves in a handoff directory is safe for another
machine to open.** `share_dir` and `[mounts] read_write` carry
session-authored bytes and session-chosen filenames out of the container on
purpose. Sanitising happens where dejima does the printing, and stops there.
- **That the allowlist bounds exfiltration once a session has push access.**
A commit is an outbound channel to a host that is on the list by definition.
See [ci.md](ci.md).
+5
View File
@@ -38,6 +38,11 @@ merge_block = gh pr merge
# repository-scoped fine-grained PAT is the expected shape.
repo_flags = --repo -R
repo_env = GH_REPO
# `gh api repos/<owner>/<name>/...` names a repository with no --repo anywhere
# in it, and is the ordinary way to reach an endpoint the subcommands do not
# cover. Scoped to `api` so the guard is not comparing every positional
# argument that contains two slashes (#243).
repo_paths = api:repos
disclose = pr:gh pr create, pr:gh pr edit, issue:gh issue create, issue:gh issue edit, comment:gh issue comment
# See the gitea manifest's note: this is the key an identity supplies and a
# project may not. The read-only [github] token_file is core's rather than
+3
View File
@@ -1,3 +1,6 @@
They can continue this exact session outside the container by running on
the host:
{{resume_command}}
(session history is shared via the bind-mounted ~/.claude, so it will pick
+29 -2
View File
@@ -5,11 +5,38 @@ image_env = CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
command = claude
command_path = /usr/local/share/npm-global/bin/claude
api_domains = platform.claude.com
proxied_api_domain = api.anthropic.com
guards = bash-deny push-guard merge-guard notify
# Anthropic's Messages API and nothing else. Claude Code speaks one dialect, so
# pairing it with an OpenAI-compatible backend is refused at launch rather
# than producing well-formed requests in the wrong shape.
api_styles = anthropic
no_confirm_args = --dangerously-skip-permissions
briefing_args = --append-system-prompt
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
# Which model API this harness talks to, and so which credential the host-side
# proxy injects. Per harness rather than global (#171): an operator running
# Claude Code against Anthropic and Cline against something else is the
# ordinary case, and one setting cannot say both.
#
# NO DEFAULT, for the reason [core] harness has none. A default that could ever
# change is a config file that is not the source of truth: a session whose
# config did not change must not start talking to a different endpoint because
# dejima's idea of a sensible one moved.
keys = backend:?provider:backend projects_dir:path=~/.claude/projects file_history_dir:path=~/.claude/file-history statusline_path:path=~/.claude/statusline.sh
# What this harness was talking to before the key existed, WRITTEN INTO the
# operator's config the first time a launch finds it unset - not resolved as a
# default on every read.
#
# The difference is the whole point. A default is dejima's answer, re-decided
# every launch, and changing it later moves a session whose config never
# changed. A migration is the operator's answer, recorded once: after it runs,
# nothing dejima decides later can move them.
#
# Safe to state here because it is not a guess. Claude Code against Anthropic
# is what every existing session already was, so writing it down changes
# nothing about the launch it runs in.
assumed_backend = anthropic
resume_command = claude --resume {{session_id}}
briefing = briefing.md
+9 -9
View File
@@ -1,10 +1,10 @@
export CLINE_DATA_DIR={{harness_state_dir}}
cline history # find this session's id
cline --id <session-id>
This session cannot be continued outside dejima, so do not offer that or ask
the operator to try it. It CAN be continued by launching dejima again on this
project: this project's Cline state - sessions, transcripts, tasks - is kept
on the host in a directory of its own and survives the container being
replaced, so the next launch picks up where this one left off.
(the session id is deliberately absent from this briefing: Cline mints one when
a run starts, which is after this text was written, so `cline history` is where
to read it. CLINE_DATA_DIR has to be set because this project's Cline state is
kept in that directory on the host rather than in ~/.cline/data - Cline indexes
every session it has ever run in a single database, so dejima gives each
project its own and one project's history is never another's context.)
(Cline indexes every session it has ever run in a single database, so dejima
gives each project its own rather than sharing one. That directory is
dejima's, not Cline's own, and pointing a host-side Cline at it is not a
supported path - which is why resuming outside is not on offer.)
+26 -12
View File
@@ -14,7 +14,9 @@ install = npm:cline
# Documents/Cline/Rules is where the briefing is staged. That path is not a
# guess and not what Cline's documentation says: ~/Documents/Cline/Rules
# reaches the model on Linux and ~/Cline/Rules does not.
image_dirs = .cline .cline/data Documents Documents/Cline Documents/Cline/Rules
# .agents and .cline/hooks are the operator's own config, mounted from the
# host by the plugin; they need a mount point in the image to land on.
image_dirs = .cline .cline/data .cline/hooks .agents .agents/skills .agents/hooks Documents Documents/Cline Documents/Cline/Rules
# DO_NOT_TRACK and DISABLE_TELEMETRY are both honoured; CLINE_NO_AUTO_UPDATE
# stops a session upgrading its own harness mid-run, which would replace a
@@ -28,15 +30,9 @@ command_path = /usr/local/share/npm-global/bin/cline
# endpoint with the firewall up, so there is no catalogue or feature-flag host
# to allowlist. Cline's own endpoints (api.cline.bot and friends) are
# deliberately absent - a session that falls back to them has lost its
# provider configuration, and failing to connect is the right outcome.
# backend configuration, and failing to connect is the right outcome.
api_domains =
# Only meaningful on the Anthropic path, where Cline is pointed at the real
# hostname with a placeholder key and dejima's terminator and host-side proxy
# supply the credential. Confirmed end to end. On the local-endpoint path
# nothing connects here and pinning it costs nothing.
proxied_api_domain = api.anthropic.com
# None. Cline registers no hook that can refuse a tool call, so bash-deny has
# nowhere to live, and it has no session lifecycle signal dejima can turn into
# a notification. push-guard and merge-guard are not listed because no harness
@@ -44,6 +40,12 @@ proxied_api_domain = api.anthropic.com
# names what this costs; see harness_report_guards.
guards =
# Both, which is what makes this harness the backend-agnostic one: it has a
# native Anthropic client and an openai-compatible one, and dejima picks
# between them from the selected backend's api_style rather than from a
# setting an operator has to keep in step by hand.
api_styles = anthropic openai
# R3. Cline registers no session-start hook, so the briefing is a file, and
# this is the path that reaches the model - established by running it, not by
# reading the documentation, which says ~/Cline/Rules. On Linux that directory
@@ -63,9 +65,10 @@ no_confirm_args = --auto-approve true
# goes stale with the next Cline release, and choosing the model an unattended
# agent runs on is the operator's call. Absent, the launch refuses and says so.
#
# base_url absent means the Anthropic path through dejima's credential proxy.
# Set, it means a self-hosted endpoint, and the plugin refuses one that is not
# host-local - see bin/harness/cline.sh for why that is an R5 check.
# base_url is NOT here. Where the model is belongs to the backend: a
# self-served one carries its own [backend.<name>] base_url, which is what
# lets an operator keep a local endpoint configured while using a hosted API
# and switch between them by changing one word.
# context_window is the one Cline cannot work out for itself. It budgets
# against its own catalogue, and for a model it does not recognise - which is
# every self-served one - it has no budget at all, so it grows the conversation
@@ -77,7 +80,18 @@ no_confirm_args = --auto-approve true
# compacts constantly. Set it a little under whatever the server allows -
# Cline's accounting is approximate and a compaction does not always land
# under target when a single tool result is large.
keys = state_dir:path=~/.local/share/dejima/cline model:?identifier base_url:?url context_window:?int:512:100000000
# See claude-code's manifest for why this is per harness and why it has no
# default. The risk is concrete here rather than theoretical: Cline supports
# many backends and has one of its own, so any value dejima shipped
# would be dejima choosing an endpoint on the operator's behalf, and changing
# it later would move a running setup without touching its config.
#
# And no assumed_backend either, deliberately. What this harness was talking
# to before the key existed depends on base_url - unset meant Anthropic, set
# meant a model the operator serves - so there is no single value to record,
# and recording the wrong one would write a proxied endpoint into the config
# of a session that never used one. Making the two coherent is its own change.
keys = backend:?provider:backend state_dir:path=~/.local/share/dejima/cline model:?modelid context_window:?int:512:100000000
# Deliberately empty: resuming needs the state directory's path as well as the
# session id, briefing placeholders do not nest, and `template` forbids the
+4 -4
View File
@@ -41,11 +41,11 @@ if [ "${DEJIMA_BRIEFING_VIA_ARGS:-0}" = "1" ]; then
fi
# The image's copy first, because that is the one that exists in a real
# session: this file reaches the container through the ~/.claude/hooks mount,
# and install.sh links it there one file at a time, so its sibling in the repo
# is not alongside it. The sibling is the fallback so the hook can be run
# session: this file is baked in beside it and registered by absolute path
# into the sandbox's --settings overlay, so nothing about it depends on the
# operator's ~/.claude. The sibling is the fallback so the hook can be run
# against a checkout - which is what the test suite does - and readlink -f is
# what turns the linked path back into the repo one.
# what turns a linked path back into the repo one.
#
# Missing or unreadable degrades to no briefing rather than failing the hook:
# a SessionStart hook that exits non-zero drops the whole session's context,
+22 -12
View File
@@ -1,10 +1,13 @@
#!/bin/bash
# Idempotent installer for dejima. Registers the selected harness's guard
# hooks wherever that harness reads them from, and puts
# Idempotent installer for dejima. Writes NOTHING to the harness's own
# configuration: every hook dejima uses is baked into the image and registered
# per launch into a settings layer of dejima's own, so installing this does not
# change how the harness behaves outside a sandbox. What it still does is take
# back what older versions put there. It 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
# printed as it happens.
# bin/dejima-audit, bin/dejima-share 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 printed as it happens.
#
# It never enables anything: the remote-access scripts do nothing without
# [remote-access] enabled = on in ~/.config/dejima/config, and the user units
@@ -76,6 +79,10 @@ link_bin dejima-project
# operator to run `dejima-audit ack`, which is only true once it is on PATH.
link_bin dejima-audit
link_bin dejima-epitaph
# ~/shared is one directory per project now, so "where do I put a file for the
# next session" has an answer only this can give - and the launch notice about
# files left over from the old layout tells the operator to run it.
link_bin dejima-share
link_bin dejima-update-check
# Remote access (opt-in, see docs/remote-access.md). All three are inert
@@ -195,8 +202,11 @@ fi
# 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).
# Nothing to register: the sandbox's hooks are baked into the image and
# registered per launch into its own settings overlay. What is delegated to
# the plugin is the host-side cleanup an upgrade needs - taking back what
# older versions wrote into the harness's config - which still has to know
# which harness is selected.
echo
source "$SCRIPT_DIR/bin/dejima-harness-lib.sh"
# A config that does not parse leaves config_get empty for EVERY key, so
@@ -205,20 +215,20 @@ source "$SCRIPT_DIR/bin/dejima-harness-lib.sh"
# fault, on stderr, which is the answer to a question this cannot answer.
_harness=""
if config_exists && ! config_validate; then
echo "$CONFIG_FILE could not be read, so no guard hooks were registered."
echo "$CONFIG_FILE could not be read, so no harness host-side setup was checked."
echo "Fix what is reported above and re-run ./install.sh."
elif ! _harness="$(config_get core harness 2>/dev/null)" || [ -z "$_harness" ]; then
_harness=""
echo "No [core] harness is set in $CONFIG_FILE, so no guard hooks were"
echo "registered. Set one and re-run ./install.sh."
echo "No [core] harness is set in $CONFIG_FILE, so no harness host-side"
echo "setup was checked. 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"
echo "Checking $_harness's host-side setup"
harness_register_guards "$SCRIPT_DIR" "$BACKUP_DIR"
else
echo "Harness '$_harness' installs no guard hooks."
echo "Harness '$_harness' needs nothing on the host."
echo
fi
fi
+13 -2
View File
@@ -629,10 +629,21 @@ EOF
printf 'keep = 10\n' >> "$CONFIG_DIR/config"
snap; git -C "$WS" config user.name changed
chmod 500 "$AUDITDIR/$slug"
run timeout 5 "$AUDIT" compare "$WS" "$STATE"
# The subject is FOREVER, not fast. Rotation trims oldest-first and breaks
# when the unlink fails; without that break the loop's condition never
# changes and it spins. Any finite bound catches that, so the bound is
# generous on purpose: a five-second one was timing the machine, and on a
# loaded one a correct run exceeded it and reported a spin that had not
# happened (#295).
run timeout 120 "$AUDIT" compare "$WS" "$STATE"
result="$status"
chmod 700 "$AUDITDIR/$slug"
[ "$result" -ne 124 ]
[ "$result" -ne 124 ] || { echo "rotation did not terminate"; return 1; }
# And it gave up rather than evicting: an unwritable directory means every
# planted report is still there. Without this the test says only that the
# command exited, which a rotation that silently did nothing also satisfies.
[ "$(find "$AUDITDIR/$slug" -maxdepth 1 -name '2026-01-01-*.txt' | wc -l)" -eq 12 ] \
|| { echo "reports were evicted from a directory that cannot be written"; return 1; }
}
# One `ack` run used to walk one report and exit, saying nothing about the
+1088 -88
View File
File diff suppressed because it is too large Load Diff
+120 -18
View File
@@ -29,17 +29,25 @@ teardown() {
# A real HTTP server standing in for Loki, appending each pushed body to a
# file. Real because the shipper reaches it with curl: a stub that replaced
# curl would stop testing the thing that formats and sends the payload.
# The server binds port 0 itself and reports what it got, rather than a helper
# picking a free port and handing the number over.
#
# That handover was a race, and `bats --jobs` is what makes it fire: the helper
# bound port 0, read the number, CLOSED the socket, and only then did the
# server bind it. Another test starting in that window takes the same port, and
# the loser dies with "Address already in use" - but the readiness probe below
# still succeeds, because something IS listening on that port. The test then
# ships to the other test's Loki, finds its own log empty, and reports the
# failure as a lost transcript. Seen in CI, not locally: it needs enough cores
# for two tests to be in that window at once.
start_loki() {
LOKI_PORT="$(python3 - <<'EOF'
import socket
s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()
EOF
)"
python3 - "$LOKI_PORT" "$LOKI_LOG" <<'EOF' &
import sys
local portfile="$BATS_TEST_TMPDIR/loki-port"
rm -f "$portfile"
python3 - "$portfile" "$LOKI_LOG" <<'EOF' &
import sys, os
from http.server import BaseHTTPRequestHandler, HTTPServer
port, path = int(sys.argv[1]), sys.argv[2]
portfile, path = sys.argv[1], sys.argv[2]
class H(BaseHTTPRequestHandler):
def do_POST(self):
@@ -51,12 +59,26 @@ class H(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
HTTPServer(("127.0.0.1", port), H).serve_forever()
srv = HTTPServer(("127.0.0.1", 0), H)
# Written only once the socket is bound and listening, and atomically, so the
# shell can never read a half-written number or one for a socket that is gone.
tmp = portfile + ".tmp"
with open(tmp, "w") as f:
f.write(str(srv.server_address[1]))
os.replace(tmp, portfile)
srv.serve_forever()
EOF
LOKI_PID=$!
local i
for i in $(seq 1 50); do
(exec 3<>/dev/tcp/127.0.0.1/"$LOKI_PORT") 2>/dev/null && return 0
for i in $(seq 1 100); do
if [ -s "$portfile" ]; then
LOKI_PORT="$(cat "$portfile")"
return 0
fi
# A server that died on startup will never write the file, and waiting
# the full timeout for a process that is already gone only delays the
# real message.
kill -0 "$LOKI_PID" 2>/dev/null || break
sleep 0.1
done
echo "the fake Loki never came up" >&2
@@ -106,6 +128,23 @@ await_shipped() {
return 1
}
# Blocks until the shipper has finished a poll and is asleep between polls.
# The two SIGTERM tests below need exactly that state - the trap installed, one
# scan done - and both used to state it by sleeping for a second. That is a
# statement about the machine rather than about the shipper: under load the
# startup that precedes the trap takes longer than the sleep, the TERM lands
# before there is a trap to catch it, and the process dies flushing nothing.
# The nap is a child `sleep` of the shipper, which makes the state observable.
await_napping() {
local i
for i in $(seq 1 200); do
pgrep -P "$SHIP_PID" -x sleep >/dev/null 2>&1 && return 0
sleep 0.05
done
echo "the shipper never reached its first nap" >&2
return 1
}
@test "a line is shipped to Loki while the session runs" {
start_loki
write_config
@@ -156,10 +195,11 @@ await_shipped() {
write_config
mkdir -p "$(slug_dir)"
start_ship
# Long enough for one poll to read the line into the batch, short enough
# that the 2s batch interval has not flushed it on its own.
# Written before the first poll, so the poll reads it into the batch; the
# kill then lands while the shipper is asleep with it still in there,
# because the batch interval has not elapsed since it started.
say "already batched" >> "$(slug_dir)/s1.jsonl"
sleep 1
await_napping
kill -TERM "$SHIP_PID"
wait "$SHIP_PID" 2>/dev/null || true
SHIP_PID=""
@@ -172,10 +212,11 @@ await_shipped() {
write_config
mkdir -p "$(slug_dir)"
start_ship
# Written and killed inside one poll interval, so nothing has read it yet:
# only the trap's own rescan can find it. A final_flush that flushed the
# batch without rescanning would pass the test above and fail this one.
sleep 1
# Written after a poll has already run and killed before the next one, so
# nothing has read it yet: only the trap's own rescan can find it. A
# final_flush that flushed the batch without rescanning would pass the test
# above and fail this one.
await_napping
say "the final answer" >> "$(slug_dir)/s1.jsonl"
kill -TERM "$SHIP_PID"
wait "$SHIP_PID" 2>/dev/null || true
@@ -352,3 +393,64 @@ EOF
run grep -c "should not ship" "$LOKI_LOG"
[ "$output" -eq 0 ] || { echo "shipped without a resolvable layout: $(shipped)"; return 1; }
}
@test "the tail survives a kill even when the harness declares an optional key with no default" {
# #284, and it is worth pinning by SHAPE rather than by the key that
# exposed it. Any optional keyspec with an empty default in the selected
# harness's `keys` used to make final_flush's config lookup fail: it
# re-resolves the transcript paths from the SIGTERM handler, and bash
# reports 128+n for commands completing while it delivers a signal, so a
# perfectly valid manifest was rejected and the tail - usually the agent's
# final answer, which is the whole reason final_flush exists - was dropped
# with no error the operator would ever see.
#
# Nothing shipped for claude-code had that shape until #171 added one, so
# this adds one of its own rather than depending on a real key keeping it.
# A provider root of its own, so the shipped manifests are not edited and
# a concurrent run is unaffected.
local proot="$BATS_TEST_TMPDIR/proot"
mkdir -p "$proot"
# Loudly: a partial copy here produces exactly the symptom this test is
# about - a lookup that fails - and swallowing the error would report the
# fixture's fault as the code's.
cp -a "$REPO_ROOT/forges" "$REPO_ROOT/workflows" "$REPO_ROOT/harnesses" \
"$REPO_ROOT/backends" "$proot/" \
|| { echo "could not build the probe provider root"; return 1; }
local axis
for axis in forges workflows harnesses backends; do
[ -d "$proot/$axis" ] || { echo "the probe provider root has no $axis/"; return 1; }
done
sed -i 's/^keys \( *\)=/keys \1= probe_only_key:?identifier/' \
"$proot/harnesses/claude-code/harness.conf"
grep -q 'probe_only_key:?identifier' "$proot/harnesses/claude-code/harness.conf" \
|| { echo "the probe key was not injected"; return 1; }
export DEJIMA_PROVIDER_ROOT="$proot"
start_loki
write_config
mkdir -p "$(slug_dir)"
start_ship
# Wait for the shipper to be demonstrably running rather than sleeping at
# it. A fixed sleep is a second race on a loaded machine: if the process
# is still starting when the signal arrives, the handler runs before the
# key schema has been resolved, which fails for the ORIGINAL #284 reason
# and would read as this fix not working.
say "a first line" >> "$(slug_dir)/s1.jsonl"
await_shipped "a first line" || { echo "the shipper never started"; return 1; }
say "the final answer" >> "$(slug_dir)/s1.jsonl"
kill -TERM "$SHIP_PID"
wait "$SHIP_PID" 2>/dev/null || true
SHIP_PID=""
# Wait for the line rather than reading the log the instant the shipper
# exits. `wait` returns when the SHIPPER is gone, which says nothing about
# the collector on the other end having written what it was sent - so
# reading immediately is a race the loaded machine loses, and it fails as
# "the tail was lost", which is precisely this test's bug signature (#295).
await_shipped "the final answer" \
|| { echo "the unread tail was lost: $(shipped)"; return 1; }
# And the lookup did not merely survive - it never failed. That message
# reaching an operator's terminal is the visible half of this bug.
[[ "$(shipped)" != *"unknown config key"* ]] || { echo "$(shipped)"; return 1; }
}
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""Runs a command on a real pty and answers its prompts.
The credential scan only asks when stdin is a terminal, which is the whole
point - an unattended launch must not block on a question nobody will see. So
a test that drives the interactive path needs a pty rather than a pipe, or it
exercises the non-interactive branch while claiming to test the other one.
Usage: answer-prompt.py [--marker TEXT] <answer-per-prompt>... -- <command> [args...]
Each answer is sent when the child writes a line ending in the prompt marker.
Everything the child wrote is printed on exit, and the child's exit status is
this program's.
"""
import os
import pty
import signal
import re
import select
import sys
import time
# The default is the credential scan's prompt. --marker takes another, so a
# test for a different prompt does not need a second copy of this.
MARKER = "[s/a/o/r]"
# dejima pauses before the image build when it printed a notice worth reading.
# That is not the prompt any test here is about - it is ambient, and every
# launch fixture that sets [core] ssh_key raises it - so it is answered
# automatically rather than costing every caller an extra argument.
AMBIENT = "Press Enter to continue"
# An IDLE timeout: how long to wait with no output before giving up. Generous
# because the thing it guards against is a command that never finishes, and
# any finite bound catches that - while a tight one just times the machine.
# Sixty seconds was enough on an idle box and not enough under the full gate,
# where every suite runs at once (#295).
TIMEOUT = 240.0
def main():
if "--" not in sys.argv:
print("usage: answer-prompt.py <answer>... -- <command>...", file=sys.stderr)
return 2
split = sys.argv.index("--")
args = list(sys.argv[1:split])
global MARKER
if "--marker" in args:
i = args.index("--marker")
MARKER = args[i + 1]
del args[i:i + 2]
answers = args
command = sys.argv[split + 1:]
if not command:
print("no command given", file=sys.stderr)
return 2
pid, fd = pty.fork()
if pid == 0:
# The child: a session leader with this pty as its controlling
# terminal, which is what makes `[ -t 0 ]` true inside dejima.
try:
os.execvp(command[0], command)
except OSError as e:
print("exec failed: %s" % e, file=sys.stderr)
os._exit(127)
out = []
pending = list(answers)
deadline = TIMEOUT
while True:
r, _, _ = select.select([fd], [], [], deadline)
if not r:
out.append("\n[answer-prompt: timed out waiting for output]\n")
break
try:
chunk = os.read(fd, 4096)
except OSError:
break
if not chunk:
break
text = chunk.decode("utf-8", "replace")
out.append(text)
# Answer only when the prompt is the most recent thing written, so a
# marker appearing inside prose does not consume an answer.
if pending and MARKER in text:
os.write(fd, (pending.pop(0) + "\n").encode())
elif AMBIENT in text and AMBIENT != MARKER:
os.write(fd, b"\n")
# The read loop ends when the child stops WRITING, which is not when it
# stops running: dejima does its exit audit and stops its shipper after
# the last thing it prints. So give it a grace period to finish before
# concluding anything - killing it there reported a child that was merely
# busy as a hang, and left it taking SIGPIPE on a pty nobody was reading.
#
# Only then kill, because an unanswered prompt is the other way this ends
# and waiting on that turns a failing test into a hung suite. The caller
# is told which it was.
stuck = False
status = 0
grace = time.time() + 60.0
try:
while True:
done, status = os.waitpid(pid, os.WNOHANG)
if done != 0:
break
if time.time() >= grace:
stuck = True
os.kill(pid, signal.SIGKILL)
_, status = os.waitpid(pid, 0)
break
time.sleep(0.1)
except OSError:
status = 0
sys.stdout.write("".join(out))
if pending:
sys.stdout.write("\n[answer-prompt: %d answer(s) never used]\n" % len(pending))
if stuck:
sys.stdout.write("\n[answer-prompt: the command never exited - killed. "
"It was probably waiting at a prompt nothing answered.]\n")
return os.waitstatus_to_exitcode(status) if hasattr(os, "waitstatus_to_exitcode") \
else (status >> 8)
if __name__ == "__main__":
sys.exit(main())
+73 -6
View File
@@ -118,8 +118,8 @@ finally:
# TTY_SUSPEND_NO_HELPER leaves the helper out, which is dejima before this
# existed. It is the control: a harness that cannot see the bug cannot be
# trusted to see the fix either.
start_helper = "" if os.environ.get("TTY_SUSPEND_NO_HELPER") else \
'"%s" &\nhelper=$!' % HELPER
HELPER_EXPECTED = not os.environ.get("TTY_SUSPEND_NO_HELPER")
start_helper = '"%s" &\nhelper=$!' % HELPER if HELPER_EXPECTED else ""
# bin/dejima's shape: the client inherits SIGTSTP ignored and is started with
# the terminal, and the keystroke is converted into an explicit SIGSTOP for it
# before the launcher stops itself. Without the conversion the client is the
@@ -221,6 +221,53 @@ def lflag():
return termios.tcgetattr(master)[3]
def reclaimed():
"""True once dejima-tty-suspend has taken ^Z back from the container's raw mode.
Read off the pty rather than inferred from output, and specific enough to
mean only the helper. Raw mode has ISIG off; a plain shell has ISIG on with
intr at ^C. Only the helper leaves ISIG on, susp at ^Z, and intr undefined
- which is the whole of what it does, so this is the feature's precondition
stated exactly.
"""
attrs = termios.tcgetattr(master)
cc = attrs[6]
return bool(attrs[3] & termios.ISIG) \
and cc[termios.VSUSP] == b"\x1a" \
and cc[termios.VINTR] == b"\x00"
def await_reclaim(timeout=10.0):
end = time.time() + timeout
while time.time() < end:
if reclaimed():
return True
pump(0.05)
return reclaimed()
def raw_restored():
"""True once the resumed client has put the terminal back into raw mode."""
flags = lflag()
return not (flags & termios.ICANON) and not (flags & termios.ECHO)
def await_raw(timeout=15.0):
"""Wait for that, rather than reading the flags at a fixed moment after fg.
`fg` returns as soon as the job is continued; the client sets raw mode some
scheduling later. Sampling the flags right after fg was timing the machine,
and on a loaded one it read the shell's own canonical mode and reported it
as a failure to restore - the same shape as the reclaim wait above.
"""
end = time.time() + timeout
while time.time() < end:
if raw_restored():
return True
pump(0.05)
return raw_restored()
def received():
try:
with open(RECEIVED, "rb") as fh:
@@ -242,6 +289,27 @@ try:
check("the container run attached", expect("CONTAINER-ATTACHED"))
for cycle in (1, 2):
# Wait for the helper to hold the terminal before typing at it (#262).
#
# CONTAINER-ATTACHED is printed by the stand-in immediately after it
# sets raw mode, so it marks the START of the window this feature
# exists to close, not the end. The helper only acts once it has POLLED
# the terminal and seen -isig, so between those two moments ^Z is still
# the byte 0x1a. Sending into that window put the byte in the container
# and failed `nothing-reached-the-container` - the security assertion -
# on a machine where nothing was wrong but the scheduling.
#
# Waiting for the precondition rather than lengthening the deadline
# after it: with the terminal provably converting ^Z to a signal before
# anything is typed, that check can no longer fail for timing reasons
# at all, which is the point. A longer timeout would only have narrowed
# the window.
#
# Skipped without the helper, where the byte reaching the container is
# the expected result and this would never come true.
if HELPER_EXPECTED:
check("cycle%d-suspend-char-reclaimed" % cycle, await_reclaim(),
"dejima-tty-suspend never took ^Z back from raw mode")
before = received()
forget()
send("\x1a")
@@ -255,12 +323,11 @@ try:
expect("SHELL-ALIVE-%d" % cycle, 5.0))
forget()
send("fg\n")
expect("CONTAINER-REDRAW", 5.0)
restored = await_raw()
flags = lflag()
check("cycle%d-raw-mode-restored" % cycle,
not (flags & termios.ICANON) and not (flags & termios.ECHO),
check("cycle%d-raw-mode-restored" % cycle, restored,
"ICANON=%s ECHO=%s" % (bool(flags & termios.ICANON), bool(flags & termios.ECHO)))
check("cycle%d-redraw-poked" % cycle, "CONTAINER-REDRAW" in "".join(seen))
check("cycle%d-redraw-poked" % cycle, expect("CONTAINER-REDRAW", 15.0))
check("cycle%d-session-alive" % cycle, "LAUNCHER-DONE" not in "".join(seen))
before = received()
+18 -17
View File
@@ -98,7 +98,8 @@ launch() {
# launch is what triggers it. Its cache needs nothing further: it lands
# under $HOME, which every launch fixture already points somewhere of its
# own.
printf '[core]\nharness = claude-code\nupdate_check = off\nssh_key = %s\n' \
# backend is stated, not defaulted: it has none and never will (#171).
printf '[core]\nharness = claude-code\nupdate_check = off\nssh_key = %s\n[harness.claude-code]\nbackend = anthropic\n' \
"$LAUNCH_DIR/sandbox-key" >> "$conf"
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
SANDBOX_TEST_RUN_ENV="$LAUNCH_ENV" \
@@ -981,7 +982,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]\nharness = claude-code\nssh_key = %s\n' "$LAUNCH_DIR/sandbox-key" >> "$CONFIG_DIR/config"
printf '[core]\nharness = claude-code\nssh_key = %s\n[harness.claude-code]\nbackend = anthropic\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 \
@@ -1066,13 +1067,13 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
launch_setup
run launch "$PROJ"
[ "$status" -eq 0 ]
[ "$(launched_env SANDBOX_IMAGE)" = "dejima:$(id -un)-harness-claude-code" ]
[ "$(launched_env SANDBOX_IMAGE)" = "localhost/dejima:$(id -un)-harness-claude-code" ]
"$REPO_ROOT/bin/dejima-dep" add --project "$PROJ" apt:libpq-dev >/dev/null
run launch "$PROJ"
[ "$status" -eq 0 ]
slug="$(bash -c 'source "$1"; project_slug "$2"' _ "$LIB" "$PROJ")"
[ "$(launched_env SANDBOX_IMAGE)" = "dejima:$(id -un)-$slug" ]
[ "$(launched_env SANDBOX_IMAGE)" = "localhost/dejima:$(id -un)-$slug" ]
}
# nvim's data/state/cache are Docker volumes, not host paths, so nothing a
@@ -1116,8 +1117,8 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
launch_setup
run launch "$PROJ"
[ "$status" -eq 0 ]
grep -q -- "-t dejima:$(id -un)-global -f" <(docker_log)
[ "$(launched_env SANDBOX_IMAGE)" = "dejima:$(id -un)-global" ]
grep -q -- "-t localhost/dejima:$(id -un)-global -f" <(docker_log)
[ "$(launched_env SANDBOX_IMAGE)" = "localhost/dejima:$(id -un)-global" ]
}
@test "the launcher builds the global layer then the project layer on top of it" {
@@ -1127,12 +1128,12 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
run launch "$PROJ"
[ "$status" -eq 0 ]
slug="$(bash -c 'source "$1"; project_slug "$2"' _ "$LIB" "$PROJ")"
global_line=$(grep -n -- "-t dejima:$(id -un)-global -f" <(docker_log) | cut -d: -f1)
project_line=$(grep -n -- "-t dejima:$(id -un)-$slug -f" <(docker_log) | cut -d: -f1)
global_line=$(grep -n -- "-t localhost/dejima:$(id -un)-global -f" <(docker_log) | cut -d: -f1)
project_line=$(grep -n -- "-t localhost/dejima:$(id -un)-$slug -f" <(docker_log) | cut -d: -f1)
[ -n "$global_line" ]
[ -n "$project_line" ]
[ "$global_line" -lt "$project_line" ]
[ "$(launched_env SANDBOX_IMAGE)" = "dejima:$(id -un)-$slug" ]
[ "$(launched_env SANDBOX_IMAGE)" = "localhost/dejima:$(id -un)-$slug" ]
}
@test "the project layer's Dockerfile is generated FROM the global layer, not the base image" {
@@ -1148,11 +1149,11 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
[ "$status" -eq 0 ]
slug="$(bash -c 'source "$1"; project_slug "$2"' _ "$LIB" "$PROJ")"
# base -> harness -> global -> project, each FROM the one before it.
dockerfile_for "dejima:$(id -un)-harness-claude-code" \
| grep -qxF "FROM dejima:$(id -un)"
dockerfile_for "dejima:$(id -un)-global" \
| grep -qxF "FROM dejima:$(id -un)-harness-claude-code"
dockerfile_for "dejima:$(id -un)-$slug" | grep -qxF "FROM dejima:$(id -un)-global"
dockerfile_for "localhost/dejima:$(id -un)-harness-claude-code" \
| grep -qxF "FROM localhost/dejima:$(id -un)"
dockerfile_for "localhost/dejima:$(id -un)-global" \
| grep -qxF "FROM localhost/dejima:$(id -un)-harness-claude-code"
dockerfile_for "localhost/dejima:$(id -un)-$slug" | grep -qxF "FROM localhost/dejima:$(id -un)-global"
}
@test "the launcher builds no dependency layer when neither global nor project deps are approved" {
@@ -1163,10 +1164,10 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
# harness in the image - so this is about the DEPENDENCY layers, which is
# what "no approved dependencies" is a statement about.
local tagged
tagged="$(grep -oE -- '-t dejima:[^ ]+' <(docker_log) | sed 's/^-t //' | sort -u)"
[ "$tagged" = "dejima:$(id -un)-harness-claude-code" ] \
tagged="$(grep -oE -- '-t localhost/dejima:[^ ]+' <(docker_log) | sed 's/^-t //' | sort -u)"
[ "$tagged" = "localhost/dejima:$(id -un)-harness-claude-code" ] \
|| { echo "expected only the harness layer to be built, got:"; echo "$tagged"; return 1; }
[ "$(launched_env SANDBOX_IMAGE)" = "dejima:$(id -un)-harness-claude-code" ]
[ "$(launched_env SANDBOX_IMAGE)" = "localhost/dejima:$(id -un)-harness-claude-code" ]
}
@test "the launcher warns when the approval store sits inside the mounted workspace" {
+332
View File
@@ -1045,11 +1045,34 @@ assert_remedy_forges_exist() {
# with (#187). The guard is generic; the manifest is what makes it fire.
[ "$(lib manifest_get forge github repo_flags)" = "--repo -R" ]
[ "$(lib manifest_get forge github repo_env)" = "GH_REPO" ]
# And in a path rather than a flag: `gh api repos/<owner>/<name>/...`
# names a repository with no --repo in it at all (#243).
[ "$(lib manifest_get forge github repo_paths)" = "api:repos" ]
# The gitea CLIs resolve the repo from origin and refuse another host, so
# there is nothing to declare and nothing for the guard to compare. A
# forge is not required to be aimable.
[ -z "$(lib manifest_get forge gitea repo_flags)" ]
[ -z "$(lib manifest_get forge gitea repo_env)" ]
[ -z "$(lib manifest_get forge gitea repo_paths)" ]
}
@test "a repo path has to name a subcommand and a segment" {
# `<subcommand>:<first segment>`. The subcommand half is what keeps the
# guard from comparing every positional argument that contains two
# slashes, so a spelling that omits it is not a narrower declaration - it
# is one that would refuse an issue title.
local d bad; d="$(mktemp -d "$BATS_TEST_TMPDIR/pathtype.XXXXXX")"
mkdir -p "$d/forges/probe"
for bad in "repos" "api:" ":repos" "api:repos/more" "api:/repos" "-api:repos"; do
printf 'repo_paths = %s\n' "$bad" > "$d/forges/probe/forge.conf"
run env DEJIMA_PROVIDER_ROOT="$d" \
bash -c 'source "$1"; manifest_validate forge probe' _ "$LIB"
[ "$status" -ne 0 ] || { echo "accepted repo_paths = $bad"; return 1; }
done
printf 'repo_paths = api:repos\n' > "$d/forges/probe/forge.conf"
run env DEJIMA_PROVIDER_ROOT="$d" \
bash -c 'source "$1"; manifest_validate forge probe' _ "$LIB"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
}
@test "a repo flag has to look like a flag" {
@@ -1322,3 +1345,312 @@ assert_remedy_forges_exist() {
done
done
}
# --- the model provider axis (#171) ------------------------------------------
@test "the anthropic backend manifest validates and answers for every field" {
lib manifest_validate backend anthropic
[ "$(lib manifest_get backend anthropic upstream)" = "https://api.anthropic.com" ]
[ "$(lib manifest_get backend anthropic proxied_domain)" = "api.anthropic.com" ]
[ "$(lib manifest_get backend anthropic credential)" = "anthropic-oauth" ]
[ "$(lib manifest_get backend anthropic auth_format)" = "Bearer {token}" ]
[ "$(lib manifest_get backend anthropic strip_headers)" = "x-api-key" ]
[ "$(lib manifest_get backend anthropic ensure_headers)" = "anthropic-beta=oauth-2025-04-20" ]
}
@test "a backend's own keys become [backend.<name>] config keys" {
# The same machinery a forge's keys go through, so a backend declaring
# where its credential lives needs no row hardcoded in core's schema.
[ "$(lib config_type backend.anthropic.credential_file)" = "path" ]
run lib config_type backend.anthropic.not_a_key
[ "$status" -ne 0 ]
}
@test "an auth_format with no token placeholder is refused by the schema" {
# The proxy refuses it too. Refusing it here as well is the difference
# between finding out at launch and finding out from a provider's 401:
# a format without {token} produces a proxy that starts, answers, and
# sends every request unauthenticated.
local d; d="$(mktemp -d "$BATS_TEST_TMPDIR/authfmt.XXXXXX")"
mkdir -p "$d/backends/probe"
_probe() {
printf 'upstream = https://x.example\nproxied_domain = x.example\ncredential = static\nauth_format = %s\n' "$1" \
> "$d/backends/probe/backend.conf"
run env DEJIMA_PROVIDER_ROOT="$d" \
bash -c 'source "$1"; manifest_validate backend probe' _ "$LIB"
}
_probe 'Bearer'
[ "$status" -ne 0 ] || { echo "a format with no {token} was accepted"; return 1; }
_probe 'Bearer {token}'
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
_probe '{token}'
[ "$status" -eq 0 ] || { echo "a bare-key format was refused: $output"; return 1; }
}
@test "an ensure_headers entry has to be NAME=VALUE with a header-shaped name" {
# envpair would refuse anthropic-beta outright: it wants an upper-case
# environment variable, and a header name is lower case with dashes.
local d; d="$(mktemp -d "$BATS_TEST_TMPDIR/ensure.XXXXXX")"
mkdir -p "$d/backends/probe"
_ensure() {
printf 'upstream = https://x.example\nproxied_domain = x.example\ncredential = static\nensure_headers = %s\n' "$1" \
> "$d/backends/probe/backend.conf"
run env DEJIMA_PROVIDER_ROOT="$d" \
bash -c 'source "$1"; manifest_validate backend probe' _ "$LIB"
}
local bad
for bad in 'anthropic-beta' '=value' 'name=' 'has space=v'; do
_ensure "$bad"
[ "$status" -ne 0 ] || { echo "accepted ensure_headers = $bad"; return 1; }
done
_ensure 'anthropic-beta=oauth-2025-04-20'
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
}
@test "a backend name that is not a directory is refused by the config type" {
# The same provider:<axis> check core.forge and core.harness go through,
# so a typo names nothing rather than resolving somewhere.
run lib _config_valid_value provider:backend anthropic
[ "$status" -eq 0 ] || { echo "the shipped provider was refused: $output"; return 1; }
run lib _config_valid_value provider:backend no-such-provider
[ "$status" -ne 0 ] || { echo "a provider with no directory was accepted"; return 1; }
run lib _config_valid_value provider:backend ../etc
[ "$status" -ne 0 ] || { echo "a traversal was accepted"; return 1; }
}
@test "the backend is selected per harness, not globally" {
# An operator running Claude Code against Anthropic and Cline against
# something else is the ordinary case, and it is why this is not one
# [credentials] key. Each harness declares its own, with its own default.
[ "$(lib config_type harness.claude-code.backend)" = "?provider:backend" ]
[ "$(lib config_type harness.cline.backend)" = "?provider:backend" ]
# And NO default, on either. One that could change later would move a
# session to another endpoint without its config changing, which is the
# reason [core] harness has none either.
[ -z "$(lib config_default harness.claude-code.backend)" ] \
|| { echo "claude-code ships a default backend"; return 1; }
[ -z "$(lib config_default harness.cline.backend)" ] \
|| { echo "cline ships a default backend"; return 1; }
# And there is no global one to disagree with them.
run lib config_type credentials.backend
[ "$status" -ne 0 ] || { echo "a global backend key is back"; return 1; }
}
@test "a harness that declares no backend names no endpoint to proxy" {
# The self-served case: a model on the operator's own network needs no
# credential withheld because there is none, so a harness may decline to
# name a provider and the launch runs without a proxy.
local d; d="$(mktemp -d "$BATS_TEST_TMPDIR/noprov.XXXXXX")"
mkdir -p "$d/harnesses/bare"
printf 'command = true\n' > "$d/harnesses/bare/harness.conf"
run env DEJIMA_PROVIDER_ROOT="$d" \
bash -c 'source "$1"; config_type harness.bare.backend' _ "$LIB"
[ "$status" -ne 0 ] || { echo "a harness that declares nothing got a provider key"; return 1; }
}
@test "openrouter is declared as a static-key, openai-style backend" {
# The second shipped provider, and the case #171 was filed about: a hosted
# endpoint behind a static API key, which dejima previously had no answer
# for other than refusing it.
lib manifest_validate backend openrouter
[ "$(lib manifest_get backend openrouter upstream)" = "https://openrouter.ai/api/v1" ]
[ "$(lib manifest_get backend openrouter proxied_domain)" = "openrouter.ai" ]
[ "$(lib manifest_get backend openrouter api_style)" = "openai" ]
[ "$(lib manifest_get backend openrouter credential)" = "static" ]
# Both spellings an OpenAI-compatible client might use for a key, so a
# placeholder cannot travel beside the injected Authorization.
[ "$(lib manifest_get backend openrouter strip_headers)" = "x-api-key api-key" ]
# No default for the key's location and no assumed value: an unset one is
# a refusal at launch, not a guess at a path.
[ "$(lib manifest_get backend openrouter requires)" = "credential_file" ]
[ -z "$(lib config_default backend.openrouter.credential_file)" ]
# proxied_domain is the HOST alone. upstream carries the path; a
# certificate and a DNS pin are about a name.
[[ "$(lib manifest_get backend openrouter proxied_domain)" != */* ]]
}
@test "a self-served endpoint is a backend like any other" {
# Where the model is belongs to the provider, including when the operator
# serves it. That is what lets a local endpoint stay configured while a
# hosted API is selected: it is [backend.self-hosted] base_url, not a
# setting on the harness that has to be unset to get out of the way.
lib manifest_validate backend self-hosted
[ "$(lib manifest_get backend self-hosted credential)" = "none" ]
[ "$(lib manifest_get backend self-hosted api_style)" = "openai" ]
[ "$(lib manifest_get backend self-hosted requires)" = "base_url" ]
[ "$(lib config_type backend.self-hosted.base_url)" = "?url" ]
[ -z "$(lib config_default backend.self-hosted.base_url)" ] \
|| { echo "self-hosted ships a default endpoint"; return 1; }
# upstream and proxied_domain describe an endpoint dejima stands in front
# of, and it stands in front of nothing here.
[ -z "$(lib manifest_get backend self-hosted upstream)" ]
[ -z "$(lib manifest_get backend self-hosted proxied_domain)" ]
# And the harness no longer carries one of its own to disagree with it.
run lib config_type harness.cline.base_url
[ "$status" -ne 0 ] || { echo "cline still declares base_url"; return 1; }
}
@test "a harness and a backend have to speak the same API" {
# Anthropic's Messages API is not OpenAI-compatible. Pairing a harness
# with a provider it has no client for is knowable at launch and produces
# nothing but confusion later, so both sides declare what they speak.
[ "$(lib manifest_get backend anthropic api_style)" = "anthropic" ]
[ "$(lib manifest_get harness claude-code api_styles)" = "anthropic" ]
# cline is the provider-agnostic one and says so.
[ "$(lib manifest_get harness cline api_styles)" = "anthropic openai" ]
# A dialect outside the two is refused by the schema. An ABSENT one is not
# - manifest_validate only type-checks the keys a file actually has, which
# is true of every axis and not something this one changed - so bin/dejima
# names the four a provider cannot work without where it reads them. That
# refusal is covered in config.bats, at the launch.
local d; d="$(mktemp -d "$BATS_TEST_TMPDIR/style.XXXXXX")"
mkdir -p "$d/backends/probe"
printf 'upstream = https://x.example\nproxied_domain = x.example\ncredential = static\napi_style = sideways\n' \
> "$d/backends/probe/backend.conf"
run env DEJIMA_PROVIDER_ROOT="$d" \
bash -c 'source "$1"; manifest_validate backend probe' _ "$LIB"
[ "$status" -ne 0 ] || { echo "an unknown api_style validated"; return 1; }
}
@test "the backend/harness matrix in the docs agrees with the manifests" {
# A compatibility table is derived data: every cell is the provider's
# api_style against the harness's api_styles. Written by hand it goes stale
# the first time a provider is added, and a stale one is worse than none -
# a reader trusts it and dejima refuses the launch.
#
# So it is recomputed here and compared cell by cell, rather than spot
# -checked. A new backend or harness that nobody adds a row for fails
# this, which is the point.
# Both copies, because the README carries a shorter one and two copies of
# derived data is two things to drift. Checked against the manifests
# rather than against each other, so neither can be right by agreeing with
# a wrong one.
local doc rows
for doc in "$REPO_ROOT/docs/providers.md" "$REPO_ROOT/README.md"; do
rows="$(sed -n '/^| Backend | `claude-code` | `cline` |/,/^$/p' "$doc")"
[ -n "$rows" ] || { echo "the matrix is gone from $doc, or its header changed"; return 1; }
local p h styles want got row
for p in $(lib provider_list backend); do
row="$(grep -E "^\| \`$p\` \|" <<< "$rows")" \
|| { echo "no row for backend '$p'"; return 1; }
# Column count first. A row with a doubled separator still answers
# the cells this asks for by index, so without this a malformed row
# passes - which is how one got in.
local want_cols cols
want_cols="$(awk -F'|' 'NR==1 {print NF}' <<< "$rows")"
cols="$(awk -F'|' '{print NF}' <<< "$row")"
[ "$cols" = "$want_cols" ] \
|| { echo "$p's row has $cols columns, the header has $want_cols"; return 1; }
local i=0
for h in claude-code cline; do
i=$((i + 1))
styles="$(lib manifest_get harness "$h" api_styles)"
if [[ " $styles " == *" $(lib manifest_get backend "$p" api_style) "* ]]; then
want=yes
else
want=no
fi
got="$(awk -F'|' -v n=$((i + 2)) '{gsub(/^ +| +$/,"",$n); print $n}' <<< "$row")"
[ "$got" = "$want" ] \
|| { echo "$p x $h: the table says '$got', the manifests say '$want'"; return 1; }
done
done
# And every shipped harness has a column, or the table is answering for
# fewer pairs than exist.
for h in $(lib provider_list harness); do
grep -q "\`$h\`" <<< "$rows" || { echo "no column for harness '$h' in $doc"; return 1; }
done
done
}
@test "an Anthropic API key is a backend, so it stays on the host too" {
# The setup most organisations use: a console key on API billing, rather
# than a subscription login. Without a backend to declare it, dejima has
# no claudeAiOauth block to protect, skips the proxy, and mounts the
# operator's real key into the container - so the common arrangement
# would be the one where a long-lived, account-wide credential sits where
# a compromised session can read it.
lib manifest_validate backend anthropic-api-key
[ "$(lib manifest_get backend anthropic-api-key credential)" = "static" ]
[ "$(lib manifest_get backend anthropic-api-key upstream)" = "https://api.anthropic.com" ]
[ "$(lib manifest_get backend anthropic-api-key api_style)" = "anthropic" ]
# Anthropic's own header, bare - no scheme prefix. That pair is the whole
# difference from the subscription backend: a token is a Bearer, a key is
# x-api-key.
[ "$(lib manifest_get backend anthropic-api-key auth_header)" = "x-api-key" ]
[ "$(lib manifest_get backend anthropic-api-key auth_format)" = "{token}" ]
[ "$(lib manifest_get backend anthropic auth_header)" = "Authorization" ]
# And the other slot stripped, so a placeholder cannot travel beside the
# injected key and be the one the API reads.
[ "$(lib manifest_get backend anthropic-api-key strip_headers)" = "authorization" ]
[ "$(lib manifest_get backend anthropic-api-key requires)" = "credential_file" ]
}
@test "one API-billing backend serves both places the key can live" {
# Claude Code's console login keeps its key as primaryApiKey in
# ~/.claude.json; a key you hold yourself is a file that is nothing but
# the key. Same endpoint, same header, same static credential - one
# backend, with WHERE the key sits left to the operator.
lib manifest_validate backend anthropic-api-key
[ "$(lib manifest_get backend anthropic-api-key credential)" = "static" ]
[ "$(lib manifest_get backend anthropic-api-key auth_header)" = "x-api-key" ]
[ "$(lib manifest_get backend anthropic-api-key auth_format)" = "{token}" ]
[ "$(lib manifest_get backend anthropic-api-key strip_headers)" = "authorization" ]
# Defaulted to the console login, so the common case needs no config.
[ "$(lib config_default backend.anthropic-api-key.credential_file)" = "~/.claude.json" ]
[ "$(lib config_default backend.anthropic-api-key.credential_field)" = "primaryApiKey" ]
# And clearable, which is what serves a bare key file.
[ "$(lib config_type backend.anthropic-api-key.credential_field)" = "?jsonfield" ]
# Two backends for Anthropic, not three: the split is the wire
# arrangement, not where a file happens to be.
run lib provider_dir backend anthropic-console
[ "$status" -ne 0 ] || { echo "anthropic-console is back"; return 1; }
[ "$(lib manifest_get backend anthropic credential)" = "anthropic-oauth" ]
}
@test "the endpoint is identical across the anthropic backends" {
# Three values describe Anthropic's API rather than any credential, so
# they must not drift between the two backends that reach it. This is what
# the duplication costs, and it is cheaper to test than to deduplicate.
local field a b
for field in upstream proxied_domain api_style; do
a="$(lib manifest_get backend anthropic "$field")"
b="$(lib manifest_get backend anthropic-api-key "$field")"
[ -n "$a" ] || { echo "anthropic declares no $field"; return 1; }
[ "$a" = "$b" ] \
|| { echo "$field differs: anthropic=$a anthropic-api-key=$b"; return 1; }
done
}
@test "a credential_field is dot-separated names, not an expression" {
# The proxy walks these segments itself, so a value that could name an
# expression would be one that could name a computation. It is a config
# type now, because where a key sits in a file is the operator's.
local good bad
for good in primaryApiKey a.b.c _x1; do
lib _config_valid_value jsonfield "$good" \
|| { echo "refused '$good'"; return 1; }
done
for bad in '.leading' 'trailing.' 'a..b' 'a[0]' 'a|keys' '$env' 'a b' '1abc'; do
run lib _config_valid_value jsonfield "$bad"
[ "$status" -ne 0 ] || { echo "accepted '$bad'"; return 1; }
done
}
@test "every backend section takes a proxy key, whatever its manifest says" {
# Whether to withhold a credential is dejima's question, not the backend
# author's, so core appends it rather than each manifest declaring it - a
# backend that forgot would silently lose the setting.
local b
for b in $(lib provider_list backend); do
[ "$(lib config_type "backend.$b.proxy")" = "?bool" ] \
|| { echo "backend '$b' has no proxy key"; return 1; }
done
# No default, because unset is a third state: inherit [credentials] proxy.
# A default of either value would decide for every backend at once.
[ -z "$(lib config_default backend.anthropic.proxy)" ] \
|| { echo "the per-backend proxy key ships a default"; return 1; }
}
+184 -18
View File
@@ -7,7 +7,7 @@ PLACEHOLDER="sandbox-credential-proxy-placeholder"
setup() {
REPO_ROOT="$BATS_TEST_DIRNAME/.."
PROXY="$REPO_ROOT/bin/dejima-anthropic-proxy"
PROXY="$REPO_ROOT/bin/dejima-credential-proxy"
TD="$(mktemp -d)"
SOCK="$TD/proxy.sock"
CREDS="$TD/credentials.json"
@@ -27,10 +27,21 @@ EOF
chmod 600 "$CREDS"
}
# The Anthropic arrangement, spelled the way bin/dejima spells it. The proxy
# itself now defaults none of this (#171), so a test that omitted a flag would
# be exercising a configuration no launch produces - which is why these are
# here in full rather than left to defaults, and why static.bats pins that
# bin/dejima still passes the same set.
start_proxy() {
"$PROXY" --socket "$SOCK" --credentials "$CREDS" \
--upstream "http://127.0.0.1:$PORT" \
--oauth-endpoint "http://127.0.0.1:$PORT/v1/oauth/token" "$@" 2>"$TD/stderr" &
--credential-kind anthropic-oauth \
--oauth-endpoint "http://127.0.0.1:$PORT/v1/oauth/token" \
--oauth-client-id "9d1c250a-e61b-44d9-88ed-5944d1962f5e" \
--auth-header Authorization \
--auth-format "Bearer {token}" \
--strip-header x-api-key \
--ensure-header "anthropic-beta=oauth-2025-04-20" "$@" 2>"$TD/stderr" &
PROXY_PID=$!
for _ in $(seq 1 50); do [ -S "$SOCK" ] && break; sleep 0.1; done
[ -S "$SOCK" ]
@@ -268,17 +279,22 @@ teardown() {
@test "an already-expired refresh token is fatal at startup, before the socket exists" {
write_creds 7200
sed -i 's/"refreshTokenExpiresAt":[0-9]*/"refreshTokenExpiresAt":1000/' "$CREDS"
run "$PROXY" --socket "$SOCK" --credentials "$CREDS" --upstream "http://127.0.0.1:$PORT"
[ "$status" -ne 0 ]
run timeout 5 "$PROXY" --socket "$SOCK" --credentials "$CREDS" \
--upstream "http://127.0.0.1:$PORT" --credential-kind anthropic-oauth \
--oauth-endpoint "http://127.0.0.1:$PORT/v1/oauth/token" \
--oauth-client-id probe
# 1, not just nonzero: an argument mistake exits 2, and this test would
# otherwise pass on one while proving nothing about the expiry check.
[ "$status" -eq 1 ]
[[ "$output" == *"log in"* ]]
[ ! -S "$SOCK" ]
}
@test "forwarder bridges TCP to the proxy's unix socket" {
start_proxy
SANDBOX_ANTHROPIC_PROXY_SOCKET="$SOCK" SANDBOX_ANTHROPIC_PROXY_PORT=18399 \
SANDBOX_ANTHROPIC_TRANSPORT=loopback \
node "$REPO_ROOT/docker/anthropic-proxy-forwarder.js" &
SANDBOX_CREDENTIAL_PROXY_SOCKET="$SOCK" SANDBOX_CREDENTIAL_PROXY_PORT=18399 \
SANDBOX_CREDENTIAL_TRANSPORT=loopback \
node "$REPO_ROOT/docker/credential-proxy-forwarder.js" &
FWD_PID=$!
ok=""
for _ in $(seq 1 50); do
@@ -342,12 +358,12 @@ PYEOF
# ANTHROPIC_BASE_URL at all, then pipes raw bytes into the same unix socket
# the loopback mode uses - the host proxy cannot tell the two apart.
start_forwarder() { # start_forwarder <transport> <port>; extra env passes through
SANDBOX_ANTHROPIC_PROXY_SOCKET="$SOCK" \
SANDBOX_ANTHROPIC_TRANSPORT="$1" \
SANDBOX_ANTHROPIC_PROXY_PORT="$2" \
SANDBOX_ANTHROPIC_TLS_CERT="$TD/cert.pem" \
SANDBOX_ANTHROPIC_TLS_KEY="$TD/key.pem" \
node "$REPO_ROOT/docker/anthropic-proxy-forwarder.js" 2>"$TD/fwd-stderr" &
SANDBOX_CREDENTIAL_PROXY_SOCKET="$SOCK" \
SANDBOX_CREDENTIAL_TRANSPORT="$1" \
SANDBOX_CREDENTIAL_PROXY_PORT="$2" \
SANDBOX_CREDENTIAL_TLS_CERT="$TD/cert.pem" \
SANDBOX_CREDENTIAL_TLS_KEY="$TD/key.pem" \
node "$REPO_ROOT/docker/credential-proxy-forwarder.js" 2>"$TD/fwd-stderr" &
FWD_PID=$!
for _ in $(seq 1 50); do
(exec 3<>"/dev/tcp/127.0.0.1/$2") 2>/dev/null && return 0
@@ -419,12 +435,12 @@ make_cert() {
#
# 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 \
node "$REPO_ROOT/docker/anthropic-proxy-forwarder.js"
run env -u SANDBOX_CREDENTIAL_TLS_CERT -u SANDBOX_CREDENTIAL_TLS_KEY \
SANDBOX_CREDENTIAL_PROXY_SOCKET="$SOCK" SANDBOX_CREDENTIAL_TRANSPORT=tls \
SANDBOX_CREDENTIAL_PROXY_PORT=18398 \
node "$REPO_ROOT/docker/credential-proxy-forwarder.js"
[ "$status" -ne 0 ]
[[ "$output" == *"SANDBOX_ANTHROPIC_TLS_CERT"* ]]
[[ "$output" == *"SANDBOX_CREDENTIAL_TLS_CERT"* ]]
}
@test "an oversized request body is refused rather than read into host RAM" {
@@ -464,3 +480,153 @@ make_cert() {
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$output" = "200" ] || { echo "a 2MiB body was refused with $output"; return 1; }
}
# --- backend-generic behaviour (#171) ----------------------------------------
#
# The proxy knows no backend's name: upstream, credential kind, auth
# header and shape, what to strip and what to ensure are all arguments. These
# exercise the second kind - a static key, which is what an OpenAI-compatible
# hosted backend has - and the parts of the shape a backend can move.
# A static-key proxy against the same fake upstream. $1.. are extra flags.
# Startup refusals run the proxy in the FOREGROUND and expect it to exit, so
# every one of them is bounded by `timeout`. Without it, a check that stopped
# refusing does not fail the test - the proxy starts, serves forever, and the
# test hangs until the whole suite is killed, which reports as no result rather
# than as a wrong one. Found by mutating two of these and watching them time
# out instead of going red.
start_static() {
printf 'sk-test-static-key\n' > "$TD/key"
chmod 600 "$TD/key"
"$PROXY" --socket "$SOCK" --credentials "$TD/key" \
--upstream "http://127.0.0.1:$PORT" \
--credential-kind static "$@" 2>"$TD/stderr" &
PROXY_PID=$!
for _ in $(seq 1 50); do [ -S "$SOCK" ] && break; sleep 0.1; done
[ -S "$SOCK" ]
}
@test "a static key is injected, and the placeholder never reaches upstream" {
start_static
run pcurl -d '{"hello":1}' http://proxy/v1/chat/completions
[ "$status" -eq 0 ]
[ "$(jq -r '.headers.authorization' <<<"$output")" = "Bearer sk-test-static-key" ]
[[ "$output" != *"$PLACEHOLDER"* ]]
[ "$(jq -r '.body' <<<"$output")" = '{"hello":1}' ]
}
@test "a static key file's trailing newline does not reach the header" {
# Written by an editor or a shell redirection as often as by a tool. A
# newline in a header value is not cosmetic: it would be rejected or split
# the block.
start_static
run pcurl -d '{}' http://proxy/v1/chat/completions
[ "$(jq -r '.headers.authorization' <<<"$output")" = "Bearer sk-test-static-key" ]
}
@test "a static credential is never refreshed, so a 401 is reported once" {
# Refreshing needs somewhere to refresh against, and a static key has
# none. Retrying would send the same bytes and turn one rejection into
# two, which reads as a flapping upstream rather than a wrong key.
# The same route the OAuth retry test uses: 401 on the first call, echo
# after. An OAuth credential refreshes and the second call succeeds; a
# static one has nowhere to refresh against, so the 401 is what the
# session sees.
start_static
run pcurl -o /dev/null -w '%{http_code}' -d '{}' http://proxy/echo-401-once
[ "$output" = "401" ]
# And no refresh was attempted anywhere, which the fake counts for us.
[ "$(curl -s "http://127.0.0.1:$PORT/oauth-hits")" = "0" ]
}
@test "the auth header and its format are the backend's, not Bearer by fiat" {
# A backend that wants its key in a header of its own, unprefixed, needs
# no code here. Asserted together because the pair is the shape.
start_static --auth-header x-goog-api-key --auth-format '{token}'
run pcurl -d '{}' http://proxy/v1/chat/completions
[ "$(jq -r '.headers["x-goog-api-key"]' <<<"$output")" = "sk-test-static-key" ]
# And the client's own Authorization placeholder is left alone rather than
# silently rewritten: it is not this backend's auth header. Stripping it
# is a separate declaration, which the next test covers.
[ "$(jq -r '.headers.authorization' <<<"$output")" = "Bearer $PLACEHOLDER" ]
}
@test "a header named in strip-header does not travel beside the injected one" {
start_static --auth-header x-goog-api-key --auth-format '{token}' \
--strip-header authorization
run pcurl -d '{}' http://proxy/v1/chat/completions
[ "$(jq -r '.headers.authorization // "absent"' <<<"$output")" = "absent" ]
[ "$(jq -r '.headers["x-goog-api-key"]' <<<"$output")" = "sk-test-static-key" ]
}
@test "the auth header cannot be stripped out from under itself" {
# Naming it in both places is a backend's mistake, and the safe reading
# is that the credential still goes: dropping it would send an
# unauthenticated request and report the backend's 401 as the fault.
#
# Position as well as presence, because presence alone does not
# distinguish the two paths. A stripped-then-re-added header arrives at
# the END of the block from the supplied-names pass; a replaced one keeps
# the slot the client gave it. Both send the credential, and only one of
# them leaves the request looking like the client wrote it - which is the
# property the whole header-emission dance exists for.
start_static --strip-header authorization
run pcurl -H "Content-Type: application/json" -H "X-Stainless-Lang: js" \
-d '{}' http://proxy/v1/chat/completions
[ "$(jq -r '.headers.authorization' <<<"$output")" = "Bearer sk-test-static-key" ]
local auth_at type_at
auth_at="$(jq -r '[.header_lines[][0] | ascii_downcase] | index("authorization")' <<<"$output")"
type_at="$(jq -r '[.header_lines[][0] | ascii_downcase] | index("content-type")' <<<"$output")"
[ "$auth_at" -lt "$type_at" ] \
|| { echo "Authorization moved to position $auth_at, after Content-Type at $type_at"; return 1; }
}
@test "no ensure-header means none is invented" {
# anthropic-beta is one backend's requirement. A proxy that added it
# anyway would put an Anthropic tell on every other backend's wire.
start_static
run pcurl -d '{}' http://proxy/v1/chat/completions
[ "$(jq -r '.headers["anthropic-beta"] // "absent"' <<<"$output")" = "absent" ]
}
@test "an unusable static key is fatal at startup, before the socket exists" {
: > "$TD/key"
run timeout 5 "$PROXY" --socket "$SOCK" --credentials "$TD/key" \
--upstream "http://127.0.0.1:$PORT" --credential-kind static
[ "$status" -eq 1 ]
[[ "$output" == *"empty"* ]]
[ ! -S "$SOCK" ]
}
@test "a key carrying a control character is refused rather than sanitised" {
# A key that needs sanitising was pasted wrong. Trimming it would send
# mangled bytes and report the backend's rejection as the fault.
printf 'sk-good\x1bbad\n' > "$TD/key"
run timeout 5 "$PROXY" --socket "$SOCK" --credentials "$TD/key" \
--upstream "http://127.0.0.1:$PORT" --credential-kind static
[ "$status" -eq 1 ]
[[ "$output" == *"control character"* ]]
[ ! -S "$SOCK" ]
}
@test "an auth-format with no token placeholder is refused" {
# It would start, answer requests, and send every one of them without a
# credential. Refusing at parse time is the difference between a
# configuration error and a silently unauthenticated session.
printf 'k\n' > "$TD/key"
run timeout 5 "$PROXY" --socket "$SOCK" --credentials "$TD/key" \
--upstream "http://127.0.0.1:$PORT" --credential-kind static \
--auth-format 'Bearer'
[ "$status" -eq 2 ]
[[ "$output" == *"{token}"* ]]
[ ! -S "$SOCK" ]
}
@test "anthropic-oauth without its endpoint or client id is refused" {
write_creds 7200
run timeout 5 "$PROXY" --socket "$SOCK" --credentials "$CREDS" \
--upstream "http://127.0.0.1:$PORT" --credential-kind anthropic-oauth
[ "$status" -eq 2 ]
[[ "$output" == *"oauth-endpoint"* ]]
[ ! -S "$SOCK" ]
}
+31 -2
View File
@@ -132,8 +132,11 @@ added="$(comm -13 \
# itself the thing that makes the rollback refuse.
# The singular is spelled out rather than derived: "harnesses" does not lose
# an s to become "harness", and a manifest is named for the singular.
#
# Every axis, including backends: the newest one is exactly the one a stale
# list would miss, because a list is only ever updated by whoever remembers.
new_providers=""
for pair in harnesses:harness forges:forge workflows:workflow; do
for pair in harnesses:harness forges:forge workflows:workflow backends:backend; do
axis="${pair%%:*}"
one="${pair#*:}"
[ -d "$axis" ] || continue
@@ -144,9 +147,35 @@ for pair in harnesses:harness forges:forge workflows:workflow; do
done < <(find "$axis" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort)
done
if [ -n "$added" ] || [ -n "$new_providers" ]; then
# And the keys a provider DECLARES, which are config keys an operator writes
# and an older dejima refuses - but which live in a manifest rather than in
# core's schema, so the diff above cannot see them. `[harness.claude-code]
# backend` is one: adopting it is what makes the rollback refuse, and nothing
# said so.
new_keys=""
for pair in harnesses:harness forges:forge workflows:workflow backends:backend; do
axis="${pair%%:*}"
one="${pair#*:}"
[ -d "$axis" ] || continue
while IFS= read -r name; do
[ -n "$name" ] || continue
now="$(sed -n 's/^keys *= *//p' "$axis/$name/$one.conf" 2>/dev/null \
| tr ' ' '\n' | sed 's/:.*//' | sort -u)"
was="$(git show "$base:$axis/$name/$one.conf" 2>/dev/null \
| sed -n 's/^keys *= *//p' | tr ' ' '\n' | sed 's/:.*//' | sort -u)"
while IFS= read -r k; do
[ -n "$k" ] || continue
new_keys="$new_keys [$one.$name]|$k"
done < <(comm -13 <(printf '%s\n' "$was") <(printf '%s\n' "$now"))
done < <(find "$axis" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort)
done
if [ -n "$added" ] || [ -n "$new_providers" ] || [ -n "$new_keys" ]; then
[ -z "$added" ] || printf ' %s\n' $added
[ -z "$new_providers" ] || printf ' [%s] as a config section\n' $new_providers
for entry in $new_keys; do
printf ' %s %s\n' "${entry%%|*}" "${entry#*|}"
done
else
echo " (none - a rollback accepts any config this release accepts)"
fi
+95 -21
View File
@@ -1353,15 +1353,29 @@ EOF
# the whitelist below is what makes the log sufficient.
stub_curl() {
STUB_DIR="${STUB_DIR:-$(mktemp -d)}"
# stdin as well as argv, because the request URL now arrives that way and
# a stub that only saw argv would report the push as carrying no
# destination at all - which reads like the value being dropped.
#
# Drained only when `--config -` asked for it, exactly as the real curl
# does. A stub that reads stdin unconditionally HANGS when the caller
# sends none, so a change that stopped redirecting would fail this suite
# by timing out rather than by failing an assertion.
cat > "$STUB_DIR/curl" <<EOF
#!/bin/bash
printf '%s\n' "\$@" >> "$STUB_DIR/curl-argv"
_want_stdin=
for _a in "\$@"; do
[ "\$_a" = --config ] && _want_stdin=1
done
[ -z "\$_want_stdin" ] || cat >> "$STUB_DIR/curl-stdin"
exit 0
EOF
chmod +x "$STUB_DIR/curl"
PATH="$STUB_DIR:$PATH"
}
curl_log() { cat "$STUB_DIR/curl-argv" 2>/dev/null; }
curl_stdin() { cat "$STUB_DIR/curl-stdin" 2>/dev/null; }
# The EXACT argument vector of one push, one argument per line - a whitelist,
# not a search. Absence assertions cannot do this job: an argument that leaks
@@ -1375,7 +1389,7 @@ expect_argv() {
-H "Title: dejima: $1" \
-H "Priority: default"
[ -z "${3:-}" ] || printf '%s\n' -H "Click: $3"
printf '%s\n' -d "$2" -- https://ntfy.example.invalid/dejima
printf '%s\n' -d "$2" --config -
}
# Compared as a whole file, so a SECOND push would fail this too. The
# sentinel is what makes "whole" true: both sides are command substitutions,
@@ -1569,6 +1583,49 @@ EOF
[ -z "$(curl_log)" ]
}
@test "the ntfy URL never reaches curl's argv, because it can carry a token" {
# An ntfy server that wants credentials takes them in the URL, and
# docs/remote-access.md documents that form. Every argument of every
# process on this host is readable by any local account through
# /proc/<pid>/cmdline, so a URL in argv is a token published to the
# machine for as long as the request runs - `ps` is enough.
#
# Both halves are asserted. Absent from argv is the fix; present on stdin
# is what stops the fix from being "the push lost its destination", which
# would pass an absence check just as well.
stub_curl
conf_with_ntfy
write_entry 0123456789abcdef '%1' 'work:2'
marker 0123456789abcdef waiting
run "$REPO_ROOT/bin/dejima-notify-watch" --once
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$(curl_log)" != *ntfy.example.invalid* ]] \
|| { echo "the URL is in argv:"; curl_log; return 1; }
[[ "$(curl_stdin)" == *'url = "https://ntfy.example.invalid/dejima"'* ]] \
|| { echo "the URL did not reach curl at all:"; curl_stdin; return 1; }
# The click URL is not secret and stays where it is: it is composed from
# base_url, which is the address of the operator's own front door.
[[ "$(curl_log)" == *"Click: https://dejima.example.invalid/?arg=0123456789abcdef"* ]] \
|| { echo "the click target moved:"; curl_log; return 1; }
}
@test "a misconfigured ntfy URL is not echoed back with its credentials" {
# The refusal path prints the offending value, and the journal is where
# that lands under a systemd user unit. A token that survives a
# misconfiguration by being written to the journal is a token that
# outlives the mistake.
stub_curl
write_remote_config 'ntfy_url = https://someone:hunter2@ntfy.example.invalid/t{}'
run "$REPO_ROOT/bin/dejima-notify-watch" --once
[ "$status" -ne 0 ] || { echo "a bad URL was accepted: $output"; return 1; }
[[ "$output" != *hunter2* ]] \
|| { echo "the credential was echoed back: $output"; return 1; }
[[ "$output" == *"ntfy_url"* ]] || { echo "$output"; return 1; }
[ -z "$(curl_log)" ]
}
@test "a config value curl would read as an option is refused, not passed on" {
# The url schema type requires an http(s) scheme, which "-K/path" is not -
# verified against the real curl, which would otherwise read "-K/path" as
@@ -2677,10 +2734,14 @@ nginx_blocks() {
}
@test "install.sh links the remote scripts and the notify hook, registers neither, and enables nothing" {
# 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"
# The hook is baked into the image and registered by absolute path, so
# install.sh puts nothing of it on the host - which file it is, and where
# a session reads it from, is the harness plugin's business. The CLIs stay
# install.sh's, being dejima's own.
grep -q 'COPY hooks/dejima-notify.sh /usr/local/lib/dejima/hooks/dejima-notify.sh' \
"$REPO_ROOT/docker/Dockerfile"
grep -q '_link_hook' "$REPO_ROOT/bin/harness/claude-code.sh" \
&& { echo "install.sh still links hooks into ~/.claude/hooks"; return 1; }
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"
@@ -2712,13 +2773,25 @@ nginx_blocks() {
# $output, and this is a statement about what THIS run printed.
[[ "$output" == *"nothing is listening"* ]]
# bin/dejima registers $HOME/.claude/hooks/dejima-notify.sh per session,
# and ~/.claude/hooks is what gets mounted read-only into the container.
# Without this link every notification hook is an exit-127.
[ -L "$FAKE_HOME/.claude/hooks/dejima-notify.sh" ]
[ "$(readlink -f "$FAKE_HOME/.claude/hooks/dejima-notify.sh")" \
= "$(readlink -f "$REPO_ROOT/hooks/dejima-notify.sh")" ]
[ -x "$FAKE_HOME/.claude/hooks/dejima-notify.sh" ]
# The notify hook is baked into the image and registered per session by
# its absolute path there, so nothing about it lands on the host. Without
# the baked copy every notification hook would be an exit-127.
[ ! -e "$FAKE_HOME/.claude/hooks/dejima-notify.sh" ] \
|| { echo "install.sh still puts the notify hook on the host"; return 1; }
# Asserted on what the registration PRODUCES rather than on the source
# text: the path is a variable there, and a grep for the literal passes or
# fails on how it happens to be spelled rather than on where it points.
local st; st="$(mktemp)"; echo '{}' > "$st"
bash -c '
source "'"$REPO_ROOT"'/bin/dejima-provider-lib.sh"
source "'"$REPO_ROOT"'/bin/dejima-harness-lib.sh"
harness_plugin_load claude-code
harness_hook harness_register_notify "$1"
' _ "$st" >/dev/null 2>&1
[ "$(jq -r '.hooks.Stop[0].hooks[0].command' "$st")" \
= "/usr/local/lib/dejima/hooks/dejima-notify.sh done" ] \
|| { echo "notify is not registered at its baked path: $(cat "$st")"; rm -f "$st"; return 1; }
rm -f "$st"
local b
for b in dejima-attach dejima-ttyd dejima-notify-watch; do
[ -L "$FAKE_HOME/.local/bin/$b" ]
@@ -2726,14 +2799,15 @@ nginx_blocks() {
[ -x "$FAKE_HOME/.local/bin/$b" ]
done
# Positive first: an absence assertion against a settings.json that did
# not get written would pass on its own.
run jq -e '[.hooks[]?[]?.hooks[]?.command] | any(test("sandbox-context.sh"))' \
"$FAKE_HOME/.claude/settings.json"
[ "$status" -eq 0 ]
run jq -e '[.hooks[]?[]?.hooks[]?.command] | any(test("dejima-notify"))' \
"$FAKE_HOME/.claude/settings.json"
[ "$status" -ne 0 ]
# install.sh registers nothing in the harness's config now, so there is
# nothing positive to check there - the settings file may not exist at
# all. The positive assertion that matters moved above: the notify hook IS
# registered, at its baked path, in the sandbox's own overlay.
if [ -f "$FAKE_HOME/.claude/settings.json" ]; then
run jq -e '[.hooks[]?[]?.hooks[]?.command] | any(test("dejima-notify"))' \
"$FAKE_HOME/.claude/settings.json"
[ "$status" -ne 0 ]
fi
# Nothing was enabled, and nothing under the runtime dir was created: the
# feature stays off until the operator sets [remote-access] enabled = on.
@@ -2744,7 +2818,7 @@ nginx_blocks() {
run env HOME="$FAKE_HOME" PATH="$STUB_DIR:$PATH" "$REPO_ROOT/install.sh"
[ "$status" -eq 0 ]
[ ! -e "$FAKE_HOME/.dejima-backup" ]
[ -L "$FAKE_HOME/.claude/hooks/dejima-notify.sh" ]
[ ! -e "$FAKE_HOME/.claude/hooks/dejima-notify.sh" ]
# And with one, it may not: re-running the installer after a git pull is
# exactly what an operator with a front door already up does, and telling
+216 -19
View File
@@ -1,13 +1,16 @@
#!/bin/bash
# Every confined suite at once, for the case where the whole set is genuinely
# wanted - before opening a pull request, or after touching bin/dejima, which
# nearly every suite reads.
# The confined suites, in one of two lanes.
#
# The suites are separate files that share no state, which is what makes this
# safe: each writes into its own BATS_*_TMPDIR, and the two that bind a socket
# ask the kernel for a free port rather than naming one. Run sequentially they
# take about twenty-five minutes on ten cores; run together, about seven, which
# is the difference between checking the whole set and not bothering.
# test/run-confined.sh every confined suite - the pull-request gate
# test/run-confined.sh --changed only the suites the working diff calls for
#
# The suites are separate files that share no state, which is what makes running
# them together safe: each writes into its own BATS_*_TMPDIR, and the two that
# bind a socket ask the kernel for a free port rather than naming one. Within a
# file, bats' own --jobs parallelism is used too; it needs flock rather than GNU
# parallel, which is why --no-parallelize-across-files is passed alongside it
# (bats refuses --jobs without GNU parallel unless across-files is off, and this
# script is already doing the across-files half itself).
#
# CI does not use this. It runs the same suites as a matrix, one job each, so
# that a failure names the suite it came from without anyone reading a log -
@@ -26,22 +29,216 @@ cd "$root" || exit 1
# this repo is usually worked on.
PRIVILEGED=" runtime ci-runner "
suites=()
# Which suites a changed path calls for. This is the table .dejima.md used to
# carry as prose; it is here so that it runs, and so that an agent iterating
# cannot map a change to the wrong suite by hand.
#
# Anything that matches no rule selects every suite. A path nobody has mapped
# is a path nobody has decided about, and guessing "none" there is how a change
# ships having been checked by nothing.
suites_for() {
case "$1" in
# Read by nearly every other suite, so a change to one is the whole set.
bin/dejima|bin/dejima-config-lib.sh) echo ALL ;;
bin/dejima-audit) echo audit ;;
bin/dejima-update-check) echo config ;;
bin/dejima-epitaph) echo config ;;
bin/dejima-tty-suspend) echo static ;;
bin/dejima-color-lib.sh) echo 'remote project' ;;
bin/dejima-tui-lib.sh) echo 'remote project' ;;
bin/dejima-conversation-ship) echo conversation ;;
bin/dejima-share) echo share ;;
# The slug scheme keys the transcript mounts, the epitaph and audit
# stores, and the share layout, so a change to it is not one area's.
bin/dejima-slug-lib.sh) echo 'share conversation audit static' ;;
bin/dejima-credential-proxy|docker/credential-proxy-forwarder.js) echo proxy ;;
bin/dejima-project-lib.sh|bin/dejima-project|bin/dejima-dep|bin/dejima-allow) echo project ;;
bin/dejima-attach|bin/dejima-ttyd|bin/dejima-notify-watch) echo remote ;;
web/*|systemd/*) echo remote ;;
bin/dejima-provider-lib.sh|bin/dejima-harness-lib.sh) echo providers ;;
bin/harness/*) echo providers ;;
forges/*|workflows/*|harnesses/*|tools/*) echo providers ;;
install.sh) echo config ;;
# static.bats asserts on files it does not own, so a shipped doc or a
# docker/ file needs it as well as whatever else the change touched -
# which for docker/ is most things, since the entrypoint and compose
# file are read by nearly every suite that builds a launch.
docker/*) echo ALL ;;
hooks/*) echo 'static config providers project' ;;
docs/*|README.md|*.md) echo static ;;
.gitea/*|.shellcheckrc) echo static ;;
.dejima/*) echo 'static config project providers' ;;
test/*.bats) local n="${1##*/}"; echo "${n%.bats}" ;;
test/*) echo static ;;
*) echo ALL ;;
esac
}
lane=all
jobs=""
base=""
list_only=0
given_paths=()
while [ "$#" -gt 0 ]; do
case "$1" in
--changed) lane=changed ;;
--base) shift; base="${1:-}" ;;
--list) list_only=1 ;;
-j|--jobs) shift; jobs="${1:-}" ;;
-h|--help)
sed -n '2,5p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
printf ' --list print the selection and stop\n'
printf ' --jobs N tests to run at once within each suite\n'
printf ' --base REF what --changed diffs against\n'
exit 0 ;;
-*) echo "unknown argument: $1" >&2; exit 2 ;;
# A path given here stands in for one git would have reported, so the
# map can be asked what it does with a path without the answer
# depending on what happens to be uncommitted.
*) given_paths+=("$1") ;;
esac
shift
done
if [ "$lane" != changed ] && [ "${#given_paths[@]}" -gt 0 ]; then
echo "paths only mean something with --changed: ${given_paths[*]}" >&2
exit 2
fi
all_suites=()
for f in test/*.bats; do
[ -f "$f" ] || continue
name="$(basename "$f" .bats)"
case "$PRIVILEGED" in *" $name "*) continue ;; esac
suites+=("$name")
all_suites+=("$name")
done
[ "${#suites[@]}" -gt 0 ] || { echo "found no confined suites under test/" >&2; exit 1; }
[ "${#all_suites[@]}" -gt 0 ] || { echo "found no confined suites under test/" >&2; exit 1; }
# Within-file parallelism is bats' own, and it needs flock. Without it bats
# exits before running anything, so the fallback is stated rather than left to
# look like a broken suite. The size of it is decided after the selection,
# below, because it depends on how many suites there are.
if command -v flock >/dev/null 2>&1; then
can_parallelize=1
else
can_parallelize=0
fi
selected=()
changed_paths=()
if [ "$lane" = changed ]; then
if [ "${#given_paths[@]}" -gt 0 ]; then
changed_paths=("${given_paths[@]}")
else
if [ -z "$base" ]; then
for candidate in origin/development development origin/main main; do
if git rev-parse --verify --quiet "$candidate" >/dev/null; then
base="$candidate"
break
fi
done
fi
# Committed work plus what is still in the working tree, including
# files git has never seen: iteration happens before a commit, and a
# lane that only saw committed changes would report on the wrong thing.
mapfile -t changed_paths < <(
{
[ -z "$base" ] || git diff --name-only \
"$(git merge-base "$base" HEAD 2>/dev/null || echo "$base")" 2>/dev/null
git diff --name-only HEAD 2>/dev/null
git ls-files --others --exclude-standard 2>/dev/null
} | sort -u
)
fi
wanted=" "
for p in "${changed_paths[@]}"; do
[ -n "$p" ] || continue
for s in $(suites_for "$p"); do
[ "$s" != ALL ] || { wanted=ALL; break 2; }
case "$wanted" in *" $s "*) ;; *) wanted="$wanted$s " ;; esac
done
done
if [ "$wanted" = ALL ]; then
selected=("${all_suites[@]}")
else
for s in "${all_suites[@]}"; do
case "$wanted" in *" $s "*) selected+=("$s") ;; esac
done
fi
else
selected=("${all_suites[@]}")
fi
skipped=()
for s in "${all_suites[@]}"; do
for t in ${selected[@]+"${selected[@]}"}; do [ "$t" = "$s" ] && continue 2; done
skipped+=("$s")
done
# Which lane ran, before anything else, because the whole point of having two
# is that a run can be read back later without guessing which one it was. To
# stderr, so --list's stdout is the suite names and nothing else.
# How much concurrency to actually ask for.
#
# Every selected suite runs at once, so what lands on the machine is
# suites x jobs. Asking each suite for `nproc` made that ten times the cores
# on a ten-core box, and #295 is what that cost: the tests that wait on a
# bounded deadline - a pty reaching raw mode, a shipper polling, an audit
# finishing - blow through it on a machine that oversubscribed, and fail for
# the load rather than for the thing they check.
#
# Oversubscription still helps, because these tests fork more than they
# compute. It is a property of the whole run rather than of each suite, so it
# is budgeted once and divided: roughly twice the cores, in total.
if [ -z "$jobs" ]; then
if [ "$can_parallelize" = 1 ] && [ "${#selected[@]}" -gt 0 ]; then
cores="$(nproc 2>/dev/null || echo 4)"
budget=$(( cores * 2 ))
jobs=$(( (budget + ${#selected[@]} - 1) / ${#selected[@]} ))
[ "$jobs" -ge 1 ] || jobs=1
else
jobs=1
fi
fi
bats_flags=()
[ "$jobs" -gt 1 ] && bats_flags=(--jobs "$jobs" --no-parallelize-across-files)
{
if [ "$lane" = changed ]; then
printf 'lane: CHANGED - %d of %d confined suites, from %d changed path(s)\n' \
"${#selected[@]}" "${#all_suites[@]}" "${#changed_paths[@]}"
[ "${#skipped[@]}" -eq 0 ] || printf ' not run: %s\n' "${skipped[*]}"
printf ' An iteration check. NOT the pull-request gate - for that run\n'
printf ' test/run-confined.sh with no arguments.\n'
else
printf 'lane: ALL - every confined suite (the pull-request gate)\n'
printf ' not run: runtime, ci-runner - they need Docker and a real runner.\n'
fi
[ "$list_only" = 1 ] || printf ' %d test(s) at a time within each suite, %d suites at once\n' \
"$jobs" "${#selected[@]}"
printf '\n'
} >&2
if [ "$list_only" = 1 ]; then
[ "${#selected[@]}" -eq 0 ] || printf '%s\n' "${selected[@]}"
exit 0
fi
if [ "${#selected[@]}" -eq 0 ]; then
echo "nothing to run: no changed path maps to a confined suite"
exit 0
fi
# One process per suite. There are fewer suites than cores on anything this
# repo is developed on, and they are shell rather than CPU-bound, so a job
# limit would cost wall clock for no benefit.
out="$(mktemp -d)"
trap 'rm -rf "$out"' EXIT
for s in "${suites[@]}"; do
( bats "test/$s.bats" > "$out/$s" 2>&1; printf '%s' "$?" > "$out/$s.status" ) &
for s in "${selected[@]}"; do
( bats ${bats_flags[@]+"${bats_flags[@]}"} "test/$s.bats" > "$out/$s" 2>&1
printf '%s' "$?" > "$out/$s.status" ) &
done
wait
@@ -51,7 +248,7 @@ wait
# that as "0 failures" is how a green board comes to mean nothing.
failed=0
printf '%-16s %6s %8s\n' suite ran failed
for s in "${suites[@]}"; do
for s in "${selected[@]}"; do
ran="$(grep -cE '^(ok|not ok)' "$out/$s")"
bad="$(grep -cE '^not ok' "$out/$s")"
status="$(cat "$out/$s.status" 2>/dev/null || echo 1)"
@@ -69,7 +266,7 @@ done
if [ "$failed" -ne 0 ]; then
echo
for s in "${suites[@]}"; do
for s in "${selected[@]}"; do
ran="$(grep -cE '^(ok|not ok)' "$out/$s")"
bad="$(grep -cE '^not ok' "$out/$s")"
{ [ "$bad" -gt 0 ] || [ "$ran" -eq 0 ]; } || continue
@@ -77,7 +274,7 @@ if [ "$failed" -ne 0 ]; then
if [ "$ran" -eq 0 ]; then
head -20 "$out/$s"
else
grep -E '^not ok' -A 6 "$out/$s"
grep -E '^not ok' -A 20 "$out/$s"
fi
echo
done
+93 -30
View File
@@ -49,7 +49,10 @@ compose_cap_add() {
' "$BATS_TEST_DIRNAME/../docker/docker-compose.yml"
}
IMAGE=dejima:latest
# localhost/ for the same reason bin/dejima uses it: a bare name is a Docker
# Hub name to anything that pushes, and nothing built here is meant to leave
# the host (#53).
IMAGE=localhost/dejima:latest
# What a session actually runs: the base image plus the selected harness's own
# layer, built in setup_file. Exported there because bats re-runs this file's
@@ -75,6 +78,29 @@ network_name() { echo "dejima-test-net-$(basename "$BATS_RUN_TMPDIR")"; }
# long-lived runner, until someone clears it by hand.
test_container_name() { echo "dejima-test-$1-$(basename "$BATS_RUN_TMPDIR")"; }
# The Anthropic arrangement, in one place.
#
# dejima-credential-proxy defaults none of this any more (#171): which upstream,
# which credential kind, which header and what shape are all the caller's to
# state, because a default is one backend's arrangement wearing every
# backend's name. Three copies of the same eight flags in this file would be
# three places for them to drift from what bin/dejima actually launches, so
# there is one - and static.bats pins that bin/dejima still passes the same set.
#
# Sets PROXY_PID, which every caller's teardown already kills.
start_credential_proxy() { # <socket> <credentials> <upstream> <oauth-endpoint>
"$BATS_TEST_DIRNAME/../bin/dejima-credential-proxy" \
--socket "$1" --credentials "$2" --upstream "$3" \
--credential-kind anthropic-oauth \
--oauth-endpoint "$4" \
--oauth-client-id "9d1c250a-e61b-44d9-88ed-5944d1962f5e" \
--auth-header Authorization \
--auth-format "Bearer {token}" \
--strip-header x-api-key \
--ensure-header "anthropic-beta=oauth-2025-04-20" &
PROXY_PID=$!
}
# 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
@@ -165,11 +191,19 @@ harness_manifest_value() {
sed -n "s/^$1 *= *//p" "$BATS_TEST_DIRNAME/../harnesses/claude-code/harness.conf"
}
# The same, for the selected model backend. proxied_domain lives off the
# harness manifest onto this one (#171): a harness reaches whichever endpoint
# it is configured for, so the name the terminator answers for belongs to the
# endpoint rather than to the program talking to it.
backend_manifest_value() {
sed -n "s/^$1 *= *//p" "$BATS_TEST_DIRNAME/../backends/anthropic/backend.conf"
}
# The selected harness's image layer, on top of $1, tagged for this run so
# teardown_file's sweep finds it. Prints the new tag.
build_harness_layer() {
local base="$1" tag df ctx
tag="dejima-test-harness:$(basename "$BATS_RUN_TMPDIR")"
tag="localhost/dejima-test-harness:$(basename "$BATS_RUN_TMPDIR")"
df="$(mktemp)"
# An empty build context, for the reason bin/dejima uses one: a build
# whose context is a directory anything else can write is a way into the
@@ -321,6 +355,37 @@ setup_file() {
done < <(docker ps -a --filter name=^dejima-test- --filter status=exited \
--filter status=created --format '{{.Names}}' 2>/dev/null)
# And the images this suite builds - harness, override, derived - which
# nothing reaps across runs (#26). The per-test teardown removes only the
# CURRENT run's suffix, so an abandoned run's images have no reaper at all.
# They hold no subnet, so this is disk rather than the network's kind of
# harm; it is also the largest thing here by disk, since each is built
# FROM the session image.
#
# Same age guard as above, for the same reason: a concurrent run's images
# are minutes old. The prefix is checked again on the name rather than
# trusted to the CLI filter, so the reap can only ever reach this suite's
# own namespace.
local _img _born _suffix
_suffix="$(basename "$BATS_RUN_TMPDIR")"
while IFS= read -r _img; do
[ -n "$_img" ] || continue
# Both spellings, because the images this suite builds were renamed to
# localhost/ (#53) after this reap was written against the bare form,
# and for two releases it therefore matched nothing. A reap whose
# pattern stops matching is not a reap that fails - it is one that
# silently does nothing, which is indistinguishable from having
# nothing to do, so nothing anywhere reported it.
#
# Matching both is also what stops the next rename doing it again.
case "$_img" in dejima-test-*|localhost/dejima-test-*) ;; *) continue ;; esac
case "$_img" in *":$_suffix") continue ;; esac
_born="$(docker image inspect -f '{{.Created}}' "$_img" 2>/dev/null)" || continue
_born="$(date -d "$_born" +%s 2>/dev/null)" || continue
[ "$(( $(date +%s) - _born ))" -gt "$REAP_AFTER" ] || continue
docker rmi -f "$_img" >/dev/null 2>&1 || true
done < <(docker images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null)
docker network create "$network" >/dev/null
# No DEJIMA_FORGE_HOSTS/LOKI_URL: this suite only asserts the default
@@ -360,7 +425,7 @@ setup_file() {
-e ANTHROPIC_BASE_URL= \
-e ANTHROPIC_AUTH_TOKEN= \
-e SANDBOX_HARNESS_DOMAINS="$(harness_manifest_value api_domains)" \
-e SANDBOX_HARNESS_PROXIED_DOMAIN="$(harness_manifest_value proxied_api_domain)" \
-e SANDBOX_BACKEND_PROXIED_DOMAIN="$(backend_manifest_value proxied_domain)" \
--entrypoint /usr/local/bin/entrypoint.sh \
"$SESSION_IMAGE" >/dev/null
@@ -407,7 +472,7 @@ teardown() {
docker rm -f "$c" >/dev/null 2>&1 || true
done < <(docker ps -a --filter "name=^dejima-test-.*$suffix\$" \
--format '{{.Names}}' 2>/dev/null)
docker images -q "dejima-test-*:$suffix" 2>/dev/null \
docker images -q "localhost/dejima-test-*:$suffix" 2>/dev/null \
| xargs -r docker rmi -f >/dev/null 2>&1 || true
}
@@ -420,7 +485,7 @@ dexec_node() {
}
@test "the container's user actually matches overridden SANDBOX_USER/UID/GID/HOME build args, not just the defaults" {
local test_image="dejima-test-override:$(basename "$BATS_RUN_TMPDIR")"
local test_image="localhost/dejima-test-override:$(basename "$BATS_RUN_TMPDIR")"
local test_container="$(test_container_name override)"
local sandbox_dir="$BATS_TEST_DIRNAME/../docker"
local repo_root="$BATS_TEST_DIRNAME/.."
@@ -601,7 +666,7 @@ dexec_node() {
# on, so a bypass of the /etc/hosts pin that resolves the name by DNS gets
# no address. When the proxy is off (kill switch or API-key auth), DNS is
# the only path to the API and fixed-domains.sh allowlists it. This file's
# shared container runs with no SANDBOX_ANTHROPIC_PROXY_SOCKET, so it is
# shared container runs with no SANDBOX_CREDENTIAL_PROXY_SOCKET, so it is
# in proxy-off mode and the name must resolve.
run dexec dig +short api.anthropic.com
[ -n "$output" ]
@@ -1256,17 +1321,16 @@ EOF'
cat > "$TD/credentials.json" <<EOF
{"claudeAiOauth":{"accessToken":"real-access-token","refreshToken":"r","expiresAt":$((($(date +%s) + 7200) * 1000)),"refreshTokenExpiresAt":$((($(date +%s) + 86400) * 1000)),"scopes":[],"subscriptionType":"max","rateLimitTier":"default"}}
EOF
"$BATS_TEST_DIRNAME/../bin/dejima-anthropic-proxy" \
--socket "$TD/proxy.sock" --credentials "$TD/credentials.json" \
--upstream "http://127.0.0.1:$(cat "$TD/port")" &
PROXY_PID=$!
start_credential_proxy "$TD/proxy.sock" "$TD/credentials.json" \
"http://127.0.0.1:$(cat "$TD/port")" \
"http://127.0.0.1:$(cat "$TD/port")/v1/oauth/token"
for _ in $(seq 1 50); do [ -S "$TD/proxy.sock" ] && break; sleep 0.1; done
# UnixStreamServer binds at umask default, so the socket lands 755, and
# connecting to a unix socket needs *write* permission on it. In the real
# flow the connecting uid is the one that bound it, so that is fine;
# under CI it is root binding and node connecting, and the whole chain
# this test follows fails at its first hop. Widened rather than left to
# dejima-anthropic-proxy, which has no such problem in production.
# dejima-credential-proxy, which has no such problem in production.
# Unconditional: if the loop above timed out, "No such file" here says so
# more clearly than a test that fails on a bare false condition.
chmod 666 "$TD/proxy.sock"
@@ -1277,9 +1341,9 @@ EOF
--network "$(network_name)" \
-e SANDBOX_TEST_MODE=1 \
-e GH_TOKEN="${GH_TOKEN:-}" \
-e SANDBOX_ANTHROPIC_PROXY_SOCKET="$TD/proxy.sock" \
-e SANDBOX_HARNESS_PROXIED_DOMAIN="$(harness_manifest_value proxied_api_domain)" \
-e SANDBOX_ANTHROPIC_TRANSPORT=loopback \
-e SANDBOX_CREDENTIAL_PROXY_SOCKET="$TD/proxy.sock" \
-e SANDBOX_BACKEND_PROXIED_DOMAIN="$(backend_manifest_value proxied_domain)" \
-e SANDBOX_CREDENTIAL_TRANSPORT=loopback \
-e ANTHROPIC_AUTH_TOKEN=sandbox-credential-proxy-placeholder \
-v "$TD:$TD" \
--entrypoint /usr/local/bin/entrypoint.sh \
@@ -1478,7 +1542,7 @@ EOF
}
@test "a derived project image gets the package and keeps the base privilege posture" {
local derived="dejima-test-derived:$(basename "$BATS_RUN_TMPDIR")"
local derived="localhost/dejima-test-derived:$(basename "$BATS_RUN_TMPDIR")"
local test_container="$(test_container_name derived)"
local ctx; ctx="$(mktemp -d)"
local store; store="$(mktemp -d)"
@@ -1535,13 +1599,13 @@ EOF
@test "live end-to-end: claude -p through the real proxy (RUN_LIVE_API=1 only)" {
[ "${RUN_LIVE_API:-0}" = "1" ] || skip "set RUN_LIVE_API=1 to hit the real API"
TD="$BATS_TEST_TMPDIR"
"$BATS_TEST_DIRNAME/../bin/dejima-anthropic-proxy" \
--socket "$TD/proxy.sock" --credentials "$HOME/.claude/.credentials.json" &
PROXY_PID=$!
start_credential_proxy "$TD/proxy.sock" "$HOME/.claude/.credentials.json" \
"https://api.anthropic.com" \
"https://platform.claude.com/v1/oauth/token"
for _ in $(seq 1 50); do [ -S "$TD/proxy.sock" ] && break; sleep 0.1; done
SANDBOX_ANTHROPIC_PROXY_SOCKET="$TD/proxy.sock" SANDBOX_ANTHROPIC_PROXY_PORT=18399 \
SANDBOX_ANTHROPIC_TRANSPORT=loopback \
node "$BATS_TEST_DIRNAME/../docker/anthropic-proxy-forwarder.js" &
SANDBOX_CREDENTIAL_PROXY_SOCKET="$TD/proxy.sock" SANDBOX_CREDENTIAL_PROXY_PORT=18399 \
SANDBOX_CREDENTIAL_TRANSPORT=loopback \
node "$BATS_TEST_DIRNAME/../docker/credential-proxy-forwarder.js" &
FWD_PID=$!
sleep 0.5
run env ANTHROPIC_BASE_URL=http://127.0.0.1:18399 \
@@ -1630,10 +1694,9 @@ s.serve_forever()' "$bind_addr" > "$port_file" 2>/dev/null &
cat > "$TD/credentials.json" <<EOF
{"claudeAiOauth":{"accessToken":"real-access-token","refreshToken":"r","expiresAt":$((($(date +%s) + 7200) * 1000)),"refreshTokenExpiresAt":$((($(date +%s) + 86400) * 1000)),"scopes":[],"subscriptionType":"max","rateLimitTier":"default"}}
EOF
"$BATS_TEST_DIRNAME/../bin/dejima-anthropic-proxy" \
--socket "$TD/proxy.sock" --credentials "$TD/credentials.json" \
--upstream "http://127.0.0.1:$(cat "$TD/port")" &
PROXY_PID=$!
start_credential_proxy "$TD/proxy.sock" "$TD/credentials.json" \
"http://127.0.0.1:$(cat "$TD/port")" \
"http://127.0.0.1:$(cat "$TD/port")/v1/oauth/token"
for _ in $(seq 1 50); do [ -S "$TD/proxy.sock" ] && break; sleep 0.1; done
# See the loopback test above for why this chmod is here.
chmod 666 "$TD/proxy.sock"
@@ -1645,8 +1708,8 @@ EOF
--network "$(network_name)" \
-e SANDBOX_TEST_MODE=1 \
-e GH_TOKEN="${GH_TOKEN:-}" \
-e SANDBOX_ANTHROPIC_PROXY_SOCKET="$TD/proxy.sock" \
-e SANDBOX_HARNESS_PROXIED_DOMAIN="$(harness_manifest_value proxied_api_domain)" \
-e SANDBOX_CREDENTIAL_PROXY_SOCKET="$TD/proxy.sock" \
-e SANDBOX_BACKEND_PROXIED_DOMAIN="$(backend_manifest_value proxied_domain)" \
-v "$TD:$TD" \
--entrypoint /usr/local/bin/entrypoint.sh \
"$SESSION_IMAGE" >/dev/null
@@ -1656,7 +1719,7 @@ EOF
# the image's ENV, not from the entrypoint process's exported environment.
# The session command itself is a child of the entrypoint and does inherit
# it - which is what the /proc/1/environ check below confirms.
run docker exec -e NODE_EXTRA_CA_CERTS=/etc/dejima-anthropic-tls/cert.pem \
run docker exec -e NODE_EXTRA_CA_CERTS=/etc/dejima-credential-tls/cert.pem \
"$container" node -e '
fetch("https://api.anthropic.com/v1/messages", {method: "POST", body: "{}"})
.then(r => r.text()).then(t => { console.log(t); })
@@ -1750,7 +1813,7 @@ EOF
--network "$(network_name)" \
-e SANDBOX_TEST_MODE=1 \
-e GH_TOKEN="${GH_TOKEN:-}" \
-e SANDBOX_ANTHROPIC_TRANSPORT=Loopback \
-e SANDBOX_CREDENTIAL_TRANSPORT=Loopback \
--entrypoint /usr/local/bin/entrypoint.sh \
"$SESSION_IMAGE"
exit_code=$status
@@ -1781,7 +1844,7 @@ EOF
}
[ "$exit_code" = "1" ] || dump "expected the container to exit 1"
[[ "$logs" == *"SANDBOX_ANTHROPIC_TRANSPORT is set to 'Loopback'"* ]] \
[[ "$logs" == *"SANDBOX_CREDENTIAL_TRANSPORT is set to 'Loopback'"* ]] \
|| dump "the transport check never ran - something aborted the entrypoint before it"
[[ "$logs" == *"'tls'"* ]] || dump "the refusal did not name the valid values"
[[ "$logs" == *"'loopback'"* ]] || dump "the refusal did not name the valid values"
+255
View File
@@ -0,0 +1,255 @@
#!/usr/bin/env bats
# The per-project share directory: what a launch mounts at ~/shared, and the
# host-side CLI that says where it is.
#
# `[core] share_dir` used to be mounted whole, so every session saw every other
# session's handoff files, across projects. What is asserted here is that the
# mount is now one project's subdirectory, that an operator can still find it,
# and that the files left over from the old layout are neither hidden silently
# nor moved without being asked about.
setup() {
REPO_ROOT="$BATS_TEST_DIRNAME/.."
LIB="$REPO_ROOT/bin/dejima-provider-lib.sh"
SLUG_LIB="$REPO_ROOT/bin/dejima-slug-lib.sh"
HOST="$(mktemp -d)"
CONFIG_DIR="$HOST/config"
SHARE="$HOST/share"
WS="$HOST/ws"
mkdir -p "$CONFIG_DIR" "$SHARE" "$WS" "$HOST/home/.claude"
echo '{}' > "$HOST/home/.claude/settings.json"
KEY="$HOST/sandbox-key"
: > "$KEY"
chmod 600 "$KEY"
export DEJIMA_CONFIG_DIR="$CONFIG_DIR"
# A dejima session exports several of these into every shell it starts, so
# a test that meant "unset" by not setting them would be reading the
# invoking environment rather than stating its own precondition.
unset SANDBOX_SHARE_DIR SANDBOX_CMD SANDBOX_EXTRA_MOUNTS LOKI_URL \
ENABLE_FIREWALL ALLOWED_DOMAINS
}
teardown() {
rm -rf "$HOST" ${STUB_DIR:+"$STUB_DIR"}
}
write_config() {
{
printf '[core]\nharness = claude-code\nforge = none\nworkflow = local-only\n'
printf 'update_check = off\nssh_key = %s\nshare_dir = %s\n' "$KEY" "$SHARE"
[ "$#" -eq 0 ] || printf '%s\n' "$@"
} > "$CONFIG_DIR/config"
}
slug() { bash -c 'source "$1"; ws_slug "$2"' _ "$SLUG_LIB" "$1"; }
share() { "$REPO_ROOT/bin/dejima-share" "$@"; }
# Drives the real launch with a stubbed docker, and records what
# SANDBOX_SHARE_DIR was when compose was invoked. Reading --print-config
# instead would pass against a version that resolved the path correctly and
# then mounted something else.
launch() {
STUB_DIR="$(mktemp -d)"
cat > "$STUB_DIR/docker" <<'EOF'
#!/bin/sh
printf '%s\n' "${SANDBOX_SHARE_DIR-}" >> "$SANDBOX_TEST_SHARE_LOG"
exit 0
EOF
chmod +x "$STUB_DIR/docker"
SHARE_LOG="$HOST/share-log"
: > "$SHARE_LOG"
HOME="$HOST/home" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
SANDBOX_TEST_SHARE_LOG="$SHARE_LOG" \
run "$REPO_ROOT/bin/dejima" "${1:-$WS}"
}
# What the launch actually handed compose, deduplicated - compose is invoked
# more than once per launch and every call must agree.
mounted_share() { sort -u "$SHARE_LOG" | grep -v '^$' | head -1; }
@test "a launch mounts one project's subdirectory, not share_dir itself" {
write_config
launch
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$(mounted_share)" = "$SHARE/projects/$(slug "$WS")" ] \
|| { echo "mounted '$(mounted_share)'"; return 1; }
[ -d "$SHARE/projects/$(slug "$WS")" ] \
|| { echo "the launch did not create the directory it mounts"; return 1; }
}
@test "two projects get two directories" {
local other="$HOST/other"
mkdir -p "$other"
write_config
launch "$WS"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
local first="$(mounted_share)"
launch "$other"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$first" != "$(mounted_share)" ] \
|| { echo "both projects mounted $first"; return 1; }
}
@test "dejima-share path prints the directory a launch mounts, and creates it" {
write_config
run share path "$WS"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ -d "$output" ] || { echo "path did not create $output"; return 1; }
local printed="$output"
launch
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
# The point of the shared layout helpers: an operator dropping a file where
# `path` says would otherwise be dropping it where nothing reads it.
[ "$printed" = "$(mounted_share)" ] \
|| { echo "path said $printed, the launch mounted $(mounted_share)"; return 1; }
}
@test "dejima-share path defaults to the current directory" {
write_config
cd "$WS"
run share path
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$output" = "$SHARE/projects/$(slug "$WS")" ] || { echo "$output"; return 1; }
}
@test "dejima-share path refuses a directory that does not exist" {
# It would otherwise answer a typo with a share directory nothing will
# ever mount, which is worse than refusing: the file goes somewhere real
# and no session sees it.
write_config
run share path "$HOST/no-such-project"
[ "$status" -ne 0 ]
[[ "$output" == *"no such directory"* ]] || { echo "$output"; return 1; }
}
@test "a launch says which files the split left behind, every time until they are gone" {
write_config
echo secret > "$SHARE/left-over.txt"
launch
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"left-over.txt"* ]] \
|| { echo "the launch hid a file it stopped mounting: $output"; return 1; }
[[ "$output" == *"dejima-share adopt"* ]] \
|| { echo "it does not say what to do about it: $output"; return 1; }
# Said again rather than once: a file handed to a session yesterday and
# silently not arriving today is the failure this exists against.
launch
[[ "$output" == *"left-over.txt"* ]] || { echo "$output"; return 1; }
rm "$SHARE/left-over.txt"
launch
[[ "$output" != *"no session can see"* ]] \
|| { echo "still warning with nothing left over: $output"; return 1; }
}
@test "the per-project root is not itself reported as left behind" {
write_config
launch
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" != *"no session can see"* ]] \
|| { echo "a clean share_dir warned about its own projects dir: $output"; return 1; }
}
@test "dejima-share list names each project and flags what is loose" {
write_config
share path "$WS" > /dev/null
echo x > "$SHARE/loose-file"
run share list
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"$(slug "$WS")"* ]] || { echo "$output"; return 1; }
[[ "$output" == *"loose-file"* ]] || { echo "$output"; return 1; }
# The retention story is the only one there is: nothing prunes this.
[[ "$output" == *"Nothing prunes this"* ]] || { echo "$output"; return 1; }
}
@test "adopt moves nothing without a yes" {
write_config
echo keep > "$SHARE/left-over.txt"
run bash -c 'printf "n\n" | "$1" adopt "$2"' _ "$REPO_ROOT/bin/dejima-share" "$WS"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ -f "$SHARE/left-over.txt" ] || { echo "declining still moved it"; return 1; }
# And an empty answer is a no, not a prompt loop or a yes.
run bash -c 'printf "\n" | "$1" adopt "$2"' _ "$REPO_ROOT/bin/dejima-share" "$WS"
[ -f "$SHARE/left-over.txt" ] || { echo "an empty answer moved it"; return 1; }
}
@test "adopt moves what is loose into one project's share" {
write_config
echo one > "$SHARE/a.txt"
mkdir -p "$SHARE/adir"
echo two > "$SHARE/adir/b.txt"
run bash -c 'printf "y\n" | "$1" adopt "$2"' _ "$REPO_ROOT/bin/dejima-share" "$WS"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
local dest="$SHARE/projects/$(slug "$WS")"
[ -f "$dest/a.txt" ] || { echo "a.txt did not arrive"; return 1; }
[ -f "$dest/adir/b.txt" ] || { echo "the directory did not arrive whole"; return 1; }
[ ! -e "$SHARE/a.txt" ] || { echo "a.txt is still loose"; return 1; }
[ "$(cat "$dest/a.txt")" = one ] || { echo "contents changed"; return 1; }
}
@test "adopt never overwrites" {
# Both files are the operator's and this cannot know which one they meant,
# so the loose one stays loose and says so.
write_config
local dest="$SHARE/projects/$(slug "$WS")"
mkdir -p "$dest"
echo kept > "$dest/a.txt"
echo incoming > "$SHARE/a.txt"
run bash -c 'printf "y\n" | "$1" adopt "$2"' _ "$REPO_ROOT/bin/dejima-share" "$WS"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$(cat "$dest/a.txt")" = kept ] || { echo "it overwrote the destination"; return 1; }
[ -f "$SHARE/a.txt" ] || { echo "it removed the source it refused to move"; return 1; }
[[ "$output" == *"already has one"* ]] || { echo "$output"; return 1; }
}
@test "adopt says so when there is nothing loose" {
write_config
share path "$WS" > /dev/null
run share adopt "$WS"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"Nothing loose"* ]] || { echo "$output"; return 1; }
}
@test "a writable mount naming the share root is refused" {
# The ~/shared mount is per project only while nothing else hands the whole
# of it over. A session's own share arrives without going through
# [mounts] read_write at all, so refusing costs nothing here.
write_config '[mounts]' "read_write = $SHARE/projects"
mkdir -p "$SHARE/projects"
launch
[ "$status" -ne 0 ] || { echo "mounted every project's handoff files"; return 1; }
[[ "$output" == *"Refusing"* ]] || { echo "$output"; return 1; }
[[ "$output" == *"handoff"* ]] || { echo "the refusal does not say why: $output"; return 1; }
}
@test "a read-only mount naming the share root is refused too" {
write_config
mkdir -p "$SHARE/projects"
launch_ro() {
STUB_DIR="$(mktemp -d)"
printf '#!/bin/sh\nexit 0\n' > "$STUB_DIR/docker"
chmod +x "$STUB_DIR/docker"
HOME="$HOST/home" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
run "$REPO_ROOT/bin/dejima" --extra-readonly "$SHARE/projects" "$WS"
}
launch_ro
[ "$status" -ne 0 ] || { echo "read-only was enough to hand it over"; return 1; }
[[ "$output" == *"Refusing"* ]] || { echo "$output"; return 1; }
}
@test "the briefing tells the session the share directory is this project's" {
# The agent is told to leave things in ~/shared and to pick things up
# there. The path inside the container did not change, so what has to
# change is what it is told about who else can read it.
local out flat
out="$(bash -c 'source "$1"; briefing_compose none local-only claude-code' _ "$LIB")" \
|| { echo "$out"; return 1; }
[[ "$out" == *"~/shared"* ]] || { echo "the briefing lost ~/shared"; return 1; }
# Flattened, because the sentence wraps: a phrase spanning a line break
# matches nothing and the test fails on a reflow that changed no words.
flat="$(tr '\n' ' ' <<< "$out" | tr -s ' ')"
[[ "$flat" == *"this project's own"* ]] \
|| { echo "the briefing still reads as one directory for the host"; return 1; }
[[ "$flat" == *"every session against this project shares it"* ]] \
|| { echo "the briefing does not say who else can read it"; return 1; }
}
+1222 -102
View File
File diff suppressed because it is too large Load Diff