Compare commits

...
Author SHA1 Message Date
branderson 7274236a7c Declare which guards a harness can host, and say when it cannot
tests / runner-env (confined) (pull_request_target) Successful in 38s
tests / confined (proxy) (pull_request_target) Successful in 1m10s
tests / confined (static) (pull_request_target) Successful in 3m8s
tests / confined (providers) (pull_request_target) Successful in 3m42s
tests / privileged (pull_request_target) Successful in 4m18s
tests / confined (remote) (pull_request_target) Successful in 4m41s
tests / runner-env (privileged) (pull_request_target) Successful in 35s
tests / confined (project) (pull_request_target) Successful in 6m11s
tests / confined (audit) (pull_request_target) Successful in 7m38s
tests / confined (config) (pull_request_target) Successful in 8m24s
The capability bar allows a harness to be supported at a smaller guard
set. Nothing made that visible: dejima applied four guard rails and
assumed every harness could host them, so a harness that could not
would have shipped three protections short with no one told.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Also corrects dejima-slug-lib.sh's header, which still named
bin/dejima-audit as the second caller. The cross-project finding that
used workspace_slugs was removed in #115; bin/dejima-epitaph is what
shares the definition now.
2026-08-12 19:10:30 -07:00
branderson d7d808425c Merge remote-tracking branch 'origin/main' into development
# Conflicts:
#	docs/development.md
2026-08-12 18:57:56 -07:00
branderson 5b8f6e71be Merge pull request 'Add epitaph: keep what a session leaves when its context dies' (#123) from compaction-summaries into development
Reviewed-on: #123
Reviewed-by: branderson <brad@branderson.io>
2026-08-12 17:19:45 -07:00
branderson bd0f5ddab6 Merge pull request 'Make the exit-time audit clearable in one sitting' (#115) from audit-usability into main
Reviewed-on: #115
Reviewed-by: branderson <brad@branderson.io>
2026-08-12 01:41:21 -07:00
branderson 54962bb165 Keep the audit edge case out of the README
tests / runner-env (confined) (pull_request_target) Successful in 33s
tests / confined (proxy) (pull_request_target) Successful in 1m7s
tests / confined (static) (pull_request_target) Successful in 2m44s
tests / confined (providers) (pull_request_target) Successful in 3m33s
tests / confined (remote) (pull_request_target) Successful in 3m55s
tests / confined (config) (pull_request_target) Successful in 6m27s
tests / confined (audit) (pull_request_target) Successful in 7m20s
tests / privileged (pull_request_target) Successful in 4m24s
tests / runner-env (privileged) (pull_request_target) Successful in 34s
tests / confined (project) (pull_request_target) Successful in 5m9s
The deleted-surface behaviour is audit mechanics, not something a reader
needs on first contact with the project. It moves to docs/security.md beside
the rest of the approve/ignore/skip rules, which is where someone asking that
question already is.

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

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

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

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

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

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

Two bugs found while doing this, both mine, both fixed with the tests that
caught them: the `reviewed` marker recorded a filename before the report shed
its -CRITICAL suffix, so it never matched afterwards; and a cleared finding
was neither baseline-suppressed nor un-ackable, so it re-presented on every
later run - the same loop the clear was meant to end.
2026-08-12 00:06:16 -07:00
23 changed files with 1513 additions and 296 deletions
+2 -2
View File
@@ -339,7 +339,7 @@ dejima ~/repos/some-project
By default the container's entry point is bare `claude` (always running with
`--dangerously-skip-permissions`, regardless of how it's invoked; see
`docker/claude-wrapper.sh`), so a fresh clone of this repo works with no
`docker/harness-wrapper.sh`), so a fresh clone of this repo works with no
editor setup at all.
The loop, in the part every workflow shares: point it at a project, ask for a
@@ -598,7 +598,7 @@ finding is something to read and clean up before your next `git` or `claude`
in that repo. Its diff is also time-windowed: it reports that a path changed
while the session was running, not that the session changed it.
`dejima-audit ack` walks the findings and shows you each one before asking.
`dejima-audit show` indexes what is waiting; `dejima-audit ack` walks it.
Approving records the exact content, so any later change re-fires; ignoring
records the surface, so that path is fine whatever it later contains; and
skipping decides nothing, which means you are asked again next session.
+109 -52
View File
@@ -25,6 +25,8 @@ source "$SCRIPT_DIR/dejima-provider-lib.sh"
source "$SCRIPT_DIR/dejima-color-lib.sh"
# shellcheck source=dejima-slug-lib.sh
source "$SCRIPT_DIR/dejima-slug-lib.sh"
# shellcheck source=dejima-harness-lib.sh
source "$SCRIPT_DIR/dejima-harness-lib.sh"
# fd 2: everything this script says for itself goes to stderr, so the
# session's own stdout stays exactly what the container wrote.
color_init 2
@@ -310,6 +312,13 @@ if [ -z "$DEJIMA_HARNESS_NAME" ]; then
fi
provider_validate_selection "$DEJIMA_FORGE_NAME" "$DEJIMA_WORKFLOW_NAME" "$DEJIMA_HARNESS_NAME" || exit 1
# The selected harness's plugin: what dejima does for this harness that no
# manifest value could state. Loaded once the selection is validated, and
# before anything that calls a hook. A manifest without a plugin is refused
# rather than treated as a lighter kind of support - see "Adding a new
# harness" in docs/development.md.
harness_plugin_load "$DEJIMA_HARNESS_NAME"
# Whether GitHub is a push destination this session, which the briefing tells
# the agent about. Two ways in: the per-launch flag, or a forge whose hosts
# core allowlists (github) under a workflow that pushes. Derived from the
@@ -747,11 +756,32 @@ SANDBOX_AUDIT_STATE="$(mktemp "${TMPDIR:-/tmp}/dejima-audit.XXXXXX")"
# and SANDBOX_DERIVED_CONTEXT is one of the `rm -rf` arguments.
DEJIMA_NOTIFY_DIR=""
DEJIMA_SESSION_FILE=""
SANDBOX_REMOTE_SETTINGS_TMP=""
SANDBOX_NVIM_LOCKFILE=""
SANDBOX_DERIVED_DOCKERFILE=""
SANDBOX_DERIVED_CONTEXT=""
# Names each host-side task in cleanup() as it starts. Between the container
# exiting and the operator's shell prompt returning, dejima audits every
# surface the session could have written to and reads back the transcripts it
# left; on a real project that is seconds of nothing on screen, and a
# terminal that prints nothing reads as one that has hung. The remedy an
# operator reaches for then is Ctrl-C, which is precisely when the audit
# must not be interrupted.
#
# Only once a snapshot exists, which is the same condition `dejima-audit
# compare` uses to decide it has anything to do: an exit before the session
# ever started is instant, and its last line is the error that caused it -
# announcing work that is not happening would push that off the screen.
#
# stderr, like everything else this trap prints, and host-side only: none of
# it is ever routed back into a session's context.
exit_step() {
# `return 0` rather than letting the test be the last command: a nonzero
# status from anything in an EXIT trap replaces the script's own.
[ -s "${SANDBOX_AUDIT_STATE:-}" ] && printf 'dejima: %s...\n' "$1" >&2
return 0
}
cleanup() {
# Every rm below is guarded with `|| true`: under set -e, a failing
# command in an EXIT trap replaces the shell's own exit status, so none
@@ -783,7 +813,7 @@ cleanup() {
fi
rm -f "$SANDBOX_SETTINGS_FILE" "$SANDBOX_CLAUDE_JSON_FILE" "${SANDBOX_NVIM_LOCKFILE:-}" "${SANDBOX_FAKE_CREDENTIALS_FILE:-}" "${SANDBOX_DERIVED_DOCKERFILE:-}" "${SANDBOX_BRIEFING_FILE:-}" || true
rm -rf "${SANDBOX_GITCONFIG_DIR:-}" "${SANDBOX_ANTHROPIC_PROXY_DIR:-}" "${SANDBOX_DERIVED_CONTEXT:-}" || true
rm -f "${DEJIMA_SESSION_FILE:-}" "${SANDBOX_REMOTE_SETTINGS_TMP:-}" || true
rm -f "${DEJIMA_SESSION_FILE:-}" || true
# Unlike the silent `|| true` guards above, this removal (and, before
# the audit below was moved in, the last command of an EXIT trap) warns
# out loud when it fails: the marker directory is bind-mounted
@@ -795,6 +825,15 @@ cleanup() {
# dejima-notify-watch's marker consumption.
rm -rf "${DEJIMA_NOTIFY_DIR:-}" \
|| echo "warning: could not remove the marker directory ${DEJIMA_NOTIFY_DIR:-}" >&2
exit_step "session ended - keeping what it compacted away"
# Keep whatever the session compacted away, into a store nothing mounts.
# `|| true` for the same reason every other line in this trap has it: a
# failure here must not replace the container's exit status.
#
# Ahead of the audit rather than after it, so the audit keeps the last
# word (see below): this prints a line of its own whenever it keeps
# something, and that line was landing under the verdict.
"$SCRIPT_DIR/dejima-epitaph" save "$WORKSPACE_DIR" || true
# Last writer, structurally: the operator reads bottom-up from their
# shell prompt, so anything printed after the verdict pushes it out of
# view. The marker-directory warning above is the one line a session can
@@ -804,11 +843,8 @@ cleanup() {
# STATE is dropped from the rm -f above and freed only after compare has
# read it, so this function's own cleanup can't race the audit that
# needs it - and its removal is guarded too, so it stays truly last.
exit_step "checking what changed on the host while it ran"
DEJIMA_RW_MOUNTS="$DEJIMA_RW_MOUNTS" "$SCRIPT_DIR/dejima-audit" compare "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
# Keep whatever the session compacted away, into a store nothing mounts.
# `|| true` for the same reason every other line in this trap has it: a
# failure here must not replace the container's exit status.
"$SCRIPT_DIR/dejima-epitaph" save "$WORKSPACE_DIR" || true
rm -f "$SANDBOX_AUDIT_STATE" || true
}
trap cleanup EXIT
@@ -884,6 +920,42 @@ case "$SANDBOX_CMD" in
;;
esac
export SANDBOX_CMD
# What the wrapper inside the container needs to shadow the harness's binary
# and pin its flags, resolved here because the manifests are host-side data
# the container never sees. Every value is the selected harness's own; none
# of it is written down in the image.
#
# SANDBOX_HARNESS_COMMAND is the name to shadow, and is the manifest's rather
# than [core] command: overriding the latter to `nvim` changes what dejima
# launches, not which harness is wrapped, and an editor-driven session that
# then runs the harness from a terminal must still get the flags.
SANDBOX_HARNESS_COMMAND="$(manifest_get harness "$DEJIMA_HARNESS_NAME" command)"
SANDBOX_HARNESS_REAL="$(manifest_get harness "$DEJIMA_HARNESS_NAME" command_path)"
SANDBOX_HARNESS_ARGS="$(manifest_get harness "$DEJIMA_HARNESS_NAME" no_confirm_args)"
# Named in full rather than templated: the settings layer's container-side
# path is dejima's own fixed contract (docker-compose.yml mounts it there),
# not a value that varies per run, and {{...}} in a manifest belongs to the
# briefing composer's substitution rather than to a second mechanism that
# merely looks like it.
SANDBOX_HARNESS_SETTINGS="$(manifest_get harness "$DEJIMA_HARNESS_NAME" settings_args)"
# Unattended operation is R1 of the capability bar, and a harness whose
# manifest names no way to ask for it would run asking for confirmation on
# every tool call, against a container built on the premise that it does not.
# That fails silently and looks like the harness hanging, so refuse instead.
if [ -z "$SANDBOX_HARNESS_ARGS" ]; then
echo "Harness '$DEJIMA_HARNESS_NAME' declares no no_confirm_args." >&2
echo "dejima runs unattended; a harness that stops to ask cannot be run that" >&2
echo "way. See \"What a harness must be able to do\" in docs/development.md." >&2
exit 1
fi
export SANDBOX_HARNESS_COMMAND SANDBOX_HARNESS_REAL SANDBOX_HARNESS_ARGS \
SANDBOX_HARNESS_SETTINGS
# [core] command defaults to the selected harness's own command rather than
# to a literal in core's schema, which is where `claude` used to sit.
[ -n "$SANDBOX_CMD" ] || SANDBOX_CMD="$SANDBOX_HARNESS_COMMAND"
export SANDBOX_CMD
# docker-compose.yml/entrypoint.sh compare these against "1"/"0", not
# "on"/"off" - config_get_bool is what produces that, config_get would pass
# "on"/"off" straight through and (among other things) silently disable the
@@ -1054,34 +1126,14 @@ if [ -d "$WORKSPACE_DIR/.git/hooks" ]; then
extra_mounts+=(-v "$WORKSPACE_DIR/.git/hooks:$WORKSPACE_DIR/.git/hooks:ro")
fi
# ~/.claude/projects, one slug at a time: the tree is every project's
# transcripts and memory, and memory is loaded into context at session start,
# so a write into another slug is background a future session there believes
# (#104). projects/ itself stays container-local, so an unmounted slug is
# still writable and just does not survive - which is how Claude Code creates
# a slug it does not find. Existing slugs only, apart from the workspace's
# own, so a checkout full of worktrees does not litter the host with empty
# directories for worktrees nothing has run in.
mkdir -p "$HOME/.claude/projects/$(ws_slug "$WORKSPACE_DIR")"
while IFS= read -r _slug; do
[ -n "$_slug" ] || continue
[ -d "$HOME/.claude/projects/$_slug" ] || continue
extra_mounts+=(-v "$HOME/.claude/projects/$_slug:$HOME/.claude/projects/$_slug")
done < <(workspace_slugs_sorted "$WORKSPACE_DIR")
# ~/.claude/file-history, same shape, sharper reason: `<session>/<hash>@v1`
# is a verbatim copy of an edited file, so the tree is other projects' source
# rather than a record of it, and it was writable - tampering there puts
# chosen content into a later restore. Keyed by session id, so there is no
# slug to match on; workspace_session_ids resolves it through the transcript
# names. The starting session's id cannot be known yet, so its directory is
# container-local: undo history for it is lost if it is resumed on the host.
mkdir -p "$HOME/.claude/file-history"
while IFS= read -r _sid; do
[ -n "$_sid" ] || continue
[ -d "$HOME/.claude/file-history/$_sid" ] || continue
extra_mounts+=(-v "$HOME/.claude/file-history/$_sid:$HOME/.claude/file-history/$_sid")
done < <(workspace_session_ids "$HOME/.claude/projects" "$WORKSPACE_DIR")
# The state this harness keeps on the host, and which of it this workspace
# is entitled to see. Harness-specific by nature - which directories exist,
# and what decides that one of their entries belongs to this workspace, is a
# fact about the harness rather than about dejima - so it lives in the
# harness plugin, which declares each mount through emit_mount. The guarded-
# store refusal runs there, at the point of declaration, rather than being
# something each plugin has to remember. See bin/dejima-harness-lib.sh.
harness_hook harness_state_mounts "$WORKSPACE_DIR"
# statusline.sh is executed on the host by Claude Code (settings.json's
# statusLine.command), so it is staged for entrypoint.sh to copy rather than
@@ -1323,24 +1375,23 @@ if remote_enabled; then
# own ID.
extra_mounts+=(-e "DEJIMA_RUN_ID=$DEJIMA_RUN_ID")
# The notify hook is registered per session, into the override layer from
# above and nowhere else: registering it in the operator's real
# settings.json would run it for every sandbox and for the host's own
# Claude Code, which share that file, when it is only meaningful for a
# session that has a marker directory. $SANDBOX_SETTINGS_FILE is
# already a throwaway mktemp file, so this rewrite cannot reach the
# operator's real settings.json; it is mounted read-only, so the
# session cannot unregister itself either. Claude Code merges hooks
# across settings layers rather than letting the higher one replace the
# lower, so these are added to whatever the operator already has rather
# than displacing it. $HOME is left unexpanded for Claude Code to resolve
# at hook-run time - inside the container, not here - matching the
# convention install.sh already uses.
SANDBOX_REMOTE_SETTINGS_TMP="$(mktemp "${TMPDIR:-/tmp}/dejima-settings-remote.XXXXXX.json")"
jq '.hooks.Notification = ((.hooks.Notification // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/dejima-notify.sh waiting"}]}])
| .hooks.Stop = ((.hooks.Stop // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/dejima-notify.sh done"}]}])' \
"$SANDBOX_SETTINGS_FILE" > "$SANDBOX_REMOTE_SETTINGS_TMP"
mv "$SANDBOX_REMOTE_SETTINGS_TMP" "$SANDBOX_SETTINGS_FILE"
# Registered per session, into the override layer from above and nowhere
# else: putting it in the operator's real settings.json would run it for
# every sandbox and for the host's own agent, which share that file, when
# it is only meaningful for a session that has a marker directory.
# $SANDBOX_SETTINGS_FILE is already a throwaway mktemp file, so this
# rewrite cannot reach the operator's real settings; it is mounted
# read-only, so the session cannot unregister itself either.
#
# How a harness is told to run something on those two events is the
# harness's own business - Claude Code merges hook entries across
# settings layers, another harness may take a config key or an
# environment variable - so the writing is the plugin's. Whether it
# happens at all is core's, and is gated on the harness having declared
# the notify guard.
if harness_has_guard notify; then
harness_hook harness_register_notify "$SANDBOX_SETTINGS_FILE"
fi
fi
# Everything from here to the container's own first output is silent: the
@@ -1354,6 +1405,12 @@ printf '%sdejima%s is starting, please wait...\n' "$C_KEY" "$C_RESET" >&2
printf '%sA first run builds the container image, which takes several minutes.%s\n' \
"$C_DIM" "$C_RESET" >&2
# Named here, once, at the moment the operator is choosing to proceed. Silent
# when the harness hosts every guard, so this is not a line anyone learns to
# scroll past - it appears only when a protection they have elsewhere is
# genuinely not in force here.
harness_report_guards "$DEJIMA_HARNESS_NAME"
# Snapshot host-persistence surfaces so the post-run audit can report
# anything that changed while this session ran (advisory - see bin/dejima-audit).
"$SCRIPT_DIR/dejima-audit" snapshot "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
+205 -60
View File
@@ -21,7 +21,7 @@ config_validate || exit 1
cmd="${1:-}"; ws_arg="${2:-}"; state="${3:-}"
PROJECTS_DIR="$(config_get harness.claude-code projects_dir)"
PROJECTS_DIR="$(harness_config_get projects_dir)"
# The one top-level ~/.claude entry docker-compose.yml's read-only overlays
# don't cover (settings.json/CLAUDE.md/plugins/hooks/skills/commands/agents/
@@ -31,7 +31,22 @@ PROJECTS_DIR="$(config_get harness.claude-code projects_dir)"
# resolves the mount destination through the symlink; confirmed against a
# live container, see test/runtime.bats). Detection here is the compensating
# control for that gap. Overridable for tests.
STATUSLINE_PATH="$(config_get harness.claude-code statusline_path)"
STATUSLINE_PATH="$(harness_config_get statusline_path)"
# With no harness selected there is no statusline path to watch, and the
# compensating control above quietly covers nothing. bin/dejima refuses to
# start without [core] harness, so a snapshot in this state means the audit
# was driven by hand against a config dejima itself would reject - say so
# rather than reporting a clean run over a surface that was never examined.
# A silently narrower audit is worse than a noisy one.
if [ -z "$STATUSLINE_PATH" ] && [ -z "$(config_get core harness)" ]; then
case "$cmd" in
snapshot|compare)
echo "dejima audit: no [core] harness is set, so harness-specific surfaces" >&2
echo " (the statusline that runs on the host) are NOT checked." >&2
;;
esac
fi
# This script's own repo root - the sandbox's trusted computing base (see
# tcb_snapshot below) is only monitored when the workspace being audited IS
@@ -73,6 +88,24 @@ AUDIT_KEEP="$(config_get audit keep)"
# startup warning may well reach for `show` first, and showing them a
# different report than the one being warned about sends them looking for a
# finding that is not in it.
# How many reports are waiting, and how many of those are CRITICAL. `ack`
# walks one report at a time by design - each is a separate session's worth of
# findings - but saying nothing about the rest is what made clearing a backlog
# feel like the tool was stuck on one.
# A report's stable identity: its basename without the -CRITICAL marker, which
# `ack` removes once every CRITICAL in it has been dealt with.
report_key() {
local b; b="$(basename "$1")"
printf '%s' "${b%-CRITICAL.txt}.txt" | sed 's/\.txt\.txt$/.txt/'
}
count_reports() {
local dir="$1"
printf '%s %s\n' \
"$(find "$dir" -maxdepth 1 -name '*.txt' 2>/dev/null | wc -l)" \
"$(find "$dir" -maxdepth 1 -name '*-CRITICAL.txt' 2>/dev/null | wc -l)"
}
select_report() {
local dir="$1" r
r="$(find "$dir" -maxdepth 1 -name '*-CRITICAL.txt' 2>/dev/null | sort | head -1)"
@@ -569,6 +602,11 @@ baseline_suppresses() {
}
# Whether `ack` has already walked this report to its end. Recorded in the
# Recorded against the report's name with any -CRITICAL suffix stripped,
# because a report that sheds that suffix later in the same run would
# otherwise carry a marker naming a file that no longer exists - and re-prompt
# forever, which is the failure the marker exists to prevent.
#
# baseline file, alongside the approvals, as `reviewed <report-basename>`:
# the file is already the per-workspace record of what the operator has
# decided, already parsed line-by-line, and already tolerant of verbs it does
@@ -777,6 +815,22 @@ hash_paths() {
} | sort
}
# Whether `git status` would show a change to $2 inside repo $1. One call per
# candidate path rather than one listing compared as a set: the candidate list
# is what changed this session, which is small, and --porcelain on a path is
# cheap. A path git does not know about at all (never added, and ignored)
# prints nothing, which is exactly the invisible case.
#
# A repo git refuses to read (not a repository, or a dubious-ownership
# refusal) makes this return false, so every change is reported rather than
# silently downgraded - failing towards the noisier answer, not the quieter
# one.
git_shows_change() {
local repo="$1" path="$2" out
out="$(git -C "$repo" status --porcelain --untracked-files=all -- "$path" 2>/dev/null)" || return 1
[ -n "$out" ]
}
tcb_snapshot() {
# encoded-path<TAB>sha for the sandbox's own trusted computing base, but
# only when $ws IS the sandbox's repo (self-mount) - see AUDIT_REPO_ROOT.
@@ -1011,20 +1065,6 @@ render_finding() {
return 0
}
projects_snapshot() {
# slug<TAB>maxmtime for each immediate subdir of PROJECTS_DIR, sorted by
# slug; maxmtime is 0 if the slug dir has no files.
local dir="$1" d slug mtime
[ -d "$dir" ] || return 0
for d in "$dir"/*/; do
[ -d "$d" ] || continue
slug="$(basename "$d")"
mtime="$(find "$d" -type f -printf '%T@\n' 2>/dev/null | sort -n | tail -1)"
mtime="${mtime%%.*}"
printf '%s\t%s\n' "$slug" "${mtime:-0}"
done | sort
}
statusline_snapshot() {
# path<TAB>sha for $HOME/.claude/statusline.sh, or nothing if this host
# has none - see STATUSLINE_PATH above for why this one path is
@@ -1050,7 +1090,11 @@ case "$cmd" in
{ echo "### staging $STAGE_DIR";
echo "### gitconfig"; git_config_snapshot "$ws";
echo "### paths"; hash_paths "$ws";
echo "### projects"; projects_snapshot "$PROJECTS_DIR";
# Empty since the cross-project finding was retired, and kept
# rather than dropped: it is a range boundary for the paths section
# above, and removing it would make a state file written by one
# version unparseable by the other across an upgrade mid-session.
echo "### projects";
echo "### tcb"; tcb_snapshot "$ws";
echo "### statusline"; statusline_snapshot "$STATUSLINE_PATH"; } > "$state" 2>/dev/null || true
exit 0
@@ -1089,7 +1133,7 @@ case "$cmd" in
new="$(mktemp)"; { echo "### gitconfig"; git_config_snapshot "$ws";
echo "### paths"; hash_paths "$ws";
echo "### projects"; projects_snapshot "$PROJECTS_DIR";
echo "### projects";
echo "### tcb"; tcb_snapshot "$ws";
echo "### statusline"; statusline_snapshot "$STATUSLINE_PATH"; } > "$new" 2>/dev/null || {
echo "dejima audit: could not complete, skipping." >&2; rm -f "$new"; exit 0; }
@@ -1180,38 +1224,55 @@ case "$cmd" in
<(sed -n '/^### paths/,/^### projects/p' "$new" | grep -v '^### ') \
| sed 's/^\t//' | cut -f1 | sort -u )
# projects diffs: a write to another project's ~/.claude/projects
# slug during this session (transcript/memory-poisoning surface).
# "self" = the workspace's own slug or a live worktree nested under
# it, exactly the set bin/dejima mounts. This used to be a
# "$current_slug"-* glob, which also matched any sibling project whose
# directory name extended this one's - so on a host with `proj` and
# `proj-ci` side by side, writes to `proj-ci` were silently exempt.
self_slugs="$(workspace_slugs_sorted "$ws")"
while IFS= read -r line; do
slug="${line%%$'\t'*}"
if printf '%s\n' "$self_slugs" | grep -qxF -- "$slug"; then
continue
fi
warn+=("CROSS-PROJECT history/memory write: $(sanitize "$slug")")
done < <(comm -13 <(sed -n '/^### projects/,/^### tcb/p' "$state" | grep -v '^### ') \
<(sed -n '/^### projects/,/^### tcb/p' "$new" | grep -v '^### ') )
# There is deliberately no cross-project finding here any more. It
# reported a write to another project's ~/.claude/projects slug, and
# a session could once make one: the whole tree was mounted. Since
# #104 it gets its own slugs and nothing else, so the only writes this
# could still see are other host or dejima sessions doing their own
# legitimate work - and with several sessions running it reported all
# of them, on every report, drowning the findings that mattered.
#
# Detection that cannot observe the thing it was built for, and fires
# constantly on the thing it was not, is worse than no detection: it
# trains the operator to skim. The mount is the control now, and
# static.bats pins it.
# tcb diffs: any change under the sandbox's own trusted computing
# base, only populated when this session's workspace IS the
# sandbox's own repo (see tcb_snapshot) - empty/no-op otherwise.
# Bounded at ### statusline (not $p, the last section now) so this
# doesn't also swallow that section's lines.
# Split on whether git can see the change, because that decides
# whether this finding is telling the operator anything.
#
# tcb_snapshot only populates when the workspace IS dejima's own repo,
# so every change here is also a change to a git working tree the
# operator is about to read in `git status` and in the pull request.
# Reporting each one as a CRITICAL to acknowledge duplicated a
# stronger control and made ordinary development unusable.
#
# What git cannot show is the case worth keeping: a file listed in
# .gitignore or .git/info/exclude, or marked assume-unchanged or
# skip-worktree, is live code that runs on the next launch and appears
# nowhere in `git status`. Nothing but this hash sees it, so it stays
# a CRITICAL finding.
tcb_visible=0
while IFS= read -r line; do
# Encoded on arrival, same as the path loop above.
rline="$(dec_id "$line")"
tid="tcb:$rline"; tsha=""; [ -e "$ws/$rline" ] && tsha="$(hash_entry "$ws/$rline")"
baseline_suppresses "$tid" "$tsha" && continue
crit+=("TCB (sandbox self) modified: $line")
if git_shows_change "$ws" "$rline"; then
tcb_visible=$((tcb_visible + 1))
continue
fi
crit+=("TCB (sandbox self) modified, INVISIBLE to git: $line")
finding_ids+=("$tid")
done < <(comm -3 <(sed -n '/^### tcb/,/^### statusline/p' "$state" | grep -v '^### ') \
<(sed -n '/^### tcb/,/^### statusline/p' "$new" | grep -v '^### ') \
| sed 's/^\t//' | cut -f1 | sort -u )
if [ "$tcb_visible" -gt 0 ]; then
info+=("$tcb_visible change(s) under the sandbox's own bin/, hooks/, docker/, test/ - this workspace IS dejima; review them with git")
fi
# statusline diff: ~/.claude/statusline.sh added/changed/removed/
# repointed - see STATUSLINE_PATH above for why this gets its own
@@ -1362,15 +1423,41 @@ case "$cmd" in
exit 0
;;
show)
color_init 1
report_dir="$AUDIT_DIR/$(ws_slug "$ws")"
report="$(select_report "$report_dir")" \
|| { echo "dejima audit: no reports for $ws" >&2; exit 0; }
# Which report, on stderr so stdout stays the report content alone:
# `show` no longer necessarily prints the newest one, and an operator
# cross-referencing the run timestamps in the startup warning needs to
# know which of them they are looking at.
echo "dejima audit: $report" >&2
cat "$report"
# Default is an index, not a dump. `cat` of one report was a wall of
# text that answered neither question an operator actually has - how
# much is waiting, and which of it still needs a decision - and it
# printed findings already acknowledged, so the wall did not even
# shrink as they worked through it.
if [ "${2:-}" = --full ] || [ "${3:-}" = --full ] || [ "${4:-}" = --full ]; then
echo "dejima audit: $report" >&2
cat "$report"
exit 0
fi
read -r n_all n_crit <<< "$(count_reports "$report_dir")"
printf '%s report(s) for %s' "$n_all" "$ws"
[ "$n_crit" -gt 0 ] && printf ', %s%s with unacknowledged CRITICAL%s' \
"$C_CRIT" "$n_crit" "$C_RESET"
printf '\n\n'
# Newest last, so the one `ack` will offer first is the one nearest
# the prompt - and mark it, since select_report prefers CRITICAL over
# recency and "the newest" is the wrong guess.
r=""; line=""; pending=""
while IFS= read -r r; do
pending=$(grep -cE '^(CRITICAL|WARNING|INFO)' "$r" 2>/dev/null || true)
line="$(printf ' %-44s %s finding(s)' "$(basename "$r")" "${pending:-0}")"
case "$r" in
*-CRITICAL.txt) printf '%s%s%s' "$C_CRIT" "$line" "$C_RESET" ;;
*) printf '%s' "$line" ;;
esac
[ "$r" = "$report" ] && printf ' %s<- ack starts here%s' "$C_KEY" "$C_RESET"
printf '\n'
done < <(find "$report_dir" -maxdepth 1 -name '*.txt' 2>/dev/null | sort)
printf '\n%sdejima-audit ack%s walks them; %sdejima-audit show --full%s prints one.\n' \
"$C_KEY" "$C_RESET" "$C_KEY" "$C_RESET"
exit 0
;;
ack)
@@ -1379,6 +1466,17 @@ case "$cmd" in
bf="$BASELINE_DIR/$(ws_slug "$ws")"
report="$(select_report "$report_dir")" \
|| { echo "dejima audit: no reports for $ws" >&2; exit 0; }
read -r n_all n_crit <<< "$(count_reports "$report_dir")"
if [ "$n_all" -gt 1 ]; then
printf '%s reports waiting' "$n_all"
[ "$n_crit" -gt 0 ] && printf ', %s%s with unacknowledged CRITICAL%s' \
"$C_CRIT" "$n_crit" "$C_RESET"
printf '.\n\n'
fi
# One report per pass, every report in one invocation. Walking one and
# exiting meant clearing a backlog took as many runs as there were
# reports, with nothing on screen to say another was behind it.
while :; do
sc="$(sidecar_dir "$report")"
# Removed only at the end, and only for findings actually
# acknowledged - an abort must leave the operator exactly where they
@@ -1397,6 +1495,10 @@ case "$cmd" in
# (or a non-path CRITICAL, which can never be "approved" - see
# ackable below) must keep its suffix and stay eviction-protected.
crit_seen=0; crit_acked=0
# Set when a finding was cleared because its surface no longer exists.
# Like unackable_shown, it means the report has been dealt with as far
# as it can be, so it earns a `reviewed` marker.
removed_cleared=0
# Findings passed over because the baseline already covers them, or
# because this report has been walked before - counted so a run that
# displays nothing can say why instead of looking broken.
@@ -1406,7 +1508,7 @@ case "$cmd" in
# end. Together they decide if a `reviewed` line gets written below.
unackable_shown=0
report_reviewed=0
if baseline_reviewed "$(basename "$report")"; then report_reviewed=1; fi
if baseline_reviewed "$(report_key "$report")"; then report_reviewed=1; fi
# The loop below reads report lines on its own stdin (`< "$report"`),
# so operator keystrokes are read from fd 3 - a copy of the script's
# original stdin taken before that redirection - rather than
@@ -1489,9 +1591,9 @@ case "$cmd" in
esl="${body#HOST-CODE-EXECUTION statusline.sh changed (runs UNSANDBOXED on the host via settings.json statusLine.command): }"
surface="$esl"
id="statusline:$esl"; rid="statusline:$(dec_id "$esl")" ;;
"TCB (sandbox self) modified: "*)
ackable=1; lbl="TCB (sandbox self) modified: "
etcb="${body#TCB (sandbox self) modified: }"
"TCB (sandbox self) modified, INVISIBLE to git: "*)
ackable=1; lbl="TCB (sandbox self) modified, INVISIBLE to git: "
etcb="${body#TCB (sandbox self) modified, INVISIBLE to git: }"
surface="$etcb"
id="tcb:$etcb"; rid="tcb:$(dec_id "$etcb")" ;;
*) ackable=0 ;;
@@ -1520,6 +1622,18 @@ case "$cmd" in
if [ "$tier" = CRITICAL ]; then crit_acked=$((crit_acked + 1)); fi
continue
fi
# A finding cleared on an earlier pass because its surface was
# gone. Nothing was recorded for it - there is no content to pin -
# so baseline_suppresses cannot cover it, and it is ackable, so
# the un-ackable branch below does not either. Without this it
# re-presents on every run: the same forever-loop the clear was
# meant to end, one step further along.
if [ "$ackable" -eq 1 ] && [ "$report_reviewed" -eq 1 ] \
&& [ -z "$(current_sha_for_id "$rid")" ]; then
already=$((already + 1))
if [ "$tier" = CRITICAL ]; then crit_acked=$((crit_acked + 1)); fi
continue
fi
# An un-ackable finding in a report already walked to the end.
# There is nothing to record for one, so without this it would
# re-prompt on every run forever, gating the exit from `ack` on a
@@ -1586,18 +1700,25 @@ case "$cmd" in
else
sha="$(current_sha_for_id "$rid")"
if [ -z "$sha" ]; then
# Nothing left to pin the approval to: current_sha_for_id
# returning empty means $id no longer exists (deleted
# between compare and ack, or - for gitconfig - the key
# was unset). Writing `safe <id> ` anyway would record a
# baseline entry baseline_suppresses() can never match
# (its own empty-sha guard rejects it as malformed), so
# the surface would stay permanently unsuppressible -
# while still advancing crit_acked and letting a CRITICAL
# report shed its -CRITICAL.txt protection. Refuse
# instead: record nothing, and leave the report's
# protection exactly as it was.
echo " cannot approve: $id no longer exists"
# The surface is gone - deleted since compare, or a
# gitconfig key unset. There is nothing to pin an
# approval to, and `safe <id> ` with an empty sha would
# record a baseline line baseline_suppresses() can never
# match, leaving the surface permanently unsuppressible.
#
# So nothing is recorded - but the finding is CLEARED,
# because a surface that does not exist does not run.
# Refusing outright is what made one deleted file pin
# the audit forever: crit_acked never reached crit_seen,
# the report kept -CRITICAL.txt, and select_report picks
# CRITICAL first, so every other report stayed hidden
# behind a finding that could not be resolved by any
# means. If the surface comes back, it comes back as a
# new finding, which is the honest outcome.
echo " ${C_OK}cleared${C_RESET}: $(sanitize "$id") no longer exists"
removed_cleared=1
[ "$tier" = CRITICAL ] && crit_acked=$((crit_acked + 1))
sc_done+=("$rid")
else
pending+=("safe $id $sha")
sc_done+=("$rid")
@@ -1618,8 +1739,9 @@ case "$cmd" in
# Written once the report has been walked to its end, and only when
# it actually showed something that cannot be acknowledged - there is
# nothing else to stop those from re-prompting forever.
if [ "$unackable_shown" -eq 1 ] && [ "$report_reviewed" -eq 0 ]; then
pending+=("reviewed $(basename "$report")")
if { [ "$unackable_shown" -eq 1 ] || [ "$removed_cleared" -eq 1 ]; } \
&& [ "$report_reviewed" -eq 0 ]; then
pending+=("reviewed $(report_key "$report")")
fi
if [ "${#pending[@]}" -gt 0 ]; then
mkdir -p "$BASELINE_DIR"
@@ -1657,10 +1779,33 @@ case "$cmd" in
else
echo "dejima audit: nothing recorded."
fi
# Next report, if there is one. Asked rather than assumed: an operator
# who has decided enough for now should not have to abort out of a
# report they have not read to stop.
next_report="$(select_report "$report_dir")" || next_report=""
if [ -n "$next_report" ] && [ "$next_report" != "$report" ]; then
read -r n_all n_crit <<< "$(count_reports "$report_dir")"
printf '\n%s report(s) still waiting' "$n_all"
[ "$n_crit" -gt 0 ] && printf ', %s%s with unacknowledged CRITICAL%s' \
"$C_CRIT" "$n_crit" "$C_RESET"
printf '. Continue? [%sy%s/%sn%s] ' "$C_KEY" "$C_RESET" "$C_KEY" "$C_RESET"
# fd 3, the same duplicate of the original stdin the finding
# prompt reads: the report is this loop's stdin, and /dev/tty is
# not the caller's input when `ack` is driven by a pipe.
{ read -r answer <&3; } 2>/dev/null || answer=n
printf '\n'
case "$answer" in
y|Y|"") report="$next_report"; sc="$(sidecar_dir "$report")"; continue ;;
esac
fi
break
done
exit 0
;;
*)
echo "usage: dejima-audit {snapshot|compare|show|ack} [workspace] [state-file]" >&2
echo " show [--full] index of waiting reports; --full prints one" >&2
echo " workspace defaults to the enclosing git repository, else the current directory" >&2
exit 2
;;
+1 -1
View File
@@ -42,7 +42,7 @@ _config_table() {
core.harness provider:harness
core.forge provider:forge none
core.workflow provider:workflow local-only
core.command identifier claude
core.command ?identifier
core.firewall bool on
core.share_dir path ~/.local/share/dejima/shared
core.ssh_key ?path
+9 -2
View File
@@ -29,7 +29,7 @@ cmd="${1:-}"; ws_arg="${2:-}"; transcripts="${3:-}"
[ "$(config_get epitaph enabled)" = "on" ] || exit 0
STORE="$(config_get epitaph dir)"
[ -n "$transcripts" ] || transcripts="$(config_get harness.claude-code projects_dir)"
[ -n "$transcripts" ] || transcripts="$(harness_config_get projects_dir)"
ws="$ws_arg"
[ -n "$ws" ] || ws="$(pwd)"
@@ -53,7 +53,6 @@ while IFS= read -r slug; do
dir="$transcripts/$slug"
[ -d "$dir" ] || continue
out="$STORE/$slug"
mkdir -p "$out" 2>/dev/null || continue
for f in "$dir"/*.jsonl; do
[ -f "$f" ] || continue
# python3 is already a hard dependency (install.sh refuses without
@@ -116,6 +115,7 @@ for line in open(path, errors="replace"):
"", "---", "",
]
tmp = dest + ".part"
os.makedirs(out, exist_ok=True)
with open(tmp, "w") as fh:
fh.write("\n".join(head) + body.rstrip() + "\n")
os.chmod(tmp, 0o600)
@@ -128,6 +128,13 @@ PY
)" || n=0
saved=$((saved + ${n:-0}))
done
# The directory is made by the first write into it, not by looking at the
# slug: most worktree slugs have transcripts and no compaction summary in
# any of them, so creating one per slug up front filled the store with
# empty directories and made it look like it held something for each. The
# rmdir clears the ones earlier runs already left; it cannot touch a slug
# that kept anything.
rmdir "$out" 2>/dev/null || true
done < <(workspace_slugs_sorted "$ws")
[ "$saved" -gt 0 ] || exit 0
+165
View File
@@ -0,0 +1,165 @@
# Shared library: loading a harness plugin, and the interface it declares
# through. Sourced by bin/dejima. Not on PATH and not executable.
#
# Three tiers, and which one a thing belongs in is the whole design:
#
# core - the mechanism, and every enforcement point.
# manifest - data. harnesses/<name>/harness.conf. Never sourced, never
# executed. Selects among named core mechanisms, supplies paths.
# plugin - bin/harness/<name>.sh. The harness-specific remainder, as
# code, in this repository, supported one harness at a time.
#
# A feature belongs in the manifest if it can be stated as a value. It moves
# here only when it cannot: opencode resolving its state directory through a
# database, Claude Code pairing file-history to transcripts by session id.
# "Add a manifest key" is the answer until it demonstrably is not.
#
# Plugins live under bin/ rather than beside their manifest because
# dejima-audit's tcb_snapshot covers install.sh, bin/, hooks/, docker/ and
# test/ - and not harnesses/. Host-side plugin code under harnesses/ would be
# code that runs on the host and that a session could edit in dejima's own
# checkout without raising a CRITICAL, which is the exact class the audit
# exists to catch. Under bin/ it is covered for free, and "manifests are
# data, never executed" (docs/security.md) stays true word for word.
#
# Plugins are SOURCED, not invoked. They are part of the trusted computing
# base, so the risk worth designing against is not a careless plugin - it is
# three plugins implementing the security model three different ways.
# Sourcing lets a plugin use core's own primitives (_refuse_guarded_mount,
# sanitize, ws_slug, harness_config_get) rather than re-deriving them, which
# is what keeps one security model instead of one per harness. An invoked
# protocol would also put a new text parser on the launch path, and every
# parsing bug this repository has had is in that family.
#
# The rule that replaces isolation: A PLUGIN COMPUTES, CORE ENFORCES.
# A plugin declares through the emit_* functions below, which validate at the
# point of declaration. It never touches extra_mounts itself - a static test
# holds that, because a plugin that appended directly would skip the guarded-
# store refusal, and would skip it differently in each harness.
# The hooks core will call. A plugin defines the ones its harness needs and
# omits the rest; anything else it defines is a name core does not call, and
# the static test refuses it rather than letting it look wired up.
DEJIMA_HARNESS_HOOKS="harness_state_mounts harness_register_notify"
# What an operator loses when a harness cannot host a given guard. The set
# itself is DEJIMA_KNOWN_GUARDS in dejima-provider-lib.sh, next to the
# manifest schema and the validation that rejects a name outside it: a
# constant the validation depends on must live where the validation does, or
# a caller that loads only provider-lib validates against an empty set and
# rejects every guard there is.
_guard_description() {
case "$1" in
bash-deny) printf 'obviously destructive shell commands are not refused' ;;
push-guard) printf 'pushes to hosts and branches outside the allowed set are not refused' ;;
merge-guard) printf 'the agent can merge its own pull requests' ;;
notify) printf 'remote access gets no waiting/done notifications' ;;
*) printf 'unknown guard' ;;
esac
}
# Whether the selected harness declared a given guard. Core gates the work
# on this rather than on whether the plugin happens to define a hook: a
# harness that forgot the declaration but implemented the hook would get a
# guard the launch message has just told the operator it does not have.
harness_has_guard() {
case " $(manifest_get harness "$DEJIMA_HARNESS_NAME" guards) " in
*" $1 "*) return 0 ;;
*) return 1 ;;
esac
}
# Says which guards the selected harness cannot host. Silent when it hosts
# them all, which is the common case and must not become noise an operator
# learns to scroll past - the point is that the line only appears when
# something really is missing.
harness_report_guards() {
local harness="$1" declared guard missing=()
declared="$(manifest_get harness "$harness" guards)"
for guard in $DEJIMA_KNOWN_GUARDS; do
case " $declared " in
*" $guard "*) ;;
*) missing+=("$guard") ;;
esac
done
[ "${#missing[@]}" -gt 0 ] || return 0
printf ' %sharness %s does not support %s of dejima'"'"'s guards:%s\n' \
"$C_WARN" "$harness" "${#missing[@]}" "$C_RESET" >&2
for guard in "${missing[@]}"; do
printf ' %s%s%s - %s\n' "$C_KEY" "$guard" "$C_RESET" "$(_guard_description "$guard")" >&2
done
printf ' The container still bounds this session; these are the fast-fail\n' >&2
printf ' guards it does not get. See docs/development.md.\n' >&2
}
# Sources the selected harness's plugin. Hard error when it is missing:
# support is per harness and deliberate (see "Adding a new harness" in
# docs/development.md), so a harness with a manifest and no plugin is a
# half-finished integration rather than a lightweight one.
harness_plugin_load() {
local name="$1" plugin
[ -n "$name" ] || return 0
plugin="$SCRIPT_DIR/harness/$name.sh"
if [ ! -f "$plugin" ]; then
echo "No harness plugin for '$name' at $plugin." >&2
echo "A supported harness has both a manifest and a plugin; see" >&2
echo "\"Adding a new harness\" in docs/development.md." >&2
exit 1
fi
# shellcheck disable=SC1090
source "$plugin"
}
# Calls one hook of the loaded plugin, if it defines it. Absence is normal:
# not every harness needs every hook, and the manifest is where a harness
# says what it cannot do at all.
harness_hook() {
local hook="$1"; shift
declare -F "$hook" >/dev/null 2>&1 || return 0
"$hook" "$@"
}
# --- what a plugin declares through --------------------------------------
#
# Each of these validates and then appends to core's own state. A plugin
# calls them; it does not reach past them.
# A host path mounted into the container at the same path. $2 is the mode
# ("ro", or empty for read-write).
#
# The guarded-store refusal runs here, at the point of declaration, so it
# cannot be skipped by a plugin that forgot it: a harness state directory
# that overlapped the approval or audit or epitaph store would otherwise
# hand a session exactly what those stores exist to withhold, and a harness
# is precisely the kind of thing whose paths are computed rather than
# written down.
emit_mount() {
local path="$1" mode="${2:-}"
[ -n "$path" ] || return 0
case "$path" in
*:*)
echo "Harness plugin emitted a mount path containing a colon: $path" >&2
exit 1 ;;
esac
_guarded_stores
_refuse_guarded_mount "harness state mount" "$path" \
${GUARDED_STORES[@]+"${GUARDED_STORES[@]}"}
if [ -n "$mode" ]; then
extra_mounts+=(-v "$path:$path:$mode")
else
extra_mounts+=(-v "$path:$path")
fi
}
# An environment variable for the container. Name-checked because it becomes
# a `-e NAME=value` argument, and a name carrying a space or an `=` would
# split into something else entirely.
emit_env() {
local name="$1" value="${2:-}"
case "$name" in
''|*[!A-Za-z0-9_]*)
echo "Harness plugin emitted an invalid environment variable name: $name" >&2
exit 1 ;;
esac
extra_mounts+=(-e "$name=$value")
}
+54
View File
@@ -141,6 +141,10 @@ EOF
;;
harness) cat <<'EOF'
command identifier claude
command_path ?path
guards ?list:identifier
no_confirm_args ?template
settings_args ?template
keys ?list:keyspec
resume_command ?template
briefing ?relpath
@@ -426,6 +430,37 @@ _config_extra_spec() {
return 1
}
# One key of the SELECTED harness, rather than of a harness named literally
# at the call site.
#
# `keys` is declared per harness, so reading one through a fixed section name
# makes it a declaration only one harness can ever be read through: a second
# harness gets selected, gets validated, and is then handed the first one's
# paths with nothing erroring anywhere. That is a silent wrong answer rather
# than a failure, which is why it is worth a helper rather than a longer
# expression at each site.
#
# Empty when no harness is selected. bin/dejima refuses to start in that
# state (there is no default, deliberately - see [core] harness), so it is
# reachable only from the standalone tools, where a harness-specific path
# that resolves to nothing is the honest answer for "no harness chosen".
harness_config_get() {
local name
name="$(config_get core harness)"
[ -n "$name" ] || return 0
config_get "harness.$name" "$1"
}
# Every guard rail dejima knows how to apply. A harness declares which of
# them it can host, in its manifest's `guards`; the ones it cannot are named
# at launch (see harness_report_guards) rather than quietly absent.
#
# Here rather than in dejima-harness-lib.sh because provider_validate_selection
# rejects a name outside this set, and a constant the validation depends on
# has to live where the validation does - providers.bats sources only this
# file, and against an empty set every guard is unknown.
DEJIMA_KNOWN_GUARDS="bash-deny push-guard merge-guard notify"
# Enumerates the keys of the SELECTED forge/harness (core.forge/core.harness,
# falling back to their schema default when no config file exists yet to
# read a selection from - e.g. an isolated call against a fresh checkout)
@@ -465,6 +500,25 @@ provider_validate_selection() {
manifest_validate workflow "$workflow" || return 1
manifest_validate harness "$harness" || return 1
# Every guard rail dejima knows how to apply. A harness declares which of
# them it can host; the ones it cannot are named at launch rather than
# quietly absent, because an operator who has them everywhere else would
# otherwise have to read a manifest to find out they are gone here.
#
# A name outside this set is a hard error rather than an unknown guard
# treated as absent: a typo would otherwise silently turn a guard off,
# which is the failure this whole declaration exists to prevent.
local guard
for guard in $(manifest_get harness "$harness" guards); do
case " $DEJIMA_KNOWN_GUARDS " in
*" $guard "*) ;;
*)
echo "harness '$harness' declares an unknown guard: $guard" >&2
echo "Known guards: $DEJIMA_KNOWN_GUARDS" >&2
return 1 ;;
esac
done
caps="$(manifest_get forge "$forge" capabilities)"
requires="$(manifest_get workflow "$workflow" requires)"
for need in $requires; do
+6 -6
View File
@@ -1,12 +1,12 @@
# 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-audit (to decide what counts as a cross-project write). Not on
# PATH and not executable.
# bin/dejima-epitaph (to decide whose transcripts to keep). Not on PATH and
# not executable.
#
# One definition, because the two callers must not disagree. A slug the mount
# exposes but the audit treats as foreign produces a finding on every ordinary
# session; a slug the audit treats as the workspace's own but the mount does
# not expose is a hole that nothing reports.
# One definition, because the callers must not disagree. A slug the mount
# exposes but a caller treats as foreign is a session whose work is silently
# left behind; a slug a caller treats as the workspace's own but the mount
# does not expose is a hole that nothing reports.
# Claude Code's own project key: the absolute cwd with every non-alphanumeric
# replaced by a dash. Matches docker/log-shipper.sh's Loki key and
+83
View File
@@ -0,0 +1,83 @@
# Harness plugin: Claude Code.
#
# Sourced by bin/dejima through harness_plugin_load when [core] harness names
# this harness. Part of the trusted computing base - see the interface notes
# in bin/dejima-harness-lib.sh for why plugins live here and why they are
# sourced rather than invoked.
#
# What is here is what could not be stated as a manifest value. The paths
# themselves come from the manifest ([harness.claude-code] projects_dir); the
# reason this is code is the pairing in the second block, which resolves one
# directory tree through the filenames of another.
# Registers the waiting/done notification handlers into dejima's settings
# layer, in the shape Claude Code reads them.
#
# Claude Code merges hook entries across settings layers rather than letting
# the higher one replace the lower, so these are added to whatever the
# operator already has rather than displacing it. $HOME is left unexpanded
# for Claude Code to resolve at hook-run time - inside the container, not
# here - matching the convention install.sh already uses.
#
# Written through a temp file and renamed: the destination is the settings
# layer bin/dejima is about to mount, and a jq that failed partway would
# otherwise leave the session with a truncated one.
harness_register_notify() {
local settings="$1" tmp
tmp="$(mktemp "${TMPDIR:-/tmp}/dejima-settings-remote.XXXXXX.json")"
if jq '.hooks.Notification = ((.hooks.Notification // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/dejima-notify.sh waiting"}]}])
| .hooks.Stop = ((.hooks.Stop // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/dejima-notify.sh done"}]}])' \
"$settings" > "$tmp"; then
mv "$tmp" "$settings"
else
rm -f "$tmp"
echo "warning: could not register the notification hooks; remote access" >&2
echo "will not be told when this session wants attention." >&2
fi
}
# The per-project and per-session state this harness keeps on the host, and
# which parts of it this workspace is entitled to see.
harness_state_mounts() {
local ws="$1" projects slug sid
projects="$(harness_config_get projects_dir)"
[ -n "$projects" ] || return 0
# projects/, one slug at a time: the tree is every project's transcripts
# and memory, and memory is loaded into context at session start, so a
# write into another slug is background a future session there believes
# (#104). projects/ itself stays container-local, so an unmounted slug is
# still writable and just does not survive - which is how Claude Code
# creates a slug it does not find. Existing slugs only, apart from the
# workspace's own, so a checkout full of worktrees does not litter the
# host with empty directories for worktrees nothing has run in.
mkdir -p "$projects/$(ws_slug "$ws")"
while IFS= read -r slug; do
[ -n "$slug" ] || continue
[ -d "$projects/$slug" ] || continue
emit_mount "$projects/$slug"
done < <(workspace_slugs_sorted "$ws")
# file-history, same shape, sharper reason: `<session>/<hash>@v1` is a
# verbatim copy of an edited file, so the tree is other projects' source
# rather than a record of it, and it was writable - tampering there puts
# chosen content into a later restore. Keyed by session id, so there is
# no slug to match on; workspace_session_ids resolves it through the
# transcript names. The starting session's id cannot be known yet, so its
# directory is container-local: undo history for it is lost if it is
# resumed on the host.
#
# The pairing is why the plugin is code, and the split is the design in
# miniature: both directories are statable, so both are manifest keys.
# What cannot be stated is the rule that a name in one selects an entry
# in the other, so that is here.
local history
history="$(harness_config_get file_history_dir)"
[ -n "$history" ] || return 0
mkdir -p "$history"
while IFS= read -r sid; do
[ -n "$sid" ] || continue
[ -d "$history/$sid" ] || continue
emit_mount "$history/$sid"
done < <(workspace_session_ids "$projects" "$ws")
}
+9 -6
View File
@@ -265,10 +265,13 @@ RUN npm install -g playwright@${PLAYWRIGHT_VERSION} && \
npx playwright install chromium
# Copy and set up firewall + DNS-proxy + entrypoint scripts, every forge's
# CLI helpers, and the claude wrapper that pins
# --dangerously-skip-permissions. The launched application itself
# (SANDBOX_CMD, default `claude`) and any editor config for it are supplied
# at runtime, not baked into the image - see docker-compose.yml/entrypoint.sh.
# CLI helpers, and the harness wrapper that pins whichever no-confirm flags
# the selected harness needs. The wrapper is installed under a neutral name:
# the image is shared across harnesses and the selection is per launch, so
# entrypoint.sh is what links it into place as the harness's own command.
# The launched application itself (SANDBOX_CMD) and any editor config for it
# are supplied at runtime, not baked into the image - see
# docker-compose.yml/entrypoint.sh.
COPY docker/fixed-domains.sh /usr/local/bin/
COPY docker/dns-proxy.sh /usr/local/bin/
COPY docker/init-firewall.sh /usr/local/bin/
@@ -278,7 +281,7 @@ COPY docker/ulogd.conf /etc/ulogd-sandbox.conf
COPY docker/sandbox-clipboard.lua /usr/local/share/nvim/runtime/plugin/sandbox-clipboard.lua
COPY docker/entrypoint.sh /usr/local/bin/
COPY docker/anthropic-proxy-forwarder.js /usr/local/bin/anthropic-proxy-forwarder.js
COPY docker/claude-wrapper.sh /usr/local/bin/claude
COPY docker/harness-wrapper.sh /usr/local/bin/dejima-harness-wrapper
# Back to root before the first RUN that writes outside /home/node. The npm
# installs above run as node deliberately; COPY writes as root regardless of
# USER, so the copies above are unaffected, but the install(1) below is a RUN
@@ -292,7 +295,7 @@ COPY forges/ /tmp/forges/
RUN find /tmp/forges -type f -path '*/bin/*' -exec install -m 755 {} /usr/local/bin/ \; \
&& rm -rf /tmp/forges
RUN chmod +x /usr/local/bin/dns-proxy.sh /usr/local/bin/init-firewall.sh /usr/local/bin/log-shipper.sh /usr/local/bin/entrypoint.sh /usr/local/bin/claude && \
RUN chmod +x /usr/local/bin/dns-proxy.sh /usr/local/bin/init-firewall.sh /usr/local/bin/log-shipper.sh /usr/local/bin/entrypoint.sh /usr/local/bin/dejima-harness-wrapper && \
echo 'export PATH="$PATH:/usr/local/share/npm-global/bin"' > /etc/profile.d/npm-global-path.sh
# Entrypoint runs as root (no USER node here) so it can set up the firewall
-16
View File
@@ -1,16 +0,0 @@
#!/bin/bash
# Shadows the real `claude` binary (installed under npm-global/bin, later on
# PATH) so every invocation - interactive shell, or claudecode.nvim's
# terminal - runs with --dangerously-skip-permissions, without callers
# needing to know that.
#
# The sandbox's settings overrides arrive the same way: as an extra
# --settings layer rather than by mounting over ~/.claude/settings.json (see
# docker-compose.yml and #47). That only holds if every entry point passes
# the flag, and shadowing the binary is what makes it true for the
# invocations the entrypoint never sees. The file is absent when a container
# is started without bin/dejima, as test/runtime.bats does.
settings=()
[ -f /etc/dejima-settings.json ] && settings=(--settings /etc/dejima-settings.json)
exec /usr/local/share/npm-global/bin/claude --dangerously-skip-permissions "${settings[@]}" "$@"
+12 -3
View File
@@ -68,9 +68,18 @@ services:
# just live at ~/.gitconfig directly.
GIT_CONFIG_GLOBAL: ${HOME}/.gitconfig-sandbox/.gitconfig
ALLOWED_DOMAINS: ${ALLOWED_DOMAINS:-}
# What actually runs as the sandbox user - defaults to bare `claude`.
# See entrypoint.sh and bin/dejima's SANDBOX_NVIM_CONFIG.
# What actually runs as the sandbox user - the selected harness's own
# command unless [core] command overrides it. See entrypoint.sh and
# bin/dejima's SANDBOX_NVIM_CONFIG.
SANDBOX_CMD: ${SANDBOX_CMD:-claude}
# The selected harness's manifest, resolved host-side: which command
# the wrapper shadows, the real binary behind it, the flags that make
# the session unattended, and how this harness is told to read dejima's
# settings layer. Nothing about a harness is baked into the image.
SANDBOX_HARNESS_COMMAND: ${SANDBOX_HARNESS_COMMAND:-}
SANDBOX_HARNESS_REAL: ${SANDBOX_HARNESS_REAL:-}
SANDBOX_HARNESS_ARGS: ${SANDBOX_HARNESS_ARGS:-}
SANDBOX_HARNESS_SETTINGS: ${SANDBOX_HARNESS_SETTINGS:-}
# Opt-in escape hatch from the default hard GitHub-push block - see
# init-firewall.sh and git-push-guard.sh. Off unless explicitly set.
ALLOW_GITHUB_PUSH: ${ALLOW_GITHUB_PUSH:-0}
@@ -161,7 +170,7 @@ services:
- ${HOME}/.claude/CLAUDE.md:/etc/dejima-claude/CLAUDE.md:ro
# The sandbox's own settings overrides (sandbox.enabled off, plus the
# remote-access notify hooks when that's on), generated per run by
# bin/dejima and reaching Claude Code as claude-wrapper.sh's
# bin/dejima and reaching the harness as harness-wrapper.sh's
# --settings argument. A container-local path, like the staged config
# above and for the same reason (#47) - and separate from that staging
# directory because this file is generated per run rather than copied
+28 -6
View File
@@ -103,6 +103,30 @@ if [ -s "$SANDBOX_SSH_KEY_SRC" ]; then
export SSH_AUTH_SOCK
fi
# Put the harness wrapper on PATH under the harness's own command name,
# ahead of the real binary, so every invocation gets the no-confirm flags -
# including the ones dejima never sees, an interactive shell or an editor
# plugin's terminal. Linked here rather than COPYed at build time because
# the image is shared across harnesses and the selection is per launch.
#
# Root-owned in a root-owned directory: the sandbox user must not be able to
# replace the wrapper with something that drops the flags, or reinstate the
# real binary's name ahead of it.
#
# Skipped when SANDBOX_HARNESS_COMMAND is unset, which is a container started
# without bin/dejima (test/runtime.bats). Refused when it is set to anything
# but a bare command name: it becomes a path under /usr/local/bin, and a
# value with a slash in it would write somewhere else entirely.
if [ -n "${SANDBOX_HARNESS_COMMAND:-}" ]; then
case "$SANDBOX_HARNESS_COMMAND" in
*/*|.*|'')
echo "entrypoint: refusing harness command name: $SANDBOX_HARNESS_COMMAND" >&2
exit 1 ;;
esac
ln -sf /usr/local/bin/dejima-harness-wrapper \
"/usr/local/bin/$SANDBOX_HARNESS_COMMAND"
fi
# ~/.claude config files arrive staged read-only under /etc/dejima-claude
# (see the mount inventory in docker-compose.yml) and get copied into place
# here rather than bind-mounted at their real paths. A copy can't be
@@ -292,10 +316,8 @@ fi
# sudo/setuid path back to root after this, so it can't re-run
# init-firewall.sh itself to widen the allowlist mid-session.
#
# SANDBOX_CMD is what actually runs as that user - defaults to bare `claude`
# (the wrapped binary on PATH, see claude-wrapper.sh) so a fresh clone of
# this repo works with no editor setup at all. Override it (e.g. to `nvim`)
# for a custom editor-driven workflow; pair with SANDBOX_NVIM_CONFIG (see
# bin/dejima) to bring in a real config for it instead of running
# unconfigured.
# SANDBOX_CMD is what actually runs as that user - the selected harness's
# own command unless [core] command overrides it (e.g. to `nvim`) for an
# editor-driven workflow; pair that with SANDBOX_NVIM_CONFIG (see bin/dejima)
# to bring in a real config for it instead of running unconfigured.
exec gosu "$SANDBOX_USER" ${SANDBOX_CMD:-claude} "$@"
+51
View File
@@ -0,0 +1,51 @@
#!/bin/bash
# Shadows the selected harness's real binary on PATH, so every invocation -
# the entrypoint's, an interactive shell's, an editor plugin's terminal -
# runs with the flags dejima requires, without any caller needing to know
# that.
#
# Installed by entrypoint.sh under the harness's own command name rather than
# COPYed to a fixed one at build time: the image is shared across harnesses
# and the selection is per launch, so which name to shadow is only known once
# the container starts.
#
# Every value here comes from the selected harness's manifest, resolved on
# the host (see bin/dejima) and passed in as environment. Nothing about any
# one harness is written down in this file.
#
# SANDBOX_HARNESS_REAL the real binary this shadows
# SANDBOX_HARNESS_ARGS the no-confirm flags, without which the agent
# stops to ask and the arrangement becomes a
# worse way of running it on the host
# SANDBOX_HARNESS_SETTINGS how this harness is told to read dejima's own
# settings layer, if it reads one at all
#
# The settings layer arrives as an extra layer rather than as a mount over
# the harness's own config file (docker-compose.yml, #47). That only holds if
# every entry point passes it, which is what shadowing the binary buys.
# /etc/dejima-settings.json is absent when a container is started without
# bin/dejima, as test/runtime.bats does, so its flag stays conditional on the
# file rather than on the manifest key alone.
#
# Word-split deliberately: both carry flags, which have no spaces, and an
# array is what exec needs. A harness whose flags need quoting has outgrown
# this and should say so in its plugin.
set -u
if [ -z "${SANDBOX_HARNESS_REAL:-}" ] || [ ! -x "${SANDBOX_HARNESS_REAL:-}" ]; then
echo "dejima: no harness binary at '${SANDBOX_HARNESS_REAL:-<unset>}'." >&2
echo "dejima: command_path in the harness manifest is what names it." >&2
exit 127
fi
args=()
# shellcheck disable=SC2206
[ -n "${SANDBOX_HARNESS_ARGS:-}" ] && args=(${SANDBOX_HARNESS_ARGS})
settings=()
# shellcheck disable=SC2206
[ -n "${SANDBOX_HARNESS_SETTINGS:-}" ] && [ -f /etc/dejima-settings.json ] \
&& settings=(${SANDBOX_HARNESS_SETTINGS})
exec "$SANDBOX_HARNESS_REAL" \
${args[@]+"${args[@]}"} ${settings[@]+"${settings[@]}"} "$@"
+1
View File
@@ -27,6 +27,7 @@ what a given launch will actually use.
| `[core] ssh_key` | unset | dedicated deploy key for headless/unattended git push |
| `[core] known_hosts` | `~/.ssh/known_hosts` | known-hosts file used for git-over-ssh |
| `[harness.claude-code] projects_dir` | `~/.claude/projects` | where Claude Code session transcripts live |
| `[harness.claude-code] file_history_dir` | `~/.claude/file-history` | Claude Code's edit-undo record, mounted per session rather than whole |
| `[harness.claude-code] statusline_path` | `~/.claude/statusline.sh` | statusline script path |
| `[forge.gitea] ssh_host` | unset | Gitea/Forgejo SSH hostname: skip if you only push to GitHub |
| `[forge.gitea] api` | unset | `https://<gitea host>/api/v1`: skip if you only push to GitHub |
+198 -2
View File
@@ -17,7 +17,7 @@ run them.
| `docker/ulogd.conf` | `ulogd2` config: turns `init-firewall.sh`'s NFLOG-tagged packets into JSON |
| `docker/log-shipper.sh` | Tails the dnsmasq/ulogd2 logs and ships them to the configured Loki endpoint (`[logging] loki_url`); also ships session transcripts when conversation logging (`[logging] conversation`) is on |
| `docker/entrypoint.sh` | Runs as root: DNS proxy, firewall, logging, drops to the sandbox user via `gosu`, then execs the session's command |
| `docker/claude-wrapper.sh` | Shadows `claude` on `PATH` to always add `--dangerously-skip-permissions` |
| `docker/harness-wrapper.sh` | Shadows the selected harness's command on `PATH` to add its no-confirm flags and dejima's settings layer. Named by no harness; `entrypoint.sh` links it into place |
| `docker/docker-compose.yml` | Wires up mounts/env; invoked via `bin/dejima`, not directly |
| `hooks/sandbox-context.sh` | `SessionStart` hook: injects sandbox context/workflow reminders, and any `.dejima.md` the project ships |
| `.dejima.md` | Project-level agent instructions, harness-agnostic: `CLAUDE.md`'s role under a neutral name. dejima loads a project's into the session context; this one is dejima's own |
@@ -35,7 +35,9 @@ run them.
| `bin/dejima-project` | Review and approve project domains and dependencies from the host |
| `bin/dejima-project-lib.sh` | Shared library sourced by `bin/dejima-dep`, `bin/dejima-project`, `bin/dejima` and the remote-access scripts; not on PATH |
| `bin/dejima-epitaph` | Host-side: keeps the summary a session leaves when its context is compacted away, in a store nothing mounts. Run from `bin/dejima`'s exit trap |
| `bin/dejima-slug-lib.sh` | The `~/.claude/projects` slug scheme and which slugs belong to a workspace; sourced by `bin/dejima` (what to mount) and `bin/dejima-audit` (what counts as cross-project), so the two cannot disagree. Not on PATH |
| `bin/dejima-harness-lib.sh` | Loads the selected harness's plugin and provides the `emit_*` functions it declares through; the guarded-store refusal runs in `emit_mount`. Not on PATH |
| `bin/harness/<name>.sh` | One per supported harness: what dejima does for that harness that no manifest value could state. Trusted computing base, sourced by `bin/dejima` |
| `bin/dejima-slug-lib.sh` | The `~/.claude/projects` slug scheme and which slugs belong to a workspace; sourced by `bin/dejima` (what to mount) and `bin/dejima-epitaph` (whose transcripts to keep), so the two cannot disagree. Not on PATH |
| `bin/dejima-ttyd` | Launches `ttyd` on loopback in front of `dejima-attach` |
| `systemd/*.service` | User units for `dejima-ttyd` and `dejima-notify-watch`; ship disabled, installed by hand |
| `docs/providers.md` | The manifest contract for the forge/workflow/harness axes, key by key, with the shipped manifests as worked examples |
@@ -148,3 +150,197 @@ push to one, `runtime.bats` included. That job needs two things from the
runner that an ordinary one doesn't (the host's Docker socket and the host
network namespace), and it checks for both up front rather than failing
obscurely partway through; the file says why each is needed.
## Adding a new harness
`[core] harness` picks a directory under `harnesses/`, and a harness manifest
declares four things. Everything else dejima does for Claude Code is written
against Claude Code, and this is the inventory of it: what a second harness
has to supply, and where dejima has to stop assuming. Taken from a pass over
the tree, so each row names the file to change rather than describing a
sketch.
### What a harness must be able to do
Support is per harness and deliberate. A supported harness gets the same
depth of integration Claude Code has - the same mounts, the same guards, the
same audit surfaces - so a harness is not a manifest someone drops in. That
is only possible if the harness can host what those protections need.
These are the requirements. A harness that fails one of the first five
cannot be supported, and that is a decision about the harness rather than a
gap in dejima.
| | Requirement | Why it is load-bearing |
| --- | --- | --- |
| **R1** | **Unattended execution.** A documented mode that runs tool calls without confirming each one. | The premise the project rests on. A harness that stops to ask is one an operator sits in front of, and the argument for accepting that mode is that the container makes being unattended survivable. |
| **R2** | **Tool-call interception.** A supported mechanism to inspect a command before it runs and refuse it, without patching the harness. | The push guard, the merge guard and the dangerous-command denylist all live here. They are guards against an eager agent rather than a boundary against a hostile one, but a harness without them ships three protections short and the operator cannot add them back. |
| **R3** | **Host-controlled context at session start.** A way to place text into the session's context at start, from a file the operator controls and the session cannot rewrite for the run in progress. | How the briefing and the project's `.dejima.md` arrive. It is also how the agent learns it is sandboxed, which is what makes it ask for a domain instead of working around the firewall. |
| **R4** | **State dejima can locate or discard.** Per-project state must be computable on the host before launch, so it can be scoped per project - or safely discardable, so it can stay container-local. | #104. A store that is neither computable nor discardable cannot be scoped, and mounting it whole reintroduces cross-project context poisoning. |
| **R5** | **A withholdable credential.** The model credential must live somewhere dejima can decline to mount, and the harness must accept a substitute: a staged fake, or an environment variable pointed at the host-side proxy. | The credential never entering the container is a headline property, not a nicety. |
Three more are required, but may be declared absent in the manifest, in
which case dejima says at launch what is not in force:
| | Requirement | What is lost without it |
| --- | --- | --- |
| **D1** | Session lifecycle signals - something that fires when the agent wants attention and when it stops. | Remote access notifications. The session still runs and is still attachable. |
| **D2** | A parseable record of the session. | Epitaph, and conversation logging. |
| **D3** | An enumerable config directory whose extension points can be mounted read-only. | Config parity, and the read-only mounts that stop a session installing a persistent hook for the next one. |
Declaring one absent is a real decision with a visible cost, not a
formality. It is there so a harness can be supported honestly at a smaller
guard set rather than shipped with three protections quietly missing.
**The guard rails are declared in the manifest and reported at launch.**
`guards` lists which of `bash-deny`, `push-guard`, `merge-guard` and
`notify` the harness can host. A name outside that set is refused, because a
typo would otherwise turn a guard off in silence. The ones a harness does
not declare are named once at launch, with what each costs - and nothing is
printed at all when it hosts them all, so the message stays a signal rather
than a line to scroll past.
Core gates the work on the declaration rather than on whether a plugin
happens to define the hook: a harness that forgot to declare a guard but
implemented it would otherwise get a protection the launch message has just
told the operator it does not have.
### Worked example: why aider is not supported
aider clears R1 (`--yes-always`), R3 (`--read` places a file in context),
R4 (its state is `.aider.*` inside the workspace, which is already mounted)
and R5 (it takes an API key from the environment).
It fails **R2**. aider has no hook or event system; the only occurrence of
"hook" in its options is `--no-verify`, which controls git's own pre-commit
hooks. There is no supported point at which a command can be inspected and
refused, so `git-push-guard.sh`, `merge-guard.sh` and
`deny-dangerous-bash.sh` have nowhere to live and no equivalent to be
rebuilt from. It also fails D1.
The container would still bound an aider session. But three guards an
operator gets everywhere else would be missing with no way to restore them,
and "supported" would mean something different for that one harness than
for every other. So it is not supported, and this is the reason.
### Where a harness's support lives: three tiers
| Tier | What | Rule |
| --- | --- | --- |
| **Core** | the mechanism, and every enforcement point | harness-agnostic, always |
| **Manifest** | `harnesses/<name>/harness.conf` | data. Never sourced, never executed. Selects among named core mechanisms and supplies paths |
| **Plugin** | `bin/harness/<name>.sh` | the harness-specific remainder, as code, in this repository |
**A feature belongs in the manifest if it can be stated as a value.** It only
moves to a plugin when it cannot. `projects_dir` and `file_history_dir` are
both paths, so both are manifest keys; the rule that a *name* in the first
selects an *entry* in the second is Claude Code's own and could not be
written as a value, so that is the plugin. "Add a manifest key" is the
answer until it demonstrably is not.
**Plugins live under `bin/`, not beside the manifest.** `dejima-audit`'s
`tcb_snapshot` covers `install.sh`, `bin/`, `hooks/`, `docker/` and `test/`,
and not the provider directories. Host-side plugin code under `harnesses/`
would be code that runs on the host and that a session could edit in
dejima's own checkout without raising a CRITICAL - the exact class the audit
exists to catch. Under `bin/` it is covered for free, and "manifests are
data, never executed" stays true word for word.
**Plugins are sourced, not invoked**, and are part of the trusted computing
base. The risk worth designing against is not a careless plugin but three
plugins implementing the security model three ways, so a plugin uses core's
own primitives rather than re-deriving them. An invoked protocol would also
put a new text parser on the launch path, and every parsing bug this
repository has had is in that family.
**A plugin computes; core enforces.** A plugin declares through `emit_mount`
and `emit_env`, which validate at the point of declaration - `emit_mount`
runs the guarded-store refusal there, so a harness state directory that
overlapped the approval, audit or epitaph store is refused without each
plugin having to remember. A plugin never touches `extra_mounts` itself, and
a static test holds that. A plugin may *classify* an audit path; it may never
suppress a finding.
Every shipped harness has both a manifest and a plugin - a static test
refuses one without the other, because a manifest alone would present as a
supported harness while none of the work had been done.
### What the manifest already covers
`harnesses/<name>/harness.conf`, and the full contract is in
[providers.md](providers.md):
| Key | What it decides |
| --- | --- |
| `command` | the container's main process |
| `keys` | this harness's own `[harness.<name>]` config schema |
| `resume_command` | what the briefing tells a session to hand the operator |
| `briefing` | this harness's fragment of the session briefing |
### Reading a harness's own config
`harness_config_get <key>` resolves whichever harness `[core] harness`
names. Use it; do not spell a `[harness.<name>]` section out at a call site.
`keys` is declared per harness, so a literal section name makes it a
declaration only one harness can ever be read through - a second harness
gets selected, gets validated, and is then handed the first one's paths with
nothing erroring anywhere. Three call sites did exactly that, in
`bin/dejima-audit` (twice) and `bin/dejima-epitaph`. A static test now holds
the rule.
It returns empty when no harness is selected. `bin/dejima` refuses to start
in that state, so that is reachable only from the standalone tools, where a
harness-specific path resolving to nothing is the honest answer.
### The inventory
| Area | What is Claude Code specific | Where |
| --- | --- | --- |
| Install | `npm install -g @anthropic-ai/claude-code`, and a pinned `CLAUDE_CODE_VERSION` build arg | `docker/Dockerfile` |
| Permission bypass | the flag itself, now `no_confirm_args` in the manifest; the wrapper is generic | `harnesses/<name>/harness.conf` |
| Settings layering | that overrides arrive as an extra layer rather than a mount over the operator's file (#47) is core; the flag that carries them is `settings_args` in the manifest | `harnesses/<name>/harness.conf` |
| Telemetry | `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` | `docker/Dockerfile` |
| Egress allowlist | `platform.claude.com` always, `api.anthropic.com` only when no credential proxy is in front of it | `docker/fixed-domains.sh` |
| Credentials | the whole proxy exists because Claude Code authenticates from `~/.claude/.credentials.json` rather than an env var; the entrypoint seeds a well-formed fake so it has something to read | `bin/dejima-anthropic-proxy`, `docker/anthropic-proxy-forwarder.js`, `docker/entrypoint.sh` |
| Hook protocol | the event payload arrives on stdin; `SessionStart` returns `hookSpecificOutput.hookEventName`/`additionalContext` as JSON, while a `PreToolUse` hook blocks by exiting 2 with its reason on stderr | `hooks/*.sh` |
| Hook registration | four hooks written into `~/.claude/settings.json` in Claude Code's schema, one `SessionStart` with a `startup\|resume\|clear\|compact` matcher and three `PreToolUse` matching `Bash` | `install.sh` |
| Briefing delivery | the briefing and the project's `.dejima.md` reach the session *through* the `SessionStart` hook. No hooks, no briefing | `hooks/sandbox-context.sh` |
| Notifications | `Notification`/`Stop` hooks, registered per session into the settings layer, are what drop the markers remote access watches | `hooks/dejima-notify.sh`, `bin/dejima` |
| Config parity | every `~/.claude` subdirectory is enumerated and mounted by name: `plugins`, `hooks`, `skills`, `commands`, `agents`, `output-styles` read-only; `backups`, `tasks`, `plans`, `sessions` read-write | `docker/docker-compose.yml` |
| Staged config | `settings.json`, `CLAUDE.md` and `statusline.sh` are staged read-only under `/etc/dejima-claude` and installed into place | `docker/entrypoint.sh`, `bin/dejima` |
| App state | `~/.claude.json` is copied rather than mounted, because it mixes live state with `mcpServers` config | `bin/dejima` |
| State layout | the project key - cwd with every non-alphanumeric replaced by `-` - is Claude Code's scheme, and dejima reproduces it in four places | `bin/dejima-slug-lib.sh`, `bin/dejima-project-lib.sh`, `docker/log-shipper.sh` |
| Per-project scoping | `projects/` is mounted one slug at a time and `file-history/` one session at a time, the second resolved by pairing `projects/<slug>/<id>.jsonl` with `file-history/<id>` (#104). Now in the plugin | `bin/harness/claude-code.sh` |
| Transcript format | conversation logging transforms Claude Code's JSONL line by line | `docker/log-shipper.sh` |
| Compaction format | epitaph keys on `isCompactSummary` records and the `compact_boundary` that precedes them | `bin/dejima-epitaph` |
| Audit surfaces | `.claude/settings.json` is CRITICAL and the rest of `.claude/` is a WARNING, because that is where this harness keeps things that run | `bin/dejima-audit` |
| Host code execution | `statusline.sh` is audited as its own CRITICAL surface because `settings.json`'s `statusLine.command` runs it **unsandboxed on the host** | `bin/dejima-audit`, `bin/dejima` |
### Two things worth deciding early
**What runs on the host when the harness is configured.** `statusLine.command`
is the one that exists today, and it is not a quirk: a harness that will
execute an operator-configured command outside the container hands a session
that writes that file a way onto the host. Find that surface in the new
harness before supporting it, because it belongs in the audit from the first
release rather than after someone finds it.
**What the harness keeps, and who else can read it.** The per-project
scoping of `projects/` and `file-history/` exists because a session that can
write another project's memory can plant text a later session there reads as
established background (#104). A new harness's state directory needs the same
question asked of it, one directory at a time. Anything mounted host-wide
should be there because it is keyed by session rather than by project, and
because nothing loads it into a session's context at startup - which is the
reasoning that leaves `tasks`, `plans`, `sessions` and `backups` mounted
whole today.
### Testing a new harness
`providers.bats` covers the manifest contract itself and is where a new
`harness.conf` gets its parsing and validation tests. The suites that assert
on Claude Code paths by name - `config.bats`, `audit.bats`, `static.bats`,
`runtime.bats` - are the ones that will need a second set, and a green run
against the existing harness proves nothing about a new one. Nothing in
`test/` currently runs a second harness end to end.
+19 -7
View File
@@ -211,7 +211,11 @@ A workflow's `briefing.md` is plain Markdown plus two marker forms that
| Key | Type | Default | Meaning |
| --- | --- | --- | --- |
| `command` | `identifier` | `claude` | The container's main process for this harness. |
| `command` | `identifier` | `claude` | The container's main process for this harness, and the name the wrapper shadows on `PATH`. `[core] command` defaults to it. |
| `command_path` | `?path` | (empty) | Where the real binary lives, behind the shadow. |
| `guards` | `?list:identifier` | (empty) | Which of dejima's guard rails this harness can host: `bash-deny`, `push-guard`, `merge-guard`, `notify`. A name outside that set is refused rather than treated as an unknown guard, since a typo would otherwise turn a guard off silently. The ones a harness does not declare are named at launch - see "What a harness must be able to do" in [development.md](development.md). |
| `no_confirm_args` | `?template` | (empty) | The flags that make the harness run without confirming each tool call. Required in practice: dejima refuses to launch a harness that names none, because unattended operation is the premise the container is built on (R1 in [development.md](development.md)). |
| `settings_args` | `?template` | (empty) | How this harness is told to read dejima's own settings layer, mounted at `/etc/dejima-settings.json`. Empty for a harness that takes its overrides some other way. |
| `keys` | `?list:keyspec` | (empty) | This harness's own `[harness.<name>]` config schema - same grammar as a forge's `keys`. |
| `resume_command` | `?template` | (empty) | Prose template (may use `{{session_id}}`) shown at the end of the briefing for resuming this exact session on the host. |
| `briefing` | `?relpath` | (empty) | This harness's briefing fragment file, relative to its own directory. |
@@ -219,14 +223,22 @@ A workflow's `briefing.md` is plain Markdown plus two marker forms that
Worked example, `harnesses/claude-code/harness.conf`:
```
command = claude
keys = projects_dir:path=~/.claude/projects statusline_path:path=~/.claude/statusline.sh
resume_command = claude --resume {{session_id}}
briefing = briefing.md
command = claude
command_path = /usr/local/share/npm-global/bin/claude
guards = bash-deny push-guard merge-guard notify
no_confirm_args = --dangerously-skip-permissions
settings_args = --settings /etc/dejima-settings.json
keys = projects_dir:path=~/.claude/projects file_history_dir:path=~/.claude/file-history statusline_path:path=~/.claude/statusline.sh
resume_command = claude --resume {{session_id}}
briefing = briefing.md
```
Reading it: the container's main process is `claude`; `[harness.claude-code]`
gets `projects_dir` and `statusline_path`, both `path`-typed with defaults;
Reading it: the container's main process is `claude`, wrapped on `PATH` in
front of the real binary at `command_path` so that every invocation - not
only the one the entrypoint makes - gets `--dangerously-skip-permissions`
and dejima's settings layer; `[harness.claude-code]`
gets `projects_dir`, `file_history_dir` and `statusline_path`, all `path`-typed
with defaults;
resuming the session on the host is `claude --resume <session id>`, with
`{{session_id}}` filled in at composition time; and its briefing fragment is
`briefing.md`.
+2 -1
View File
@@ -792,7 +792,8 @@ the next one is worth checking.
2. **Is the hook registered?** The `Notification` and `Stop` entries live in
the throwaway override layer, not in your real `settings.json`:
`grep -c dejima-notify /etc/dejima-settings.json` from inside should be 2.
That file is passed to Claude Code as `--settings` by `claude-wrapper.sh`;
That file is passed to the harness by `harness-wrapper.sh`, as whatever
its manifest's `settings_args` names (`--settings` for Claude Code);
if it is missing entirely, this container was not started by `dejima`.
3. **Does the marker reach the host?** This is the one to check first: it
splits the chain cleanly in half. From inside the sandbox:
+32 -14
View File
@@ -218,9 +218,8 @@ hands to whichever session starts against it. See
appears in `.git/config` directly), `!`-shell aliases - plus content
changes under the workspace's `.claude/`, `.mcp.json`, `.envrc`,
`.vscode/`, and `.git/hooks/` (symlinks included - a repointed symlink is
hashed by its target, not just its content), plus cross-project writes
under `~/.claude/projects` (see below). When the mounted workspace is this
repo itself, it also hashes the sandbox's own trusted computing base -
hashed by its target, not just its content). When the mounted workspace is
this repo itself, it also hashes the sandbox's own trusted computing base -
`bin/`, `hooks/`, `docker/`, `install.sh`, `test/`.
Worth knowing about that first surface, because the sandbox's own workflow
@@ -260,6 +259,13 @@ hands to whichever session starts against it. See
[a] approve this exact content [i] ignore this key from now on [s] skip (ask again next time) [q] quit
```
A finding whose file has since been deleted is **cleared** rather than
approved: there is no content left to pin an approval to, and a surface
that does not exist does not run. Nothing is recorded, so if it comes back
it comes back as a new finding. Refusing instead - the old behaviour - left
the report unresolvable, and because `ack` opens CRITICAL reports first it
hid every other report behind that one.
Skipping asks again next session, the right default when you are unsure.
It matters for keys some tools rewrite on every run: with no decision
recorded, one re-fires every session and pushes real findings out of the
@@ -386,17 +392,17 @@ hands to whichever session starts against it. See
first-run case it already handles, and it is why an absent sibling does
not risk the hang that a blocked write to `~/.claude.json` caused.
Cross-project writes are still **detected**: the audit runs host-side over
the whole tree, so narrowing the mount does not narrow it, and it now
covers what the mount cannot reach - concurrent *host* sessions. Residual:
the workspace's own transcripts and memory stay writable, since
`claude --resume` needs that; and detection keys off each slug's max file
mtime, so an edit to a file that is not already the newest goes unflagged.
The time-window caveat applies most sharply here, because on the host
`~/.claude/projects` is a single
directory shared by *every* Claude Code session - so an innocent concurrent
session elsewhere can produce a `CROSS-PROJECT` finding that reads like
poisoning and isn't.
Residual: the workspace's own transcripts and memory stay fully writable
throughout, since Claude Code needs that for host-side `claude --resume`.
There is no longer a `CROSS-PROJECT` finding for this. It existed when the
whole tree was mounted and a session really could write another project's
slug. Now that it cannot, the only writes the audit could still see there
are other sessions doing their own work - and on a host running several at
once it reported all of them, on every report. Detection that cannot
observe what it was built for, and fires constantly on what it was not,
trains an operator to skim. The mount is the control, and `static.bats`
pins it.
- `~/.claude/file-history` (Claude Code's edit-undo record) is **not**
mounted as a tree either, and the reason is sharper than for `projects/`.
Each `<session>/<hash>@v1` is a **verbatim copy of an edited file**, so the
@@ -728,6 +734,18 @@ hands to whichever session starts against it. See
changes takes effect the *next* time `dejima` itself runs,
not the current one.
The report splits on whether git can see the change. tcb_snapshot only
populates when the workspace IS dejima's own repo, so an ordinary change
there is already in the working tree you are about to read in `git status`
and in the pull request - reporting each one as a CRITICAL to acknowledge
duplicated a stronger control and made developing dejima itself unusable.
Those are counted in one INFO line instead.
A change git *cannot* see stays a CRITICAL finding, because nothing else
would catch it: a file in `.gitignore` or `.git/info/exclude`, or one
marked `assume-unchanged` or `skip-worktree`, is live code that runs on the
next launch and appears nowhere in `git status`.
### Declaration files are session-writable
A project's `.dejima/` declaration files sit inside the project mount, which
+8 -4
View File
@@ -1,4 +1,8 @@
command = claude
keys = projects_dir:path=~/.claude/projects statusline_path:path=~/.claude/statusline.sh
resume_command = claude --resume {{session_id}}
briefing = briefing.md
command = claude
command_path = /usr/local/share/npm-global/bin/claude
guards = bash-deny push-guard merge-guard notify
no_confirm_args = --dangerously-skip-permissions
settings_args = --settings /etc/dejima-settings.json
keys = projects_dir:path=~/.claude/projects file_history_dir:path=~/.claude/file-history statusline_path:path=~/.claude/statusline.sh
resume_command = claude --resume {{session_id}}
briefing = briefing.md
+156 -98
View File
@@ -23,6 +23,8 @@ setup() {
CONFIG_DIR="$(mktemp -d)"
export DEJIMA_CONFIG_DIR="$CONFIG_DIR"
cat > "$CONFIG_DIR/config" <<EOF
[core]
harness = claude-code
[harness.claude-code]
projects_dir = $PROJDIR
statusline_path = $DEJIMA_AUDIT_STATUSLINE
@@ -143,16 +145,24 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
[ "$count" -eq 1 ]
}
@test "flags a cross-project write under ~/.claude/projects" {
# The cross-project finding is gone, and these are its replacement: writes
# under ~/.claude/projects must produce NOTHING now, whichever slug they land
# in. It reported a poisoning route a session could once take - the whole
# tree was mounted - and since #104 a session gets its own slugs only, so
# every remaining write it could see belongs to some other session doing its
# own work. With several sessions running it reported all of them on every
# report.
@test "a write to another project's slug is not reported" {
mkdir -p "$PROJDIR/-some-other-project"
echo x > "$PROJDIR/-some-other-project/file1"
snap
echo y > "$PROJDIR/-some-other-project/file2"; touch -d '+1 hour' "$PROJDIR/-some-other-project/file2"
run cmp_
[[ "$output" == *"CROSS-PROJECT history/memory write: -some-other-project"* ]]
[[ "$output" != *"CROSS-PROJECT"* ]]
[[ "$output" != *"-some-other-project"* ]]
}
@test "does not flag the current project's own projects slug" {
@test "a write to this project's own slug is not reported either" {
mkdir -p "$PROJDIR/$CURRENT_SLUG"
echo x > "$PROJDIR/$CURRENT_SLUG/file1"
snap
@@ -161,34 +171,6 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
[[ "$output" != *"CROSS-PROJECT"* ]]
}
@test "does not flag a worktree child's projects slug" {
# A real worktree, not just a directory named like one. "Self" is derived
# from live worktree paths now, so the fixture has to produce one.
git -C "$WS" commit -q --allow-empty -m base
git -C "$WS" worktree add -q "$WS/.worktrees/featurex" -b featurex
mkdir -p "$PROJDIR/${CURRENT_SLUG}--worktrees-featurex"
echo x > "$PROJDIR/${CURRENT_SLUG}--worktrees-featurex/file1"
snap
echo y > "$PROJDIR/${CURRENT_SLUG}--worktrees-featurex/file2"; touch -d '+1 hour' "$PROJDIR/${CURRENT_SLUG}--worktrees-featurex/file2"
run cmp_
[[ "$output" != *"CROSS-PROJECT"* ]]
}
# The regression guard for #104's sibling case. The old test was a
# "$CURRENT_SLUG"-* glob, which this slug matches: a project checked out
# beside this one, whose directory name merely extends it, was silently
# exempt from cross-project reporting. `proj` and `proj-ci-hardener` side by
# side is an ordinary layout, not a contrived one.
@test "flags a sibling project whose directory name extends this one's" {
mkdir -p "$PROJDIR/${CURRENT_SLUG}-ci-hardener"
echo x > "$PROJDIR/${CURRENT_SLUG}-ci-hardener/file1"
snap
echo y > "$PROJDIR/${CURRENT_SLUG}-ci-hardener/file2"
touch -d '+1 hour' "$PROJDIR/${CURRENT_SLUG}-ci-hardener/file2"
run cmp_
[[ "$output" == *"CROSS-PROJECT"* ]]
}
@test "silent when no projects changed" {
mkdir -p "$PROJDIR/-some-other-project"
echo x > "$PROJDIR/-some-other-project/file1"
@@ -230,7 +212,11 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
[[ "$output" == *".claude/evil"* ]]
}
@test "TCB: flags a change under bin/ when workspace is the audit's own repo root" {
@test "TCB: a change git can see is reported as info, not as a CRITICAL to ack" {
# tcb_snapshot only populates when the workspace IS dejima's own repo, so
# every change here is also in the git working tree the operator is about
# to read. One CRITICAL per file duplicated `git status` and made
# developing dejima itself unusable.
mkdir -p "$WS/bin"
echo x > "$WS/bin/x"
echo x > "$WS/install.sh"
@@ -238,7 +224,27 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
snap
echo changed > "$WS/bin/x"
run cmp_
[[ "$output" == *"TCB (sandbox self) modified: bin/x"* ]]
[[ "$output" != *"TCB (sandbox self) modified"* ]] # nothing to acknowledge
# Recorded as INFO in the report rather than shouted on stderr: the
# startup warning already said the TCB is writable this session.
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
run bash -c "cat '$AUDITDIR/$slug'/*.txt"
[[ "$output" == *"this workspace IS dejima"* ]]
}
@test "TCB: a change git CANNOT see is still a CRITICAL finding" {
# The case that justifies keeping the check at all. A file listed in
# .git/info/exclude is live code that runs on the next launch and appears
# nowhere in `git status`, so nothing but this hash sees it.
mkdir -p "$WS/bin" "$WS/.git/info"
echo x > "$WS/bin/hidden"
echo x > "$WS/install.sh"
echo 'bin/hidden' > "$WS/.git/info/exclude"
export DEJIMA_AUDIT_REPO_ROOT="$(readlink -f "$WS")"
snap
echo changed > "$WS/bin/hidden"
run cmp_
[[ "$output" == *"TCB (sandbox self) modified, INVISIBLE to git: bin/hidden"* ]]
}
@test "TCB: does not flag changes when the workspace is not the audit's own repo root" {
@@ -260,6 +266,24 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
[ "$output" = "" ]
}
@test "says so when no harness is selected, rather than auditing a narrower surface in silence" {
# The statusline path comes from the selected harness, so with none
# selected there is nothing to watch and the compensating control for
# the symlink gap covers nothing. bin/dejima refuses to start without
# [core] harness, so this state means the audit was driven by hand - and
# a clean report over a surface that was never examined is worse than a
# noisy one.
printf '#!/bin/sh\necho original\n' > "$DEJIMA_AUDIT_STATUSLINE"
cat > "$CONFIG_DIR/config" <<EOF
[audit]
dir = $AUDITDIR
baselines_dir = $BASEDIR
EOF
run "$AUDIT" snapshot "$WS" "$STATE"
[[ "$output" == *"no [core] harness is set"* ]] || { echo "$output"; return 1; }
[[ "$output" == *"NOT checked"* ]] || { echo "$output"; return 1; }
}
@test "flags a plain regular-file statusline.sh replaced with different content" {
printf '#!/bin/sh\necho original\n' > "$DEJIMA_AUDIT_STATUSLINE"
snap
@@ -611,14 +635,56 @@ cmp_() { "$AUDIT" compare "$WS" "$STATE" 2>&1; }
[ "$result" -ne 124 ]
}
@test "show prints the only report there is, and names it" {
# One `ack` run used to walk one report and exit, saying nothing about the
# rest, so clearing a backlog took as many runs as there were reports and
# looked like the tool was stuck on the first one.
@test "ack walks every report in one run when told to continue" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
snap; mkdir -p "$WS/.claude"; echo '{}' > "$WS/.claude/settings.json"; cmp_ >/dev/null 2>&1
[ "$(find "$AUDITDIR/$slug" -name '*.txt' | wc -l)" -eq 2 ]
# approve, continue, approve
run bash -c "printf 'a\ny\na\n' | '$AUDIT' ack '$WS'"
[ "$status" -eq 0 ]
[[ "$output" == *"2 reports waiting"* ]]
[[ "$output" == *".envrc"* ]]
[[ "$output" == *"settings.json"* ]] # the second report, same run
}
@test "ack stops at one report when told not to continue" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
snap; mkdir -p "$WS/.claude"; echo '{}' > "$WS/.claude/settings.json"; cmp_ >/dev/null 2>&1
run bash -c "printf 'a\nn\n' | '$AUDIT' ack '$WS'"
[ "$status" -eq 0 ]
[[ "$output" == *"still waiting"* ]] # said what is left behind
[[ "$output" != *"settings.json"* ]] # and did not open it
}
@test "show indexes the reports rather than dumping one" {
# It used to `cat` a single report: a wall of text that answered neither
# question an operator has - how much is waiting, and what still needs a
# decision - and included findings already acknowledged, so it did not
# even shrink as they worked through it.
snap
echo 'export X=1' > "$WS/.envrc"
cmp_ >/dev/null 2>&1
run bash -c "'$AUDIT' show '$WS' 2>&1"
[ "$status" -eq 0 ]
[[ "$output" == *"1 report(s)"* ]]
[[ "$output" == *"-CRITICAL.txt"* ]] # names it
[[ "$output" == *"ack starts here"* ]] # and which one ack will open
[[ "$output" != *".envrc"* ]] # without the contents
}
@test "show --full prints the report ack would open" {
snap
echo 'export X=1' > "$WS/.envrc"
cmp_ >/dev/null 2>&1
run bash -c "'$AUDIT' show '$WS' --full 2>&1"
[ "$status" -eq 0 ]
[[ "$output" == *".envrc"* ]]
[[ "$output" == *"-CRITICAL.txt"* ]] # said which report it printed
[[ "$output" == *"-CRITICAL.txt"* ]]
}
@test "show and ack agree on which report they mean" {
@@ -638,19 +704,20 @@ WARNING changed workspace file: .vscode/settings.json
REPORT
run "$AUDIT" show "$WS"
[ "$status" -eq 0 ]
[[ "$output" == *".envrc"* ]] # the stale CRITICAL report...
[[ "$output" == *"-CRITICAL.txt"* ]] # the stale CRITICAL report...
[[ "$output" != *"settings.json"* ]] # ...not the newer clean one
}
@test "show falls back to the newest report when no CRITICAL one is pending" {
@test "show marks the newest report as the one ack will open, absent a CRITICAL" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
report_dir="$AUDITDIR/$slug"; mkdir -p "$report_dir"
echo 'older' > "$report_dir/2020-01-01-000000.txt"
echo 'newer' > "$report_dir/2026-01-01-000001.txt"
run "$AUDIT" show "$WS"
[ "$status" -eq 0 ]
[[ "$output" == *"newer"* ]]
[[ "$output" != *"older"* ]]
[[ "$output" == *"2 report(s)"* ]] # both listed, not just one
[[ "$output" == *"2020-01-01-000000.txt"* ]]
[[ "$output" == *"2026-01-01-000001.txt"*"ack starts here"* ]]
}
@test "ack presents a stale unacknowledged CRITICAL report before a newer clean one" {
@@ -791,39 +858,32 @@ REPORT
[[ "$output" != *"-CRITICAL.txt"* ]]
}
@test "a finding that cannot be acknowledged is presented once, not on every run" {
# There is nothing to record for one, so without a marker it re-prompted
# forever and `ack` could never reach a state of having nothing to ask.
@test "a cleared finding is presented once, not on every run" {
# There is nothing to record for a surface that no longer exists, so
# without a `reviewed` marker the report re-prompted forever and `ack`
# could never reach a state of having nothing to ask.
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
mkdir -p "$PROJDIR/-some-other-project"
echo x > "$PROJDIR/-some-other-project/file1"
snap
echo y > "$PROJDIR/-some-other-project/file2"
touch -d '+1 hour' "$PROJDIR/-some-other-project/file2"
cmp_ >/dev/null 2>&1
run bash -c "printf 's\n' | '$AUDIT' ack '$WS'"
[[ "$output" == *"CROSS-PROJECT"* ]]
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
rm -f "$WS/.envrc"
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[[ "$output" == *".envrc"* ]]
grep -q '^reviewed ' "$BASEDIR/$slug"
run bash -c "printf 's\n' | '$AUDIT' ack '$WS'"
[[ "$output" != *"CROSS-PROJECT"* ]]
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[[ "$output" != *".envrc"* ]]
}
@test "quitting a report does not mark it reviewed" {
# The marker means "walked to the end". An abort discards everything
# else, and must discard this too or one q would silence the report.
# The marker means "walked to the end". An abort discards everything else,
# and must discard this too or one q would silence the report.
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
mkdir -p "$PROJDIR/-some-other-project"
echo x > "$PROJDIR/-some-other-project/file1"
snap
echo y > "$PROJDIR/-some-other-project/file2"
touch -d '+1 hour' "$PROJDIR/-some-other-project/file2"
cmp_ >/dev/null 2>&1
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
rm -f "$WS/.envrc"
run bash -c "printf 'q\n' | '$AUDIT' ack '$WS'"
! grep -q '^reviewed ' "$BASEDIR/$slug" 2>/dev/null
run bash -c "printf 's\n' | '$AUDIT' ack '$WS'"
[[ "$output" == *"CROSS-PROJECT"* ]]
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[[ "$output" == *".envrc"* ]]
}
@test "a WARNING git config finding can be acknowledged, both ways (#45)" {
@@ -873,24 +933,6 @@ REPORT
[[ "$output" != *"-CRITICAL.txt"* ]]
}
@test "ack does not write an unmatchable entry for a finding with no id" {
# Was written against a changed git config, which #45 made ackable. The
# invariant is about findings that have no id to record at all, so it now
# uses the one class that still has none: a cross-project write, whose
# subject is another project's slug rather than anything this audit can
# hash and pin.
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
mkdir -p "$PROJDIR/-some-other-project"
echo x > "$PROJDIR/-some-other-project/file1"
snap
echo y > "$PROJDIR/-some-other-project/file2"
touch -d '+1 hour' "$PROJDIR/-some-other-project/file2"
cmp_ >/dev/null 2>&1
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[[ "$output" == *"cannot"* ]]
! grep -qE '^(safe|spam) ' "$BASEDIR/$slug" 2>/dev/null
}
@test "an ack-approved finding is suppressed on the next compare (id round-trips through the baseline)" {
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
@@ -919,24 +961,37 @@ REPORT
# so the report must stay eviction-protected
}
@test "ack refuses to approve a finding whose path no longer exists, and the CRITICAL suffix survives" {
# A since-deleted path has no content left to pin a `safe` approval to -
# writing one anyway (the old behaviour) produced a baseline entry
# baseline_suppresses() can never match (its own empty-sha guard rejects
# it as malformed), so the surface stayed permanently unsuppressible -
# while `a` still advanced crit_acked and let the report shed its
# -CRITICAL.txt protection for good. Refusing outright keeps both the
# baseline and the report's protection honest.
@test "a finding whose path no longer exists is cleared, not refused forever" {
# Refusing outright kept the baseline honest - there is no content left to
# pin a `safe` entry to - but it also meant crit_acked never reached
# crit_seen, so the report kept -CRITICAL.txt. select_report prefers
# CRITICAL, so one deleted file pinned `ack` to a report that no decision
# could resolve and hid every other report behind it, permanently.
#
# Still nothing written to the baseline: an entry with an empty sha can
# never match. The finding is simply cleared, because a surface that does
# not exist does not run. If it comes back, it comes back as new.
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
rm -f "$WS/.envrc" # gone by the time ack runs
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[ "$status" -eq 0 ]
[[ "$output" == *"cannot approve: .envrc no longer exists"* ]]
[[ "$output" == *"nothing recorded"* ]]
[ ! -f "$BASEDIR/$slug" ] # nothing written to the baseline at all
[[ "$output" == *"cleared"* ]]
[[ "$output" == *".envrc"* ]]
! grep -qE '^(safe|spam) ' "$BASEDIR/$slug" 2>/dev/null # nothing unmatchable recorded
grep -q '^reviewed ' "$BASEDIR/$slug" # but the report is done with
run bash -c "ls '$AUDITDIR/$slug'"
[[ "$output" == *"-CRITICAL.txt"* ]] # unacknowledged CRITICAL keeps its protection
[[ "$output" != *"-CRITICAL.txt"* ]] # and no longer blocks the queue
}
# The other half: clearing is not a way to launder a finding whose surface is
# still there. Only an absent one can be cleared.
@test "a finding whose path still exists is approved against its content, not cleared" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
snap; echo 'export X=1' > "$WS/.envrc"; cmp_ >/dev/null 2>&1
run bash -c "printf 'a\n' | '$AUDIT' ack '$WS'"
[[ "$output" != *"cleared"* ]]
grep -q '^safe .envrc ' "$BASEDIR/$slug"
}
@test "a workspace path containing its own ': ' cannot hijack ack's id derivation" {
@@ -1079,7 +1134,10 @@ REPORT
@test "ack records a safe tcb entry whose id round-trips and suppresses the next compare" {
slug="$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
mkdir -p "$WS/bin"; echo x > "$WS/bin/x"; echo x > "$WS/install.sh"
# Hidden from git, which is the only shape that is still a finding.
mkdir -p "$WS/bin" "$WS/.git/info"
echo x > "$WS/bin/x"; echo x > "$WS/install.sh"
echo 'bin/x' > "$WS/.git/info/exclude"
export DEJIMA_AUDIT_REPO_ROOT="$(readlink -f "$WS")"
snap
echo changed > "$WS/bin/x"
@@ -1137,7 +1195,7 @@ REPORT
cmp_ >/dev/null
run bash -c "cd '$WS' && '$AUDIT' show 2>/dev/null"
[ "$status" -eq 0 ]
[[ "$output" == *".envrc"* ]]
[[ "$output" == *"report(s) for $WS"* ]]
}
@test "show finds the workspace when run from a subdirectory" {
@@ -1147,7 +1205,7 @@ REPORT
cmp_ >/dev/null
run bash -c "cd '$WS/src/deep' && '$AUDIT' show 2>/dev/null"
[ "$status" -eq 0 ]
[[ "$output" == *".envrc"* ]]
[[ "$output" == *"report(s) for $WS"* ]]
}
@test "show finds the parent repo when run from a linked worktree" {
@@ -1162,7 +1220,7 @@ REPORT
cmp_ >/dev/null
run bash -c "cd '$WS/.worktrees/wt' && '$AUDIT' show 2>/dev/null"
[ "$status" -eq 0 ]
[[ "$output" == *".envrc"* ]]
[[ "$output" == *"report(s) for $WS"* ]]
}
@test "show finds the parent repo's report even after the worktree itself was snapshotted" {
@@ -1177,7 +1235,7 @@ REPORT
"$AUDIT" snapshot "$WS/.worktrees/wt" "$(mktemp)"
run bash -c "cd '$WS/.worktrees/wt' && '$AUDIT' show 2>/dev/null"
[ "$status" -eq 0 ]
[[ "$output" == *".envrc"* ]]
[[ "$output" == *"report(s) for $WS"* ]]
}
@test "show names the path it resolved when that workspace has no reports" {
@@ -1198,7 +1256,7 @@ REPORT
other="$(mktemp -d)"; git -C "$other" init -q
run bash -c "cd '$other' && '$AUDIT' show '$WS' 2>/dev/null"
[ "$status" -eq 0 ]
[[ "$output" == *".envrc"* ]]
[[ "$output" == *"report(s) for $WS"* ]]
rm -rf "$other"
}
+115
View File
@@ -1693,6 +1693,32 @@ _epitaph_transcript() {
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
}
@test "a slug that kept nothing gets no directory" {
# A workspace's worktrees are slugs of its own, and they have transcripts;
# what they mostly do not have is a compaction summary in any of them. A
# directory per slug therefore made the store read as one entry per
# worktree, all of them empty.
_epitaph_fixture
_epitaph_transcript s1 "kept"
git -C "$EPI_WS" -c user.email=t@example.com -c user.name=t \
commit -q --allow-empty -m init
local wt="$EPI_WS/wt" wt_slug
git -C "$EPI_WS" worktree add -q "$wt" -b side
wt_slug="$(printf '%s' "$wt" | sed 's/[^a-zA-Z0-9]/-/g')"
mkdir -p "$EPI_TR/$wt_slug"
printf '{"type":"user","message":{"content":"no summary in here"}}\n' \
> "$EPI_TR/$wt_slug/s2.jsonl"
# A directory an earlier run already left behind goes too.
mkdir -p "$EPI_STORE/$wt_slug"
"$REPO_ROOT/bin/dejima-epitaph" save "$EPI_WS" "$EPI_TR" >/dev/null 2>&1
[ ! -d "$EPI_STORE/$wt_slug" ] \
|| { echo "empty directory left for $wt_slug"; return 1; }
[ -n "$(find "$EPI_STORE/$EPI_SLUG" -name '*.md' 2>/dev/null)" ] \
|| { echo "the workspace's own epitaph was not kept"; return 1; }
git -C "$EPI_WS" worktree remove --force "$wt" 2>/dev/null || true
rm -rf "$EPI_STORE" "$EPI_TR" "$EPI_WS"
}
@test "the epitaph store is not mounted into the container" {
# The whole point: a session must not be able to read what earlier
# sessions concluded, or edit what this one is about to record.
@@ -1701,3 +1727,92 @@ _epitaph_transcript() {
run grep -nE 'extra_mounts\+=.*epitaph' "$REPO_ROOT/bin/dejima"
[ "$status" -ne 0 ] || { echo "$output"; return 1; }
}
# --- harness plugins ------------------------------------------------------
@test "a harness with a manifest but no plugin is refused" {
# Support is per harness and deliberate. A manifest alone would present
# as a supported harness while none of what dejima does for a harness
# had been written, which is the "thin drop-in manifest" this design
# exists to prevent.
_rw_setup
workspace="$(mktemp -d)"
# A provider root of its own, via the DEJIMA_PROVIDER_ROOT test hook,
# rather than a directory added to the checkout: a suite that writes into
# the repository it is testing leaves litter when it aborts and collides
# with a concurrent run.
proot="$(mktemp -d)"
cp -a "$REPO_ROOT/forges" "$REPO_ROOT/workflows" "$REPO_ROOT/harnesses" "$proot/"
mkdir -p "$proot/harnesses/test-no-plugin"
printf 'command = true\n' > "$proot/harnesses/test-no-plugin/harness.conf"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'harness = test-no-plugin'
DEJIMA_PROVIDER_ROOT="$proot" _rw_launch "$workspace"
[ "$status" -ne 0 ] || { echo "launched anyway:"; echo "$output"; return 1; }
[[ "$output" == *"No harness plugin for 'test-no-plugin'"* ]] || { echo "$output"; return 1; }
rm -rf "$workspace" "$proot"
}
@test "emit_mount refuses a harness state directory that overlaps a guarded store" {
# The reason the refusal lives in emit_mount rather than in each plugin.
# A harness's state paths are computed from config rather than written
# down, so this is exactly the shape that reaches a mount without anyone
# having looked at the value - the same blind spot as #15.
_rw_setup
workspace="$(mktemp -d)"
audit_dir="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[audit]' "dir = $audit_dir" \
'[harness.claude-code]' "projects_dir = $audit_dir/projects"
_rw_launch "$workspace"
[ "$status" -ne 0 ] || { echo "launched anyway:"; echo "$output"; return 1; }
[[ "$output" == *"Refusing harness state mount"* ]] || { echo "$output"; return 1; }
rm -rf "$workspace" "$audit_dir"
}
@test "a harness that cannot host a guard says so at launch" {
# The capability bar allows a harness to be supported at a smaller guard
# set. What it does not allow is that being invisible: an operator who
# has these everywhere else would otherwise have to read a manifest to
# find out they are gone here.
_rw_setup
workspace="$(mktemp -d)"
proot="$(mktemp -d)"
cp -a "$REPO_ROOT/forges" "$REPO_ROOT/workflows" "$REPO_ROOT/harnesses" "$proot/"
# Same harness, minus two guards, so the message is the only difference.
sed -i 's/^guards .*/guards = bash-deny notify/' \
"$proot/harnesses/claude-code/harness.conf"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
DEJIMA_PROVIDER_ROOT="$proot" _rw_launch "$workspace"
[[ "$output" == *"does not support 2 of dejima's guards"* ]] || { echo "$output"; return 1; }
[[ "$output" == *"push-guard"* ]] || { echo "$output"; return 1; }
[[ "$output" == *"merge the agent"* || "$output" == *"merge its own pull requests"* ]] \
|| { echo "$output"; return 1; }
rm -rf "$workspace" "$proot"
}
@test "a harness hosting every guard says nothing about guards" {
# The message must not become a line an operator learns to scroll past.
_rw_setup
workspace="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
_rw_launch "$workspace"
[[ "$output" != *"guards"* ]] || { echo "$output"; return 1; }
rm -rf "$workspace"
}
@test "a harness declaring a guard dejima does not know is refused" {
# A typo would otherwise turn a guard off silently, which is the failure
# the declaration exists to prevent.
_rw_setup
workspace="$(mktemp -d)"
proot="$(mktemp -d)"
cp -a "$REPO_ROOT/forges" "$REPO_ROOT/workflows" "$REPO_ROOT/harnesses" "$proot/"
sed -i 's/^guards .*/guards = bash-deny push-gaurd/' \
"$proot/harnesses/claude-code/harness.conf"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only'
DEJIMA_PROVIDER_ROOT="$proot" _rw_launch "$workspace"
[ "$status" -ne 0 ] || { echo "launched anyway:"; echo "$output"; return 1; }
[[ "$output" == *"unknown guard: push-gaurd"* ]] || { echo "$output"; return 1; }
rm -rf "$workspace" "$proot"
}
+248 -16
View File
@@ -482,10 +482,15 @@ EOF
}
}
@test "bin/dejima mounts ~/.claude/projects one slug at a time" {
grep -q 'workspace_slugs_sorted "$WORKSPACE_DIR"' "$REPO_ROOT/bin/dejima"
grep -q 'extra_mounts+=(-v "$HOME/.claude/projects/$_slug:$HOME/.claude/projects/$_slug")' \
"$REPO_ROOT/bin/dejima"
@test "the claude-code plugin mounts projects/ one slug at a time" {
# Moved out of bin/dejima into the harness plugin: which directories a
# harness keeps, and what decides an entry belongs to this workspace, is
# a fact about the harness. The property is unchanged - the tree is
# every project's transcripts and memory, and memory is read into
# context at session start (#104).
local p="$REPO_ROOT/bin/harness/claude-code.sh"
grep -q 'workspace_slugs_sorted "$ws"' "$p"
grep -q 'emit_mount "$projects/$slug"' "$p"
}
# Docker creates the parents of a mount point root-owned when they do not
@@ -510,10 +515,10 @@ EOF
}
}
@test "bin/dejima mounts ~/.claude/file-history one session at a time" {
grep -q 'workspace_session_ids "$HOME/.claude/projects" "$WORKSPACE_DIR"' "$REPO_ROOT/bin/dejima"
grep -q 'extra_mounts+=(-v "$HOME/.claude/file-history/$_sid:$HOME/.claude/file-history/$_sid")' \
"$REPO_ROOT/bin/dejima"
@test "the claude-code plugin mounts file-history one session at a time" {
local p="$REPO_ROOT/bin/harness/claude-code.sh"
grep -q 'workspace_session_ids "$projects" "$ws"' "$p"
grep -q 'emit_mount "$history/$sid"' "$p"
}
@test "the image pre-creates ~/.claude/file-history so the session mounts have a writable parent" {
@@ -694,8 +699,13 @@ EOF
# Which only takes effect if the flag is actually passed. It goes in the
# wrapper rather than the entrypoint so it also covers invocations the
# entrypoint never sees (an interactive shell, claudecode.nvim).
grep -qE -- '--settings /etc/dejima-settings\.json' "$SANDBOX_DIR/claude-wrapper.sh"
grep -qE -- '"\$\{settings\[@\]\}"' "$SANDBOX_DIR/claude-wrapper.sh"
# The flag itself is the harness's now, not the wrapper's: the wrapper
# is shared and a --settings it hardcoded would be handed to a harness
# that layers settings some other way.
grep -qE -- 'settings_args\s*=\s*--settings /etc/dejima-settings\.json' \
"$REPO_ROOT/harnesses/claude-code/harness.conf"
grep -q 'SANDBOX_HARNESS_SETTINGS' "$SANDBOX_DIR/harness-wrapper.sh"
grep -qE -- '\$\{settings\[@\]\+"\$\{settings\[@\]\}"\}' "$SANDBOX_DIR/harness-wrapper.sh"
}
@test "bin/dejima pre-creates the ~/.claude config files so neither is mounted as a directory" {
@@ -1418,15 +1428,46 @@ registered_commands() {
audit_line=$(grep -n 'dejima-audit" compare' "$REPO_ROOT/bin/dejima" | head -1 | cut -d: -f1)
trap_line=$(grep -n '^trap cleanup EXIT' "$REPO_ROOT/bin/dejima" | head -1 | cut -d: -f1)
marker_line=$(grep -n 'could not remove the marker directory' "$REPO_ROOT/bin/dejima" | head -1 | cut -d: -f1)
epitaph_line=$(grep -n 'dejima-epitaph" save' "$REPO_ROOT/bin/dejima" | head -1 | cut -d: -f1)
[ -n "$marker_line" ]
[ -n "$epitaph_line" ]
[ "$audit_line" -lt "$trap_line" ]
# Not just "inside cleanup()" but after the one line in it a session can
# deliberately provoke to print - the actual last-writer invariant.
[ "$marker_line" -lt "$audit_line" ]
# Same invariant, for the other task in the trap that prints: epitaph
# announces every summary it keeps, so it goes before the verdict.
[ "$epitaph_line" -lt "$audit_line" ]
run grep -c 'could not remove the marker directory' "$REPO_ROOT/bin/dejima"
[ "$output" -eq 1 ]
}
@test "cleanup says what it is doing before each task that takes a while" {
# A silent terminal between the container exiting and the prompt
# returning reads as a hung one, and the operator's answer to that is
# Ctrl-C - during the audit, which is the worst moment for it.
local d="$REPO_ROOT/bin/dejima" start s1 epi s2 aud
# Absolute line numbers, so each announcement is checked to sit with the
# task it names rather than merely somewhere in the same function. The
# definition of exit_step() itself is above cleanup() and so is skipped.
start=$(grep -n '^cleanup() {' "$d" | cut -d: -f1)
epi=$(grep -n 'dejima-epitaph" save' "$d" | head -1 | cut -d: -f1)
aud=$(grep -n 'dejima-audit" compare' "$d" | head -1 | cut -d: -f1)
s1=$(awk -v s="$start" 'NR>s && /exit_step /{print NR; exit}' "$d")
s2=$(awk -v s="$epi" 'NR>s && /exit_step /{print NR; exit}' "$d")
[ -n "$s1" ] && [ -n "$s2" ] || { echo "no announcement before a task"; return 1; }
[ "$s1" -lt "$epi" ] || { echo "epitaph runs unannounced"; return 1; }
[ "$s2" -lt "$aud" ] || { echo "the audit runs unannounced"; return 1; }
# Gated on the snapshot, the same condition `compare` uses to decide it
# has work: an exit before the session started is instant, and its last
# line is the error that caused it - which announcements would bury.
run sed -n '/^exit_step() {/,/^}/p' "$REPO_ROOT/bin/dejima"
[ -n "$output" ] || { echo "exit_step is not defined"; return 1; }
grep -q 'SANDBOX_AUDIT_STATE' <<< "$output"
# A nonzero status from anything in an EXIT trap replaces the script's.
grep -q 'return 0' <<< "$output"
}
@test "credential proxy is on by default, gated only by credentials.proxy" {
grep -q 'config_get_bool credentials proxy' "$REPO_ROOT/bin/dejima"
grep -Pq 'credentials\.proxy\tbool\ton' "$REPO_ROOT/bin/dejima-config-lib.sh"
@@ -1989,12 +2030,15 @@ EOF
grep -q 'not a verdict on who caused them' "$REPO_ROOT/README.md"
}
@test "docs/security.md documents the ~/.claude/projects cross-session false-positive case" {
# Presence-only grep, same caveat as above: confirms the specific
# ~/.claude/projects wording landed, not that the surrounding
# reasoning is sound.
grep -q 'CROSS-PROJECT. finding' "$REPO_ROOT/docs/security.md"
grep -q 'directory shared by' "$REPO_ROOT/docs/security.md"
# Was: that the cross-session false-positive case is documented. The finding
# itself is gone - a session can no longer write another project's slug, so
# every one it could still see belonged to some other session - and what
# needs documenting now is its absence, so that removing detection stays a
# stated decision rather than something a reader has to infer from silence.
@test "docs/security.md says why there is no cross-project finding" {
grep -q 'no longer a .CROSS-PROJECT. finding' "$REPO_ROOT/docs/security.md"
run grep -n 'CROSS-PROJECT history/memory write' "$REPO_ROOT/bin/dejima-audit"
[ "$status" -ne 0 ] || { echo "the finding is back but the doc says it is gone" >&2; false; }
}
# The CI disclosure is the one part of issue #72 that helps a reader whatever
@@ -2008,6 +2052,46 @@ EOF
done
}
@test "core never reads a harness's config by a literal harness name" {
# `keys` is declared per harness, so a fixed [harness.<name>] at a call
# site makes it a declaration only one harness can ever be read through:
# a second harness is selected, is validated, and is then handed the
# first one's paths with nothing erroring anywhere. A silent wrong
# answer, so it gets a test rather than a convention.
run grep -rn 'config_get["'"'"' ]*harness\.[a-z]' "$REPO_ROOT/bin"
[ "$status" -ne 0 ] || { echo "use harness_config_get instead:"; echo "$output"; return 1; }
grep -q '^harness_config_get()' "$REPO_ROOT/bin/dejima-provider-lib.sh"
}
@test "the harness guide states the bar a harness has to clear" {
# Support is per harness and deliberate, at the depth Claude Code has.
# That only holds if the requirements are written down: without them
# each new harness is argued about rather than measured, and the first
# one that cannot host a guard gets waved through.
local sec r
grep -q '^## Adding a new harness' "$REPO_ROOT/docs/development.md"
sec="$(sed -n '/^### What a harness must be able to do/,/^### What the manifest/p' \
"$REPO_ROOT/docs/development.md")"
[ -n "$sec" ] || { echo "the capability bar is gone"; return 1; }
for r in R1 R2 R3 R4 R5; do
grep -q "\*\*$r\*\*" <<< "$sec" || { echo "no $r in the bar"; return 1; }
done
# Unattended execution is the premise every other control is justified
# against, so it stays stated rather than assumed.
grep -qi 'without confirming each one' <<< "$sec"
}
@test "a harness dejima declines is declined against a stated requirement" {
# The reason has to point at a rule. Otherwise the bar is decoration and
# the next harness is decided on how much someone wants it.
local sec
sec="$(sed -n '/^### Worked example/,/^### What the manifest/p' \
"$REPO_ROOT/docs/development.md")"
[ -n "$sec" ] || { echo "no worked example of a refusal"; return 1; }
grep -q 'R2' <<< "$sec" \
|| { echo "the refusal names no requirement it failed"; return 1; }
}
@test "docs/ci.md exists and is linked from the README and docs/security.md" {
[ -f "$REPO_ROOT/docs/ci.md" ]
grep -q 'docs/ci\.md' "$REPO_ROOT/README.md"
@@ -2886,3 +2970,151 @@ q
run grep -n 'scope-flag) form\|scope-flag form' "$REPO_ROOT/README.md"
[ "$status" -ne 0 ]
}
# --- harness plugins: a plugin computes, core enforces ---
@test "no harness plugin touches the mount list directly" {
# A plugin that appended to extra_mounts itself would skip the
# guarded-store refusal emit_mount runs at the point of declaration -
# and would skip it differently in each harness, which is how one
# security model becomes one per harness. The plugins are trusted code,
# so this is held at review time rather than by isolating them.
run grep -rn 'extra_mounts' "$REPO_ROOT/bin/harness"
[ "$status" -ne 0 ] || { echo "a plugin bypasses emit_mount:"; echo "$output"; return 1; }
}
@test "a harness plugin defines only hooks core actually calls" {
# A function named something core never calls looks wired up and is
# dead. Worse, a near-miss of a real hook name (harness_state_mount for
# harness_state_mounts) reads as implemented and silently does nothing.
local hooks f fn
hooks="$(sed -n 's/^DEJIMA_HARNESS_HOOKS="\(.*\)"$/\1/p' \
"$REPO_ROOT/bin/dejima-harness-lib.sh")"
[ -n "$hooks" ] || { echo "the hook list is gone"; return 1; }
for f in "$REPO_ROOT"/bin/harness/*.sh; do
[ -f "$f" ] || continue
while IFS= read -r fn; do
[ -n "$fn" ] || continue
case " $hooks " in
*" $fn "*) ;;
*) echo "$(basename "$f") defines $fn, which core never calls"; return 1 ;;
esac
done < <(grep -oE '^[a-zA-Z_][a-zA-Z0-9_]*\(\)' "$f" | sed 's/()$//')
done
}
@test "every shipped harness has both a manifest and a plugin" {
# Support is per harness and deliberate. A manifest with no plugin is a
# half-finished integration that would present as a supported harness.
local d name
for d in "$REPO_ROOT"/harnesses/*/; do
[ -d "$d" ] || continue
name="$(basename "$d")"
[ -f "$d/harness.conf" ] || { echo "$name has no manifest"; return 1; }
[ -f "$REPO_ROOT/bin/harness/$name.sh" ] \
|| { echo "$name has a manifest but no plugin"; return 1; }
done
for d in "$REPO_ROOT"/bin/harness/*.sh; do
[ -f "$d" ] || continue
name="$(basename "$d" .sh)"
[ -f "$REPO_ROOT/harnesses/$name/harness.conf" ] \
|| { echo "$name has a plugin but no manifest"; return 1; }
done
}
@test "harness plugins are inside the trusted computing base the audit watches" {
# They run on the host, in the launch path. Under harnesses/ they would
# be host-executed code a session could edit in dejima's own checkout
# without raising a CRITICAL, because tcb_snapshot covers install.sh,
# bin/, hooks/, docker/ and test/ - and not the provider directories.
[ -d "$REPO_ROOT/bin/harness" ] || { echo "plugins are not under bin/"; return 1; }
run grep -rn 'harness_state_mounts\|harness_plugin_load' "$REPO_ROOT/harnesses"
[ "$status" -ne 0 ] || { echo "plugin code under harnesses/:"; echo "$output"; return 1; }
sed -n '/^tcb_snapshot()/,/^}/p' "$REPO_ROOT/bin/dejima-audit" | grep -q 'for dir in bin '
}
# --- the wrapper is the harness's, not Claude Code's ---
@test "the wrapper names no harness, and the image installs it under a neutral name" {
# It is shadowed into place under the harness's own command by
# entrypoint.sh, because the image is shared across harnesses and the
# selection is per launch. A COPY to a fixed name would bake one
# harness's command into every image.
local w="$SANDBOX_DIR/harness-wrapper.sh"
[ -f "$w" ] || { echo "the generic wrapper is gone"; return 1; }
run grep -niE 'claude|anthropic' "$w"
[ "$status" -ne 0 ] || { echo "the shared wrapper names a harness:"; echo "$output"; return 1; }
grep -q 'COPY docker/harness-wrapper.sh /usr/local/bin/dejima-harness-wrapper' \
"$SANDBOX_DIR/Dockerfile"
run grep -n 'COPY docker/claude-wrapper' "$SANDBOX_DIR/Dockerfile"
[ "$status" -ne 0 ] || { echo "the old fixed-name copy is back"; return 1; }
}
@test "the entrypoint refuses a harness command name that is not a bare name" {
# It becomes a path under /usr/local/bin. A value with a slash would
# write the symlink somewhere else entirely, and this one arrives from
# the host as an environment variable.
sed -n '/SANDBOX_HARNESS_COMMAND:-/,/^fi/p' "$SANDBOX_DIR/entrypoint.sh" \
| grep -q '\*/\*'
}
@test "the no-confirm flags come from the harness manifest" {
# R1 of the capability bar. The flag was hardcoded in the wrapper, which
# made unattended operation a property of the image rather than of the
# selected harness.
grep -qE -- 'no_confirm_args\s*=\s*--dangerously-skip-permissions' \
"$REPO_ROOT/harnesses/claude-code/harness.conf"
grep -q 'no_confirm_args' "$REPO_ROOT/bin/dejima"
# And a harness that names none is refused rather than run asking for
# confirmation on every tool call, which presents as a hang.
grep -q 'declares no no_confirm_args' "$REPO_ROOT/bin/dejima"
}
@test "core's schema holds no harness's command as a default" {
# [core] command defaulted to the literal `claude`, so core carried one
# harness's command name while the manifest's own `command` key was read
# by nothing.
run grep -nE '^core\.command\s+identifier\s+claude' "$REPO_ROOT/bin/dejima-config-lib.sh"
[ "$status" -ne 0 ] || { echo "core still defaults to a harness command"; return 1; }
grep -qE '^core\.command\s+\?identifier' "$REPO_ROOT/bin/dejima-config-lib.sh"
grep -q 'SANDBOX_CMD="$SANDBOX_HARNESS_COMMAND"' "$REPO_ROOT/bin/dejima"
}
# --- guards are declared, not assumed ---
@test "every shipped harness declares which guards it can host" {
# An absent `guards` reads as "hosts none", which for a harness that
# hosts all four would tell the operator at every launch that four
# protections are missing. Silence is not a valid declaration here.
local d name g known
known="$(sed -n 's/^DEJIMA_KNOWN_GUARDS="\(.*\)"$/\1/p' "$REPO_ROOT/bin/dejima-provider-lib.sh")"
[ -n "$known" ] || { echo "the known-guard set is gone"; return 1; }
for d in "$REPO_ROOT"/harnesses/*/; do
[ -f "$d/harness.conf" ] || continue
name="$(basename "$d")"
grep -qE '^\s*guards\s*=' "$d/harness.conf" \
|| { echo "$name declares no guards"; return 1; }
for g in $(sed -n 's/^\s*guards\s*=\s*//p' "$d/harness.conf"); do
case " $known " in
*" $g "*) ;;
*) echo "$name declares unknown guard: $g"; return 1 ;;
esac
done
done
}
@test "the guard set core knows lives with the validation that rejects a name" {
# It was in dejima-harness-lib.sh while provider_validate_selection read
# it from dejima-provider-lib.sh, so a caller loading only the latter -
# providers.bats does - validated against an empty set and rejected
# every guard there is.
grep -q '^DEJIMA_KNOWN_GUARDS=' "$REPO_ROOT/bin/dejima-provider-lib.sh"
run grep -n '^DEJIMA_KNOWN_GUARDS=' "$REPO_ROOT/bin/dejima-harness-lib.sh"
[ "$status" -ne 0 ] || { echo "the set is defined in two places"; return 1; }
}
@test "core gates guard work on the declaration, not on the plugin defining a hook" {
# A harness that forgot the declaration but implemented the hook would
# get a guard the launch message has just said it does not have.
grep -q 'harness_has_guard notify' "$REPO_ROOT/bin/dejima"
}