Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7274236a7c | ||
|
|
48bcd32c0e | ||
|
|
7695325c2e | ||
|
|
0707cfc1dd | ||
|
|
d49b9eaa08 | ||
|
|
821e15283a | ||
|
|
eedf855bc4 | ||
|
|
4df8ca1dfa | ||
|
|
0b8d1e5817 | ||
|
|
0ac60b7a59 | ||
|
|
34b0b1bec2 | ||
|
|
38045f87c8 | ||
|
|
d7d808425c | ||
|
|
5b8f6e71be | ||
|
|
bd0f5ddab6 | ||
|
|
54962bb165 | ||
|
|
1efcc42294 |
@@ -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
@@ -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
@@ -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
|
||||
;;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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[@]}" "$@"
|
||||
@@ -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
@@ -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} "$@"
|
||||
|
||||
Executable
+51
@@ -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[@]}"} "$@"
|
||||
@@ -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
@@ -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
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user