Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
011675ab8b | ||
|
|
7581ac0351 | ||
|
|
02bda50b4d | ||
|
|
7174d1147f | ||
|
|
7274236a7c | ||
|
|
48bcd32c0e | ||
|
|
7695325c2e | ||
|
|
0707cfc1dd | ||
|
|
d49b9eaa08 | ||
|
|
821e15283a | ||
|
|
eedf855bc4 | ||
|
|
4df8ca1dfa |
@@ -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
|
||||
|
||||
+77
-48
@@ -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,7 +756,6 @@ 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=""
|
||||
@@ -805,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
|
||||
@@ -912,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
|
||||
@@ -1082,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
|
||||
@@ -1351,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
|
||||
@@ -1382,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
|
||||
|
||||
+126
-49
@@ -14,6 +14,8 @@ source "$(dirname "$(readlink -f "$0")")/dejima-provider-lib.sh"
|
||||
source "$(dirname "$(readlink -f "$0")")/dejima-color-lib.sh"
|
||||
# shellcheck source=dejima-slug-lib.sh
|
||||
source "$(dirname "$(readlink -f "$0")")/dejima-slug-lib.sh"
|
||||
# shellcheck source=dejima-harness-lib.sh
|
||||
source "$(dirname "$(readlink -f "$0")")/dejima-harness-lib.sh"
|
||||
# Defined empty here so every interpolation below is safe before a surface
|
||||
# picks its stream; compare (stderr) and ack (stdout) each re-init for theirs.
|
||||
color_init 1
|
||||
@@ -21,17 +23,36 @@ 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/
|
||||
# output-styles all are) - settings.json's statusLine.command runs it on the
|
||||
# HOST, unsandboxed, and bin/dejima's own read-only mount onto it
|
||||
# doesn't reliably protect it when it's a symlink (the common case - runc
|
||||
# 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)"
|
||||
# The paths the selected harness causes to run ON THE HOST, unsandboxed,
|
||||
# from configuration a session can reach - Claude Code's statusline is the
|
||||
# instance that exists. Each is watched on its own rather than folded into
|
||||
# hash_paths, because bin/dejima's read-only mount does not reliably protect
|
||||
# one that is a symlink (the common case - runc resolves the mount
|
||||
# destination through the link; confirmed against a live container, see
|
||||
# test/runtime.bats), so detection here is the compensating control.
|
||||
#
|
||||
# Declared by the harness's plugin, not by its manifest: the labels below
|
||||
# are the fixed prefixes `ack` strips to recover an id, which makes them
|
||||
# part of a parser's grammar rather than data. See bin/dejima-harness-lib.sh.
|
||||
harness_plugin_load "$(config_get core harness)"
|
||||
host_exec_load
|
||||
|
||||
# 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 [ "${#DEJIMA_HOST_EXEC[@]}" -eq 0 ] && [ -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 " (whatever this harness 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
|
||||
@@ -369,11 +390,14 @@ withhold() { printf '%s\n' "$2" > "$1" 2>/dev/null || true; }
|
||||
# compare against.
|
||||
existed_before() {
|
||||
local rid="$1" range enc
|
||||
if host_exec_is_id "$rid"; then
|
||||
range='/^### hostexec/,$p'; enc="$(enc_id "${rid#*:}")"
|
||||
else
|
||||
case "$rid" in
|
||||
statusline:*) range='/^### statusline/,$p'; enc="$(enc_id "${rid#statusline:}")" ;;
|
||||
tcb:*) range='/^### tcb/,/^### statusline/p'; enc="$(enc_id "${rid#tcb:}")" ;;
|
||||
tcb:*) range='/^### tcb/,/^### hostexec/p'; enc="$(enc_id "${rid#tcb:}")" ;;
|
||||
*) range='/^### paths/,/^### projects/p'; enc="$(enc_id "$rid")" ;;
|
||||
esac
|
||||
fi
|
||||
sed -n "$range" "$state" | grep -v '^### ' | cut -f1 | grep -qxF -- "$enc"
|
||||
}
|
||||
|
||||
@@ -518,11 +542,15 @@ promote_finding() {
|
||||
# Mirrors current_content_for_id's dispatch; gitconfig ids never reach
|
||||
# here (promote_gitconfig returned above), and stat does not follow a
|
||||
# symlink, whose rendering is its target string rather than its content.
|
||||
# A host-execution surface's path comes from the harness plugin that
|
||||
# declared it, not from the workspace.
|
||||
local _hp
|
||||
if _hp="$(host_exec_path_for_id "$rid")"; then src="$_hp"; else
|
||||
case "$rid" in
|
||||
statusline:*) src="$STATUSLINE_PATH" ;;
|
||||
tcb:*) src="$ws/${rid#tcb:}" ;;
|
||||
*) src="$ws/$rid" ;;
|
||||
esac
|
||||
fi
|
||||
csz="$(stat -c %s "$src" 2>/dev/null)" || csz=0
|
||||
if [ "$csz" -gt "$STAGE_FILE_CAP" ]; then
|
||||
withhold "$sc/$key.withheld" "change too large to retain - compare by hand"
|
||||
@@ -661,7 +689,7 @@ id_severity() {
|
||||
gitconfig:*) k="${1#gitconfig:}"; k="${k#worktree:}"
|
||||
is_exec_key "$k" && { echo CRITICAL; return; }
|
||||
case "$k" in alias.*) echo CRITICAL ;; *) echo WARNING ;; esac ;;
|
||||
statusline:*|tcb:*) echo CRITICAL ;;
|
||||
tcb:*) echo CRITICAL ;;
|
||||
*) path_severity "$1" ;;
|
||||
esac
|
||||
}
|
||||
@@ -897,13 +925,14 @@ gitconfig_sha_for_key() {
|
||||
# that namespace, so the id/sha pair ack records is exactly what
|
||||
# baseline_suppresses will later compare against.
|
||||
current_sha_for_id() {
|
||||
local id="$1" f
|
||||
local id="$1" f _hp
|
||||
if _hp="$(host_exec_path_for_id "$id")"; then f="$_hp"; else
|
||||
case "$id" in
|
||||
gitconfig:*) gitconfig_sha_for_key "$ws" "${id#gitconfig:}"; return ;;
|
||||
statusline:*) f="$STATUSLINE_PATH" ;;
|
||||
tcb:*) f="$ws/${id#tcb:}" ;;
|
||||
*) f="$ws/$id" ;;
|
||||
esac
|
||||
fi
|
||||
# -e alone follows a symlink and misses one whose target is gone - but a
|
||||
# dangling symlink is exactly what hash_entry() below can still hash (the
|
||||
# link's target string, never the target's content), so excluding it here
|
||||
@@ -924,13 +953,14 @@ DISPLAY_LINE_CAP=40
|
||||
# as their target, because that is what hash_entry() hashes for them - the
|
||||
# link's own content is not what changed.
|
||||
current_content_for_id() {
|
||||
local id="$1" f
|
||||
local id="$1" f _hp
|
||||
if _hp="$(host_exec_path_for_id "$id")"; then f="$_hp"; else
|
||||
case "$id" in
|
||||
gitconfig:*) gitconfig_value_for_key "$ws" "${id#gitconfig:}" ;;
|
||||
statusline:*) f="$STATUSLINE_PATH" ;;
|
||||
tcb:*) f="$ws/${id#tcb:}" ;;
|
||||
*) f="$ws/$id" ;;
|
||||
esac
|
||||
fi
|
||||
case "$id" in gitconfig:*) return ;; esac
|
||||
[ -e "$f" ] || [ -L "$f" ] || return 1
|
||||
if [ -L "$f" ]; then
|
||||
@@ -999,10 +1029,13 @@ show_capped() {
|
||||
# Never sanitized through a path that would make it unfindable: it is the
|
||||
# audit's OWN text about a location, not workspace-controlled content.
|
||||
source_hint() {
|
||||
local id="$1"
|
||||
local id="$1" _hp
|
||||
if _hp="$(host_exec_path_for_id "$id")"; then
|
||||
printf '%s\n' " (full text: $(sanitize "$_hp"))"
|
||||
return
|
||||
fi
|
||||
case "$id" in
|
||||
gitconfig:*) printf '%s\n' " (full value: git -C $ws config --get $(sanitize "${id#gitconfig:}"))" ;;
|
||||
statusline:*) printf '%s\n' " (full text: $STATUSLINE_PATH)" ;;
|
||||
tcb:*) printf '%s\n' " (full text: $ws/$(sanitize "${id#tcb:}"))" ;;
|
||||
*) printf '%s\n' " (full text: $ws/$(sanitize "$id"))" ;;
|
||||
esac
|
||||
@@ -1050,20 +1083,33 @@ render_finding() {
|
||||
return 0
|
||||
}
|
||||
|
||||
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
|
||||
# monitored on its own rather than as part of hash_paths.
|
||||
local f="$1"
|
||||
[ -e "$f" ] || return 0
|
||||
stage_entry "statusline:statusline.sh" "$f"
|
||||
printf 'statusline.sh\t%s\n' "$(hash_entry "$f")"
|
||||
host_exec_snapshot() {
|
||||
# leaf<TAB>sha for every host-execution surface the harness declared, or
|
||||
# nothing where the host has none. One line per surface, keyed by the
|
||||
# leaf so the id a baseline stores ("<namespace>:<leaf>") is unchanged
|
||||
# for a harness already in use.
|
||||
local rec ns leaf path label
|
||||
for rec in ${DEJIMA_HOST_EXEC[@]+"${DEJIMA_HOST_EXEC[@]}"}; do
|
||||
IFS=$'\t' read -r ns leaf path label <<< "$rec"
|
||||
[ -n "$path" ] && [ -e "$path" ] || continue
|
||||
stage_entry "$ns:$leaf" "$path"
|
||||
printf '%s\t%s\n' "$leaf" "$(hash_entry "$path")"
|
||||
done
|
||||
}
|
||||
|
||||
case "$cmd" in
|
||||
--print-paths)
|
||||
printf 'projects_dir=%s\nstatusline_path=%s\naudit_dir=%s\nbaselines_dir=%s\nkeep=%s\n' \
|
||||
"$PROJECTS_DIR" "$STATUSLINE_PATH" "$AUDIT_DIR" "$BASELINE_DIR" "$AUDIT_KEEP"
|
||||
# statusline_path is still named, because it is the surface this
|
||||
# harness declares and existing callers read it by that name. It
|
||||
# comes from the declaration now rather than from a global of its
|
||||
# own, so a harness declaring none prints an empty value and one
|
||||
# declaring several prints a line each.
|
||||
printf 'projects_dir=%s\naudit_dir=%s\nbaselines_dir=%s\nkeep=%s\n' \
|
||||
"$PROJECTS_DIR" "$AUDIT_DIR" "$BASELINE_DIR" "$AUDIT_KEEP"
|
||||
for _rec in ${DEJIMA_HOST_EXEC[@]+"${DEJIMA_HOST_EXEC[@]}"}; do
|
||||
IFS=$'\t' read -r _ns _leaf _path _label <<< "$_rec"
|
||||
printf '%s_path=%s\n' "$_ns" "$_path"
|
||||
done
|
||||
exit 0
|
||||
;;
|
||||
snapshot)
|
||||
@@ -1081,7 +1127,7 @@ case "$cmd" in
|
||||
# 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
|
||||
echo "### hostexec"; host_exec_snapshot; } > "$state" 2>/dev/null || true
|
||||
exit 0
|
||||
;;
|
||||
compare)
|
||||
@@ -1120,7 +1166,7 @@ case "$cmd" in
|
||||
echo "### paths"; hash_paths "$ws";
|
||||
echo "### projects";
|
||||
echo "### tcb"; tcb_snapshot "$ws";
|
||||
echo "### statusline"; statusline_snapshot "$STATUSLINE_PATH"; } > "$new" 2>/dev/null || {
|
||||
echo "### hostexec"; host_exec_snapshot; } > "$new" 2>/dev/null || {
|
||||
echo "dejima audit: could not complete, skipping." >&2; rm -f "$new"; exit 0; }
|
||||
|
||||
# One raw namespaced id per finding whose surface has retainable
|
||||
@@ -1225,7 +1271,7 @@ case "$cmd" in
|
||||
# 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
|
||||
# Bounded at ### hostexec (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.
|
||||
@@ -1252,25 +1298,39 @@ case "$cmd" in
|
||||
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 '^### ') \
|
||||
done < <(comm -3 <(sed -n '/^### tcb/,/^### hostexec/p' "$state" | grep -v '^### ') \
|
||||
<(sed -n '/^### tcb/,/^### hostexec/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
|
||||
# section instead of folding into hash_paths. Worded at least as
|
||||
# strongly as COMMAND-EXECUTION/TCB above: this is unsandboxed host
|
||||
# code execution, not just a config change.
|
||||
# host-execution diff: a path the harness runs on the HOST added,
|
||||
# changed, removed or repointed. Its own section rather than folded
|
||||
# into hash_paths for the reason given where the surfaces are loaded,
|
||||
# and worded at least as strongly as COMMAND-EXECUTION/TCB above:
|
||||
# this is unsandboxed host code execution, not just a config change.
|
||||
#
|
||||
# The label is the plugin's, because it is the fixed prefix `ack`
|
||||
# strips to recover the id and so belongs in trusted code rather than
|
||||
# in manifest data. A leaf with no declaring surface cannot happen
|
||||
# from a snapshot this run wrote, but it can from a state file
|
||||
# written before the harness changed - such a line is skipped rather
|
||||
# than reported under a label nothing owns.
|
||||
while IFS= read -r line; do
|
||||
sid="statusline:$line"; ssha=""; [ -e "$STATUSLINE_PATH" ] && ssha="$(hash_entry "$STATUSLINE_PATH")"
|
||||
_hl=""; _hns=""; _hpath=""
|
||||
for _rec in ${DEJIMA_HOST_EXEC[@]+"${DEJIMA_HOST_EXEC[@]}"}; do
|
||||
IFS=$'\t' read -r _ns _leaf _path _label <<< "$_rec"
|
||||
[ "$_leaf" = "$line" ] || continue
|
||||
_hns="$_ns"; _hpath="$_path"; _hl="$_label"; break
|
||||
done
|
||||
[ -n "$_hl" ] || continue
|
||||
sid="$_hns:$line"; ssha=""; [ -e "$_hpath" ] && ssha="$(hash_entry "$_hpath")"
|
||||
baseline_suppresses "$sid" "$ssha" && continue
|
||||
crit+=("HOST-CODE-EXECUTION statusline.sh changed (runs UNSANDBOXED on the host via settings.json statusLine.command): $(enc_id "$line")")
|
||||
crit+=("$_hl$(enc_id "$line")")
|
||||
finding_ids+=("$sid")
|
||||
done < <(comm -3 <(sed -n '/^### statusline/,$p' "$state" | grep -v '^### ') \
|
||||
<(sed -n '/^### statusline/,$p' "$new" | grep -v '^### ') \
|
||||
done < <(comm -3 <(sed -n '/^### hostexec/,$p' "$state" | grep -v '^### ') \
|
||||
<(sed -n '/^### hostexec/,$p' "$new" | grep -v '^### ') \
|
||||
| sed 's/^\t//' | cut -f1 | sort -u )
|
||||
|
||||
rm -f "$new"
|
||||
@@ -1544,6 +1604,27 @@ case "$cmd" in
|
||||
# from $id because a gitconfig body carries `key=value` while its
|
||||
# id is the key alone.
|
||||
ackable=0; id=""; rid=""; lbl=""; surface=""
|
||||
# Host-execution surfaces first, because their labels come from
|
||||
# the harness plugin rather than from a literal here. Matched by
|
||||
# an exact prefix strip - "$body" with the label removed differs
|
||||
# from "$body" only when the label really is at position 0 -
|
||||
# rather than by a `case` pattern, which would treat a label's
|
||||
# own characters as globs. Anchoring is the whole point (#45):
|
||||
# an unanchored match lets chosen text derive an id in the wrong
|
||||
# namespace, and approving it baselines an id that never matches
|
||||
# while the real finding counts as acknowledged.
|
||||
for _rec in ${DEJIMA_HOST_EXEC[@]+"${DEJIMA_HOST_EXEC[@]}"}; do
|
||||
IFS=$'\t' read -r _ns _leaf _path _label <<< "$_rec"
|
||||
_rest="${body#"$_label"}"
|
||||
[ "$_rest" != "$body" ] || continue
|
||||
ackable=1; lbl="$_label"; surface="$_rest"
|
||||
id="$_ns:$_rest"; rid="$_ns:$(dec_id "$_rest")"
|
||||
break
|
||||
done
|
||||
# Only if none of them claimed it: a host-exec label that
|
||||
# matched has already derived the id, and the arms below must
|
||||
# not get a second go at the same text.
|
||||
if [ -z "$id" ]; then
|
||||
case "$body" in
|
||||
"HOST-CODE-EXECUTION workspace file: "*)
|
||||
ackable=1; lbl="HOST-CODE-EXECUTION workspace file: "
|
||||
@@ -1571,11 +1652,6 @@ case "$cmd" in
|
||||
cfgline="${body#changed git config: }"
|
||||
ekey="${cfgline%%=*}"; surface="$ekey"
|
||||
id="gitconfig:$ekey"; rid="gitconfig:$(dec_id "$ekey")" ;;
|
||||
"HOST-CODE-EXECUTION statusline.sh changed (runs UNSANDBOXED on the host via settings.json statusLine.command): "*)
|
||||
ackable=1; lbl="HOST-CODE-EXECUTION statusline.sh changed (runs UNSANDBOXED on the host via settings.json statusLine.command): "
|
||||
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, INVISIBLE to git: "*)
|
||||
ackable=1; lbl="TCB (sandbox self) modified, INVISIBLE to git: "
|
||||
etcb="${body#TCB (sandbox self) modified, INVISIBLE to git: }"
|
||||
@@ -1583,6 +1659,7 @@ case "$cmd" in
|
||||
id="tcb:$etcb"; rid="tcb:$(dec_id "$etcb")" ;;
|
||||
*) ackable=0 ;;
|
||||
esac
|
||||
fi
|
||||
# Defence in depth: even an anchored arm above should never
|
||||
# leave id empty while claiming ackable, but don't trust that -
|
||||
# crit_acked (and thus whether a report can shed -CRITICAL.txt)
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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)"
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
# Shared library: loading a harness plugin, and the interface it declares
|
||||
# through. Sourced by bin/dejima. Not on PATH and not executable.
|
||||
#
|
||||
# Three tiers, and which one a thing belongs in is the whole design:
|
||||
#
|
||||
# core - the mechanism, and every enforcement point.
|
||||
# manifest - data. harnesses/<name>/harness.conf. Never sourced, never
|
||||
# executed. Selects among named core mechanisms, supplies paths.
|
||||
# plugin - bin/harness/<name>.sh. The harness-specific remainder, as
|
||||
# code, in this repository, supported one harness at a time.
|
||||
#
|
||||
# A feature belongs in the manifest if it can be stated as a value. It moves
|
||||
# here only when it cannot: opencode resolving its state directory through a
|
||||
# database, Claude Code pairing file-history to transcripts by session id.
|
||||
# "Add a manifest key" is the answer until it demonstrably is not.
|
||||
#
|
||||
# Plugins live under bin/ rather than beside their manifest because
|
||||
# dejima-audit's tcb_snapshot covers install.sh, bin/, hooks/, docker/ and
|
||||
# test/ - and not harnesses/. Host-side plugin code under harnesses/ would be
|
||||
# code that runs on the host and that a session could edit in dejima's own
|
||||
# checkout without raising a CRITICAL, which is the exact class the audit
|
||||
# exists to catch. Under bin/ it is covered for free, and "manifests are
|
||||
# data, never executed" (docs/security.md) stays true word for word.
|
||||
#
|
||||
# Plugins are SOURCED, not invoked. They are part of the trusted computing
|
||||
# base, so the risk worth designing against is not a careless plugin - it is
|
||||
# three plugins implementing the security model three different ways.
|
||||
# Sourcing lets a plugin use core's own primitives (_refuse_guarded_mount,
|
||||
# sanitize, ws_slug, harness_config_get) rather than re-deriving them, which
|
||||
# is what keeps one security model instead of one per harness. An invoked
|
||||
# protocol would also put a new text parser on the launch path, and every
|
||||
# parsing bug this repository has had is in that family.
|
||||
#
|
||||
# The rule that replaces isolation: A PLUGIN COMPUTES, CORE ENFORCES.
|
||||
# A plugin declares through the emit_* functions below, which validate at the
|
||||
# point of declaration. It never touches extra_mounts itself - a static test
|
||||
# holds that, because a plugin that appended directly would skip the guarded-
|
||||
# store refusal, and would skip it differently in each harness.
|
||||
|
||||
# The hooks core will call. A plugin defines the ones its harness needs and
|
||||
# omits the rest; anything else it defines is a name core does not call, and
|
||||
# the static test refuses it rather than letting it look wired up.
|
||||
DEJIMA_HARNESS_HOOKS="harness_state_mounts harness_register_notify harness_register_guards harness_host_exec_surfaces"
|
||||
|
||||
# What an operator loses when a harness cannot host a given guard. The set
|
||||
# itself is DEJIMA_KNOWN_GUARDS in dejima-provider-lib.sh, next to the
|
||||
# manifest schema and the validation that rejects a name outside it: a
|
||||
# constant the validation depends on must live where the validation does, or
|
||||
# a caller that loads only provider-lib validates against an empty set and
|
||||
# rejects every guard there is.
|
||||
_guard_description() {
|
||||
case "$1" in
|
||||
bash-deny) printf 'obviously destructive shell commands are not refused' ;;
|
||||
push-guard) printf 'pushes to hosts and branches outside the allowed set are not refused' ;;
|
||||
merge-guard) printf 'the agent can merge its own pull requests' ;;
|
||||
notify) printf 'remote access gets no waiting/done notifications' ;;
|
||||
*) printf 'unknown guard' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Whether the selected harness declared a given guard. Core gates the work
|
||||
# on this rather than on whether the plugin happens to define a hook: a
|
||||
# harness that forgot the declaration but implemented the hook would get a
|
||||
# guard the launch message has just told the operator it does not have.
|
||||
harness_has_guard() {
|
||||
case " $(manifest_get harness "$DEJIMA_HARNESS_NAME" guards) " in
|
||||
*" $1 "*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Says which guards the selected harness cannot host. Silent when it hosts
|
||||
# them all, which is the common case and must not become noise an operator
|
||||
# learns to scroll past - the point is that the line only appears when
|
||||
# something really is missing.
|
||||
harness_report_guards() {
|
||||
local harness="$1" declared guard missing=()
|
||||
declared="$(manifest_get harness "$harness" guards)"
|
||||
for guard in $DEJIMA_KNOWN_GUARDS; do
|
||||
case " $declared " in
|
||||
*" $guard "*) ;;
|
||||
*) missing+=("$guard") ;;
|
||||
esac
|
||||
done
|
||||
[ "${#missing[@]}" -gt 0 ] || return 0
|
||||
printf ' %sharness %s does not support %s of dejima'"'"'s guards:%s\n' \
|
||||
"$C_WARN" "$harness" "${#missing[@]}" "$C_RESET" >&2
|
||||
for guard in "${missing[@]}"; do
|
||||
printf ' %s%s%s - %s\n' "$C_KEY" "$guard" "$C_RESET" "$(_guard_description "$guard")" >&2
|
||||
done
|
||||
printf ' The container still bounds this session; these are the fast-fail\n' >&2
|
||||
printf ' guards it does not get. See docs/development.md.\n' >&2
|
||||
}
|
||||
|
||||
# Sources the selected harness's plugin. Hard error when it is missing:
|
||||
# support is per harness and deliberate (see "Adding a new harness" in
|
||||
# docs/development.md), so a harness with a manifest and no plugin is a
|
||||
# half-finished integration rather than a lightweight one.
|
||||
harness_plugin_load() {
|
||||
local name="$1" plugin
|
||||
[ -n "$name" ] || return 0
|
||||
# Resolved from this file's own location, not from a caller's SCRIPT_DIR:
|
||||
# install.sh sets that to the repo root while bin/dejima sets it to bin/,
|
||||
# and a library that only works for one of its callers is a trap for the
|
||||
# next one.
|
||||
plugin="$(cd "${BASH_SOURCE[0]%/*}" && pwd)/harness/$name.sh"
|
||||
if [ ! -f "$plugin" ]; then
|
||||
echo "No harness plugin for '$name' at $plugin." >&2
|
||||
echo "A supported harness has both a manifest and a plugin; see" >&2
|
||||
echo "\"Adding a new harness\" in docs/development.md." >&2
|
||||
exit 1
|
||||
fi
|
||||
# shellcheck disable=SC1090
|
||||
source "$plugin"
|
||||
}
|
||||
|
||||
# Calls one hook of the loaded plugin, if it defines it. Absence is normal:
|
||||
# not every harness needs every hook, and the manifest is where a harness
|
||||
# says what it cannot do at all.
|
||||
harness_hook() {
|
||||
local hook="$1"; shift
|
||||
declare -F "$hook" >/dev/null 2>&1 || return 0
|
||||
"$hook" "$@"
|
||||
}
|
||||
|
||||
# --- what a plugin declares through --------------------------------------
|
||||
#
|
||||
# Each of these validates and then appends to core's own state. A plugin
|
||||
# calls them; it does not reach past them.
|
||||
|
||||
# A host path mounted into the container at the same path. $2 is the mode
|
||||
# ("ro", or empty for read-write).
|
||||
#
|
||||
# The guarded-store refusal runs here, at the point of declaration, so it
|
||||
# cannot be skipped by a plugin that forgot it: a harness state directory
|
||||
# that overlapped the approval or audit or epitaph store would otherwise
|
||||
# hand a session exactly what those stores exist to withhold, and a harness
|
||||
# is precisely the kind of thing whose paths are computed rather than
|
||||
# written down.
|
||||
emit_mount() {
|
||||
local path="$1" mode="${2:-}"
|
||||
[ -n "$path" ] || return 0
|
||||
case "$path" in
|
||||
*:*)
|
||||
echo "Harness plugin emitted a mount path containing a colon: $path" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
_guarded_stores
|
||||
_refuse_guarded_mount "harness state mount" "$path" \
|
||||
${GUARDED_STORES[@]+"${GUARDED_STORES[@]}"}
|
||||
if [ -n "$mode" ]; then
|
||||
extra_mounts+=(-v "$path:$path:$mode")
|
||||
else
|
||||
extra_mounts+=(-v "$path:$path")
|
||||
fi
|
||||
}
|
||||
|
||||
# An environment variable for the container. Name-checked because it becomes
|
||||
# a `-e NAME=value` argument, and a name carrying a space or an `=` would
|
||||
# split into something else entirely.
|
||||
emit_env() {
|
||||
local name="$1" value="${2:-}"
|
||||
case "$name" in
|
||||
''|*[!A-Za-z0-9_]*)
|
||||
echo "Harness plugin emitted an invalid environment variable name: $name" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
extra_mounts+=(-e "$name=$value")
|
||||
}
|
||||
|
||||
# --- host-execution surfaces ---------------------------------------------
|
||||
#
|
||||
# Paths a harness causes to run ON THE HOST, unsandboxed, from configuration
|
||||
# a session can reach. Claude Code's statusline is the instance that exists:
|
||||
# settings.json's statusLine.command names a script the host runs, so a
|
||||
# session that can write that file has a way onto the host that no container
|
||||
# boundary sees.
|
||||
#
|
||||
# Declared by the plugin rather than by the manifest, and the reason is a
|
||||
# trust boundary rather than convenience. dejima-audit's `ack` derives a
|
||||
# finding's id by stripping a FIXED label from the front of the finding text
|
||||
# - anchored deliberately, because an unanchored match lets chosen text
|
||||
# derive an id in the wrong namespace and baseline an approval that never
|
||||
# matches while the real finding is counted as acknowledged (#45). Those
|
||||
# labels are therefore part of a parser's grammar. Manifests are data, and
|
||||
# provider directories are NOT in tcb_snapshot's list, so in the self-mount
|
||||
# case a session can edit one without raising a CRITICAL. A plugin is under
|
||||
# bin/, which the audit does watch. The label stays a literal in trusted
|
||||
# code; it just lives with the harness that owns it.
|
||||
#
|
||||
# Each surface carries its own namespace, so ids stay stable per harness and
|
||||
# no rename is forced on anyone's baseline.
|
||||
DEJIMA_HOST_EXEC=()
|
||||
|
||||
# namespace, leaf, path, label. The id is "<namespace>:<leaf>", which is what
|
||||
# a baseline stores, so neither may change for a harness already in use.
|
||||
emit_host_exec() {
|
||||
local ns="$1" leaf="$2" path="$3" label="$4"
|
||||
[ -n "$path" ] || return 0
|
||||
case "$ns" in
|
||||
''|*[!a-z0-9-]*)
|
||||
echo "Harness plugin emitted an invalid host-exec namespace: $ns" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
# The leaf lands in an id, which is compared against baseline entries and
|
||||
# written into a report; a tab or newline in either would split a record.
|
||||
case "$leaf$label" in
|
||||
*$'\t'*|*$'\n'*)
|
||||
echo "Harness plugin emitted a host-exec leaf or label containing a tab or newline" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
[ -n "$leaf" ] && [ -n "$label" ] || {
|
||||
echo "Harness plugin emitted a host-exec surface with no leaf or label" >&2
|
||||
exit 1
|
||||
}
|
||||
DEJIMA_HOST_EXEC+=("$ns"$'\t'"$leaf"$'\t'"$path"$'\t'"$label")
|
||||
}
|
||||
|
||||
# Asks the loaded plugin for its surfaces. Empty when no harness is selected
|
||||
# or the harness has none, which is a normal state rather than an error.
|
||||
host_exec_load() {
|
||||
DEJIMA_HOST_EXEC=()
|
||||
harness_hook harness_host_exec_surfaces
|
||||
}
|
||||
|
||||
# The host path behind an id, or nothing if that id is not a host-exec one.
|
||||
host_exec_path_for_id() {
|
||||
local id="$1" rec ns leaf path label
|
||||
for rec in ${DEJIMA_HOST_EXEC[@]+"${DEJIMA_HOST_EXEC[@]}"}; do
|
||||
IFS=$'\t' read -r ns leaf path label <<< "$rec"
|
||||
[ "$id" = "$ns:$leaf" ] || continue
|
||||
printf '%s' "$path"
|
||||
return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Whether an id names a host-execution surface. Used where severity is
|
||||
# decided: every one of these is CRITICAL, because the whole category is
|
||||
# "runs on the host, outside everything".
|
||||
host_exec_is_id() { host_exec_path_for_id "$1" >/dev/null; }
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
# Harness plugin: Claude Code.
|
||||
#
|
||||
# Sourced by bin/dejima through harness_plugin_load when [core] harness names
|
||||
# this harness. Part of the trusted computing base - see the interface notes
|
||||
# in bin/dejima-harness-lib.sh for why plugins live here and why they are
|
||||
# sourced rather than invoked.
|
||||
#
|
||||
# What is here is what could not be stated as a manifest value. The paths
|
||||
# themselves come from the manifest ([harness.claude-code] projects_dir); the
|
||||
# reason this is code is the pairing in the second block, which resolves one
|
||||
# directory tree through the filenames of another.
|
||||
|
||||
# Registers the waiting/done notification handlers into dejima's settings
|
||||
# layer, in the shape Claude Code reads them.
|
||||
#
|
||||
# Claude Code merges hook entries across settings layers rather than letting
|
||||
# the higher one replace the lower, so these are added to whatever the
|
||||
# operator already has rather than displacing it. $HOME is left unexpanded
|
||||
# for Claude Code to resolve at hook-run time - inside the container, not
|
||||
# here - matching the convention install.sh already uses.
|
||||
#
|
||||
# Written through a temp file and renamed: the destination is the settings
|
||||
# layer bin/dejima is about to mount, and a jq that failed partway would
|
||||
# otherwise leave the session with a truncated one.
|
||||
harness_register_notify() {
|
||||
local settings="$1" tmp
|
||||
tmp="$(mktemp "${TMPDIR:-/tmp}/dejima-settings-remote.XXXXXX.json")"
|
||||
if jq '.hooks.Notification = ((.hooks.Notification // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/dejima-notify.sh waiting"}]}])
|
||||
| .hooks.Stop = ((.hooks.Stop // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/dejima-notify.sh done"}]}])' \
|
||||
"$settings" > "$tmp"; then
|
||||
mv "$tmp" "$settings"
|
||||
else
|
||||
rm -f "$tmp"
|
||||
echo "warning: could not register the notification hooks; remote access" >&2
|
||||
echo "will not be told when this session wants attention." >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# The per-project and per-session state this harness keeps on the host, and
|
||||
# which parts of it this workspace is entitled to see.
|
||||
harness_state_mounts() {
|
||||
local ws="$1" projects slug sid
|
||||
projects="$(harness_config_get projects_dir)"
|
||||
[ -n "$projects" ] || return 0
|
||||
|
||||
# projects/, one slug at a time: the tree is every project's transcripts
|
||||
# and memory, and memory is loaded into context at session start, so a
|
||||
# write into another slug is background a future session there believes
|
||||
# (#104). projects/ itself stays container-local, so an unmounted slug is
|
||||
# still writable and just does not survive - which is how Claude Code
|
||||
# creates a slug it does not find. Existing slugs only, apart from the
|
||||
# workspace's own, so a checkout full of worktrees does not litter the
|
||||
# host with empty directories for worktrees nothing has run in.
|
||||
mkdir -p "$projects/$(ws_slug "$ws")"
|
||||
while IFS= read -r slug; do
|
||||
[ -n "$slug" ] || continue
|
||||
[ -d "$projects/$slug" ] || continue
|
||||
emit_mount "$projects/$slug"
|
||||
done < <(workspace_slugs_sorted "$ws")
|
||||
|
||||
# file-history, same shape, sharper reason: `<session>/<hash>@v1` is a
|
||||
# verbatim copy of an edited file, so the tree is other projects' source
|
||||
# rather than a record of it, and it was writable - tampering there puts
|
||||
# chosen content into a later restore. Keyed by session id, so there is
|
||||
# no slug to match on; workspace_session_ids resolves it through the
|
||||
# transcript names. The starting session's id cannot be known yet, so its
|
||||
# directory is container-local: undo history for it is lost if it is
|
||||
# resumed on the host.
|
||||
#
|
||||
# The pairing is why the plugin is code, and the split is the design in
|
||||
# miniature: both directories are statable, so both are manifest keys.
|
||||
# What cannot be stated is the rule that a name in one selects an entry
|
||||
# in the other, so that is here.
|
||||
local history
|
||||
history="$(harness_config_get file_history_dir)"
|
||||
[ -n "$history" ] || return 0
|
||||
mkdir -p "$history"
|
||||
while IFS= read -r sid; do
|
||||
[ -n "$sid" ] || continue
|
||||
[ -d "$history/$sid" ] || continue
|
||||
emit_mount "$history/$sid"
|
||||
done < <(workspace_session_ids "$projects" "$ws")
|
||||
}
|
||||
|
||||
# Puts dejima's guard hooks where Claude Code will find them: symlinked into
|
||||
# ~/.claude/hooks, and registered in ~/.claude/settings.json. Run by
|
||||
# install.sh, not at launch - this writes the operator's own config, and the
|
||||
# operator is who decides to install.
|
||||
#
|
||||
# $1 is the repo root, $2 the directory to move a displaced file into. Every
|
||||
# change is printed as it happens, because it is someone's real config.
|
||||
#
|
||||
# Each guard is gated on the harness having declared it: a harness declares
|
||||
# what it can host (see `guards` in its manifest), and registering something
|
||||
# it did not declare would contradict the launch message that says so.
|
||||
harness_register_guards() {
|
||||
local repo="$1" backup="$2" settings="$HOME/.claude/settings.json"
|
||||
|
||||
mkdir -p "$HOME/.claude/hooks"
|
||||
|
||||
_link_hook() {
|
||||
local name="$1" src="$repo/hooks/$1" dest="$HOME/.claude/hooks/$1"
|
||||
if [ -L "$dest" ] && [ "$(readlink -f "$dest")" = "$(readlink -f "$src")" ]; then
|
||||
echo "Skipping: $name already linked to this repo's copy"
|
||||
return
|
||||
fi
|
||||
if [ -e "$dest" ] || [ -L "$dest" ]; then
|
||||
mkdir -p "$backup"
|
||||
echo "Moving: existing ~/.claude/hooks/$name to $backup/$name"
|
||||
mv "$dest" "$backup/$name"
|
||||
fi
|
||||
echo "Linking: $name ($src -> $dest)"
|
||||
ln -s "$src" "$dest"
|
||||
}
|
||||
|
||||
# Not a guard: this is R3, the briefing's delivery mechanism, and a
|
||||
# session without it does not know it is in a sandbox.
|
||||
_link_hook sandbox-context.sh
|
||||
harness_has_guard push-guard && _link_hook git-push-guard.sh
|
||||
harness_has_guard merge-guard && _link_hook merge-guard.sh
|
||||
|
||||
# Symlink from before the gitea-merge-guard.sh -> merge-guard.sh rename
|
||||
# points at a hooks/ path that no longer exists. Only remove it if it is
|
||||
# actually dangling, so an operator's own file at this name is left alone.
|
||||
local old_merge_guard="$HOME/.claude/hooks/gitea-merge-guard.sh"
|
||||
if [ -L "$old_merge_guard" ] && [ ! -e "$old_merge_guard" ]; then
|
||||
echo "Removing: dangling pre-rename symlink $old_merge_guard"
|
||||
rm -f "$old_merge_guard"
|
||||
fi
|
||||
|
||||
# Linked so it exists at a stable path for the read-only ~/.claude/hooks
|
||||
# mount, but deliberately NOT registered below: bin/dejima registers it
|
||||
# per session, into a throwaway settings copy, and only when remote
|
||||
# access is configured. Registering it here would fire it for every
|
||||
# unsandboxed host session too, where it has no marker directory to
|
||||
# write to.
|
||||
harness_has_guard notify && _link_hook dejima-notify.sh
|
||||
|
||||
# deny-dangerous-bash.sh is the one hook that is NOT sandbox-gated: it
|
||||
# has no DEJIMA check, so it applies to the operator's own host sessions
|
||||
# as well. That is deliberate, and it is why it may already be owned by
|
||||
# another install (dotfiles, say) - only claim it if nothing else
|
||||
# provides it, so this repo works standalone without clobbering an
|
||||
# existing setup.
|
||||
if harness_has_guard bash-deny; then
|
||||
local deny_dest="$HOME/.claude/hooks/deny-dangerous-bash.sh"
|
||||
if [ -e "$deny_dest" ] || [ -L "$deny_dest" ]; then
|
||||
echo "Skipping: deny-dangerous-bash.sh already present at $deny_dest (leaving it as-is)"
|
||||
else
|
||||
echo "Linking: deny-dangerous-bash.sh ($repo/hooks/deny-dangerous-bash.sh -> $deny_dest)"
|
||||
ln -s "$repo/hooks/deny-dangerous-bash.sh" "$deny_dest"
|
||||
fi
|
||||
fi
|
||||
echo
|
||||
|
||||
if [ ! -f "$settings" ]; then
|
||||
echo "Creating $settings"
|
||||
echo '{}' > "$settings"
|
||||
fi
|
||||
|
||||
# The registration that went with the symlink removed above. Two things
|
||||
# depend on it going: Claude Code reports a missing hook command on every
|
||||
# session start while it points at a path that no longer exists, and -
|
||||
# until the dedupe below stopped matching it - it also stood in for the
|
||||
# registration it was renamed from, so `_register merge-guard.sh` skipped
|
||||
# and the merge guard was registered nowhere at all. Dropped only when
|
||||
# the path it names is gone, so an operator's own file at that name keeps
|
||||
# its registration, matching the symlink check above.
|
||||
if [ ! -e "$old_merge_guard" ] && jq -e --arg s "/gitea-merge-guard.sh" \
|
||||
'[.hooks.PreToolUse[]?.hooks[]?.command // empty] | any(endswith($s))' \
|
||||
"$settings" >/dev/null 2>&1; then
|
||||
echo "Removing: stale pre-rename gitea-merge-guard.sh registration from $settings"
|
||||
local stale_tmp; stale_tmp=$(mktemp)
|
||||
jq --arg s "/gitea-merge-guard.sh" \
|
||||
'.hooks.PreToolUse = ((.hooks.PreToolUse // [])
|
||||
| map(.hooks = ((.hooks // []) | map(select((.command // "") | endswith($s) | not))))
|
||||
| map(select((.hooks | length) > 0)))' \
|
||||
"$settings" > "$stale_tmp" && mv "$stale_tmp" "$settings"
|
||||
fi
|
||||
|
||||
_register() {
|
||||
local event="$1" matcher="$2" script_name="$3" description="$4"
|
||||
# endswith on the leading slash, not test(): test() is an unanchored
|
||||
# regex, so a hook whose name merely ends in another's - which is what
|
||||
# a rename like gitea-merge-guard.sh -> merge-guard.sh produces - read
|
||||
# as already registered, and the new hook was silently never added.
|
||||
if jq -e --arg s "/$script_name" \
|
||||
"[.hooks.${event}[]?.hooks[]?.command // empty] | any(endswith(\$s))" \
|
||||
"$settings" >/dev/null 2>&1; then
|
||||
echo "Skipping: $script_name already registered as a $event hook"
|
||||
return
|
||||
fi
|
||||
echo "Registering $script_name as a $event hook in $settings ($description)"
|
||||
local tmp
|
||||
tmp=$(mktemp)
|
||||
jq --arg matcher "$matcher" --arg cmd "\$HOME/.claude/hooks/$script_name" \
|
||||
".hooks.${event} = ((.hooks.${event} // []) + [{\"matcher\": \$matcher, \"hooks\": [{\"type\": \"command\", \"command\": \$cmd}]}])" \
|
||||
"$settings" > "$tmp" && mv "$tmp" "$settings"
|
||||
}
|
||||
|
||||
_register SessionStart "startup|resume|clear|compact" sandbox-context.sh \
|
||||
"injects sandbox context on session start"
|
||||
harness_has_guard push-guard && _register PreToolUse Bash git-push-guard.sh \
|
||||
"blocks pushes outside the allowed host(s)/branches"
|
||||
harness_has_guard merge-guard && _register PreToolUse Bash merge-guard.sh \
|
||||
"blocks the configured forge's merge command inside the sandbox"
|
||||
harness_has_guard bash-deny && _register PreToolUse Bash deny-dangerous-bash.sh \
|
||||
"blocks obviously destructive Bash commands"
|
||||
echo
|
||||
return 0
|
||||
}
|
||||
|
||||
# What Claude Code causes to run on the HOST, unsandboxed.
|
||||
#
|
||||
# settings.json's statusLine.command names a script the host executes on
|
||||
# every session, so a session that can write that file has a path onto the
|
||||
# host that no container boundary sees. bin/dejima stages the file rather
|
||||
# than mounting over it, because a :ro mount landing on a symlink - the
|
||||
# common case - protects the target and leaves the link replaceable;
|
||||
# detecting a change here is the compensating control for that.
|
||||
#
|
||||
# The namespace and leaf together form the id a baseline stores
|
||||
# ("statusline:statusline.sh"), so neither may change while anyone has
|
||||
# approvals recorded. The label is the fixed prefix `ack` strips to recover
|
||||
# that id, which is why it lives here as a literal rather than in the
|
||||
# manifest - see the host-exec notes in bin/dejima-harness-lib.sh.
|
||||
harness_host_exec_surfaces() {
|
||||
local p
|
||||
p="$(harness_config_get statusline_path)"
|
||||
emit_host_exec statusline statusline.sh "$p" \
|
||||
"HOST-CODE-EXECUTION statusline.sh changed (runs UNSANDBOXED on the host via settings.json statusLine.command): "
|
||||
}
|
||||
+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 |
|
||||
|
||||
+208
-1
@@ -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,6 +35,8 @@ 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-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 |
|
||||
@@ -148,3 +150,208 @@ push to one, `runtime.bats` included. That job needs two things from the
|
||||
runner that an ordinary one doesn't (the host's Docker socket and the host
|
||||
network namespace), and it checks for both up front rather than failing
|
||||
obscurely partway through; the file says why each is needed.
|
||||
|
||||
## Adding a new harness
|
||||
|
||||
`[core] harness` picks a directory under `harnesses/`, and a harness manifest
|
||||
declares four things. Everything else dejima does for Claude Code is written
|
||||
against Claude Code, and this is the inventory of it: what a second harness
|
||||
has to supply, and where dejima has to stop assuming. Taken from a pass over
|
||||
the tree, so each row names the file to change rather than describing a
|
||||
sketch.
|
||||
|
||||
### What a harness must be able to do
|
||||
|
||||
Support is per harness and deliberate. A supported harness gets the same
|
||||
depth of integration Claude Code has - the same mounts, the same guards, the
|
||||
same audit surfaces - so a harness is not a manifest someone drops in. That
|
||||
is only possible if the harness can host what those protections need.
|
||||
|
||||
These are the requirements. A harness that fails one of the first five
|
||||
cannot be supported, and that is a decision about the harness rather than a
|
||||
gap in dejima.
|
||||
|
||||
| | Requirement | Why it is load-bearing |
|
||||
| --- | --- | --- |
|
||||
| **R1** | **Unattended execution.** A documented mode that runs tool calls without confirming each one. | The premise the project rests on. A harness that stops to ask is one an operator sits in front of, and the argument for accepting that mode is that the container makes being unattended survivable. |
|
||||
| **R2** | **Tool-call interception.** A supported mechanism to inspect a command before it runs and refuse it, without patching the harness. | The push guard, the merge guard and the dangerous-command denylist all live here. They are guards against an eager agent rather than a boundary against a hostile one, but a harness without them ships three protections short and the operator cannot add them back. |
|
||||
| **R3** | **Host-controlled context at session start.** A way to place text into the session's context at start, from a file the operator controls and the session cannot rewrite for the run in progress. | How the briefing and the project's `.dejima.md` arrive. It is also how the agent learns it is sandboxed, which is what makes it ask for a domain instead of working around the firewall. |
|
||||
| **R4** | **State dejima can locate or discard.** Per-project state must be computable on the host before launch, so it can be scoped per project - or safely discardable, so it can stay container-local. | #104. A store that is neither computable nor discardable cannot be scoped, and mounting it whole reintroduces cross-project context poisoning. |
|
||||
| **R5** | **A withholdable credential.** The model credential must live somewhere dejima can decline to mount, and the harness must accept a substitute: a staged fake, or an environment variable pointed at the host-side proxy. | The credential never entering the container is a headline property, not a nicety. |
|
||||
|
||||
Three more are required, but may be declared absent in the manifest, in
|
||||
which case dejima says at launch what is not in force:
|
||||
|
||||
| | Requirement | What is lost without it |
|
||||
| --- | --- | --- |
|
||||
| **D1** | Session lifecycle signals - something that fires when the agent wants attention and when it stops. | Remote access notifications. The session still runs and is still attachable. |
|
||||
| **D2** | A parseable record of the session. | Epitaph, and conversation logging. |
|
||||
| **D3** | An enumerable config directory whose extension points can be mounted read-only. | Config parity, and the read-only mounts that stop a session installing a persistent hook for the next one. |
|
||||
|
||||
Declaring one absent is a real decision with a visible cost, not a
|
||||
formality. It is there so a harness can be supported honestly at a smaller
|
||||
guard set rather than shipped with three protections quietly missing.
|
||||
|
||||
**The guard rails are declared in the manifest and reported at launch.**
|
||||
`guards` lists which of `bash-deny`, `push-guard`, `merge-guard` and
|
||||
`notify` the harness can host. A name outside that set is refused, because a
|
||||
typo would otherwise turn a guard off in silence. The ones a harness does
|
||||
not declare are named once at launch, with what each costs - and nothing is
|
||||
printed at all when it hosts them all, so the message stays a signal rather
|
||||
than a line to scroll past.
|
||||
|
||||
Core gates the work on the declaration rather than on whether a plugin
|
||||
happens to define the hook: a harness that forgot to declare a guard but
|
||||
implemented it would otherwise get a protection the launch message has just
|
||||
told the operator it does not have.
|
||||
|
||||
### Worked example: why aider is not supported
|
||||
|
||||
aider clears R1 (`--yes-always`), R3 (`--read` places a file in context),
|
||||
R4 (its state is `.aider.*` inside the workspace, which is already mounted)
|
||||
and R5 (it takes an API key from the environment).
|
||||
|
||||
It fails **R2**. aider has no hook or event system; the only occurrence of
|
||||
"hook" in its options is `--no-verify`, which controls git's own pre-commit
|
||||
hooks. There is no supported point at which a command can be inspected and
|
||||
refused, so `git-push-guard.sh`, `merge-guard.sh` and
|
||||
`deny-dangerous-bash.sh` have nowhere to live and no equivalent to be
|
||||
rebuilt from. It also fails D1.
|
||||
|
||||
The container would still bound an aider session. But three guards an
|
||||
operator gets everywhere else would be missing with no way to restore them,
|
||||
and "supported" would mean something different for that one harness than
|
||||
for every other. So it is not supported, and this is the reason.
|
||||
|
||||
### Where a harness's support lives: three tiers
|
||||
|
||||
| Tier | What | Rule |
|
||||
| --- | --- | --- |
|
||||
| **Core** | the mechanism, and every enforcement point | harness-agnostic, always |
|
||||
| **Manifest** | `harnesses/<name>/harness.conf` | data. Never sourced, never executed. Selects among named core mechanisms and supplies paths |
|
||||
| **Plugin** | `bin/harness/<name>.sh` | the harness-specific remainder, as code, in this repository |
|
||||
|
||||
**A feature belongs in the manifest if it can be stated as a value.** It only
|
||||
moves to a plugin when it cannot. `projects_dir` and `file_history_dir` are
|
||||
both paths, so both are manifest keys; the rule that a *name* in the first
|
||||
selects an *entry* in the second is Claude Code's own and could not be
|
||||
written as a value, so that is the plugin. "Add a manifest key" is the
|
||||
answer until it demonstrably is not.
|
||||
|
||||
**Plugins live under `bin/`, not beside the manifest.** `dejima-audit`'s
|
||||
`tcb_snapshot` covers `install.sh`, `bin/`, `hooks/`, `docker/` and `test/`,
|
||||
and not the provider directories. Host-side plugin code under `harnesses/`
|
||||
would be code that runs on the host and that a session could edit in
|
||||
dejima's own checkout without raising a CRITICAL - the exact class the audit
|
||||
exists to catch. Under `bin/` it is covered for free, and "manifests are
|
||||
data, never executed" stays true word for word.
|
||||
|
||||
**Plugins are sourced, not invoked**, and are part of the trusted computing
|
||||
base. The risk worth designing against is not a careless plugin but three
|
||||
plugins implementing the security model three ways, so a plugin uses core's
|
||||
own primitives rather than re-deriving them. An invoked protocol would also
|
||||
put a new text parser on the launch path, and every parsing bug this
|
||||
repository has had is in that family.
|
||||
|
||||
**The manifest may carry values that are only used as paths or compared as
|
||||
data. It must not carry text that becomes part of a parser's grammar, or the
|
||||
identity of a security surface.** `dejima-audit`'s `ack` recovers a finding's
|
||||
id by stripping a fixed label from the front of the finding text, anchored
|
||||
deliberately (#45). Those labels are grammar. Provider directories are not in
|
||||
`tcb_snapshot`'s list, so in the self-mount case a session can edit a
|
||||
manifest without raising a CRITICAL - which is why host-execution surfaces
|
||||
are a plugin hook (`harness_host_exec_surfaces`) rather than a manifest key.
|
||||
The label stays a literal in code the audit watches; it just lives with the
|
||||
harness that owns it.
|
||||
|
||||
**A plugin computes; core enforces.** A plugin declares through `emit_mount`
|
||||
and `emit_env`, which validate at the point of declaration - `emit_mount`
|
||||
runs the guarded-store refusal there, so a harness state directory that
|
||||
overlapped the approval, audit or epitaph store is refused without each
|
||||
plugin having to remember. A plugin never touches `extra_mounts` itself, and
|
||||
a static test holds that. A plugin may *classify* an audit path; it may never
|
||||
suppress a finding.
|
||||
|
||||
Every shipped harness has both a manifest and a plugin - a static test
|
||||
refuses one without the other, because a manifest alone would present as a
|
||||
supported harness while none of the work had been done.
|
||||
|
||||
### What the manifest already covers
|
||||
|
||||
`harnesses/<name>/harness.conf`, and the full contract is in
|
||||
[providers.md](providers.md):
|
||||
|
||||
| Key | What it decides |
|
||||
| --- | --- |
|
||||
| `command` | the container's main process |
|
||||
| `keys` | this harness's own `[harness.<name>]` config schema |
|
||||
| `resume_command` | what the briefing tells a session to hand the operator |
|
||||
| `briefing` | this harness's fragment of the session briefing |
|
||||
|
||||
### Reading a harness's own config
|
||||
|
||||
`harness_config_get <key>` resolves whichever harness `[core] harness`
|
||||
names. Use it; do not spell a `[harness.<name>]` section out at a call site.
|
||||
|
||||
`keys` is declared per harness, so a literal section name makes it a
|
||||
declaration only one harness can ever be read through - a second harness
|
||||
gets selected, gets validated, and is then handed the first one's paths with
|
||||
nothing erroring anywhere. Three call sites did exactly that, in
|
||||
`bin/dejima-audit` (twice) and `bin/dejima-epitaph`. A static test now holds
|
||||
the rule.
|
||||
|
||||
It returns empty when no harness is selected. `bin/dejima` refuses to start
|
||||
in that state, so that is reachable only from the standalone tools, where a
|
||||
harness-specific path resolving to nothing is the honest answer.
|
||||
|
||||
### The inventory
|
||||
|
||||
| Area | What is Claude Code specific | Where |
|
||||
| --- | --- | --- |
|
||||
| Install | `npm install -g @anthropic-ai/claude-code`, and a pinned `CLAUDE_CODE_VERSION` build arg | `docker/Dockerfile` |
|
||||
| Permission bypass | the flag itself, now `no_confirm_args` in the manifest; the wrapper is generic | `harnesses/<name>/harness.conf` |
|
||||
| Settings layering | that overrides arrive as an extra layer rather than a mount over the operator's file (#47) is core; the flag that carries them is `settings_args` in the manifest | `harnesses/<name>/harness.conf` |
|
||||
| Telemetry | `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` | `docker/Dockerfile` |
|
||||
| Egress allowlist | `platform.claude.com` always, `api.anthropic.com` only when no credential proxy is in front of it | `docker/fixed-domains.sh` |
|
||||
| Credentials | the whole proxy exists because Claude Code authenticates from `~/.claude/.credentials.json` rather than an env var; the entrypoint seeds a well-formed fake so it has something to read | `bin/dejima-anthropic-proxy`, `docker/anthropic-proxy-forwarder.js`, `docker/entrypoint.sh` |
|
||||
| Hook protocol | the event payload arrives on stdin; `SessionStart` returns `hookSpecificOutput.hookEventName`/`additionalContext` as JSON, while a `PreToolUse` hook blocks by exiting 2 with its reason on stderr | `hooks/*.sh` |
|
||||
| Hook registration | four hooks written into `~/.claude/settings.json` in Claude Code's schema, one `SessionStart` with a `startup\|resume\|clear\|compact` matcher and three `PreToolUse` matching `Bash`. Now in the plugin, gated per declared guard | `bin/harness/claude-code.sh` |
|
||||
| Briefing delivery | the briefing and the project's `.dejima.md` reach the session *through* the `SessionStart` hook. No hooks, no briefing | `hooks/sandbox-context.sh` |
|
||||
| Notifications | `Notification`/`Stop` hooks, registered per session into the settings layer, are what drop the markers remote access watches | `hooks/dejima-notify.sh`, `bin/dejima` |
|
||||
| Config parity | every `~/.claude` subdirectory is enumerated and mounted by name: `plugins`, `hooks`, `skills`, `commands`, `agents`, `output-styles` read-only; `backups`, `tasks`, `plans`, `sessions` read-write | `docker/docker-compose.yml` |
|
||||
| Staged config | `settings.json`, `CLAUDE.md` and `statusline.sh` are staged read-only under `/etc/dejima-claude` and installed into place | `docker/entrypoint.sh`, `bin/dejima` |
|
||||
| App state | `~/.claude.json` is copied rather than mounted, because it mixes live state with `mcpServers` config | `bin/dejima` |
|
||||
| State layout | the project key - cwd with every non-alphanumeric replaced by `-` - is Claude Code's scheme, and dejima reproduces it in four places | `bin/dejima-slug-lib.sh`, `bin/dejima-project-lib.sh`, `docker/log-shipper.sh` |
|
||||
| Per-project scoping | `projects/` is mounted one slug at a time and `file-history/` one session at a time, the second resolved by pairing `projects/<slug>/<id>.jsonl` with `file-history/<id>` (#104). Now in the plugin | `bin/harness/claude-code.sh` |
|
||||
| Transcript format | conversation logging transforms Claude Code's JSONL line by line | `docker/log-shipper.sh` |
|
||||
| Compaction format | epitaph keys on `isCompactSummary` records and the `compact_boundary` that precedes them | `bin/dejima-epitaph` |
|
||||
| Audit surfaces | `.claude/settings.json` is CRITICAL and the rest of `.claude/` is a WARNING, because that is where this harness keeps things that run | `bin/dejima-audit` |
|
||||
| Host code execution | `statusline.sh` is audited as its own CRITICAL surface because `settings.json`'s `statusLine.command` runs it **unsandboxed on the host**. Declared by the plugin, since a finding's label is a parser's grammar rather than data | `bin/harness/claude-code.sh` |
|
||||
|
||||
### Two things worth deciding early
|
||||
|
||||
**What runs on the host when the harness is configured.** `statusLine.command`
|
||||
is the one that exists today, and it is not a quirk: a harness that will
|
||||
execute an operator-configured command outside the container hands a session
|
||||
that writes that file a way onto the host. Find that surface in the new
|
||||
harness before supporting it, because it belongs in the audit from the first
|
||||
release rather than after someone finds it.
|
||||
|
||||
**What the harness keeps, and who else can read it.** The per-project
|
||||
scoping of `projects/` and `file-history/` exists because a session that can
|
||||
write another project's memory can plant text a later session there reads as
|
||||
established background (#104). A new harness's state directory needs the same
|
||||
question asked of it, one directory at a time. Anything mounted host-wide
|
||||
should be there because it is keyed by session rather than by project, and
|
||||
because nothing loads it into a session's context at startup - which is the
|
||||
reasoning that leaves `tasks`, `plans`, `sessions` and `backups` mounted
|
||||
whole today.
|
||||
|
||||
### Testing a new harness
|
||||
|
||||
`providers.bats` covers the manifest contract itself and is where a new
|
||||
`harness.conf` gets its parsing and validation tests. The suites that assert
|
||||
on Claude Code paths by name - `config.bats`, `audit.bats`, `static.bats`,
|
||||
`runtime.bats` - are the ones that will need a second set, and a green run
|
||||
against the existing harness proves nothing about a new one. Nothing in
|
||||
`test/` currently runs a second harness end to end.
|
||||
|
||||
+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:
|
||||
|
||||
@@ -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
|
||||
|
||||
+28
-109
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# Idempotent installer for dejima. Symlinks this repo's hooks into
|
||||
# ~/.claude/hooks, registers them in ~/.claude/settings.json, and puts
|
||||
# Idempotent installer for dejima. Registers the selected harness's guard
|
||||
# hooks wherever that harness reads them from, and puts
|
||||
# bin/dejima, bin/dejima-allow, bin/dejima-dep, bin/dejima-project,
|
||||
# bin/dejima-audit and the opt-in remote-access scripts on PATH. Safe to
|
||||
# re-run - every step is a no-op if already done, and every change is
|
||||
@@ -35,113 +35,6 @@ fi
|
||||
echo "Found required tools: docker, docker compose v2, jq, python3"
|
||||
echo
|
||||
|
||||
mkdir -p "$HOME/.claude/hooks"
|
||||
|
||||
link_hook() {
|
||||
local name="$1" src="$SCRIPT_DIR/hooks/$1" dest="$HOME/.claude/hooks/$1"
|
||||
if [ -L "$dest" ] && [ "$(readlink -f "$dest")" = "$(readlink -f "$src")" ]; then
|
||||
echo "Skipping: $name already linked to this repo's copy"
|
||||
return
|
||||
fi
|
||||
if [ -e "$dest" ] || [ -L "$dest" ]; then
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
echo "Moving: existing ~/.claude/hooks/$name to $BACKUP_DIR/$name"
|
||||
mv "$dest" "$BACKUP_DIR/$name"
|
||||
fi
|
||||
echo "Linking: $name ($src -> $dest)"
|
||||
ln -s "$src" "$dest"
|
||||
}
|
||||
|
||||
link_hook sandbox-context.sh
|
||||
link_hook git-push-guard.sh
|
||||
link_hook merge-guard.sh
|
||||
|
||||
# Symlink from before the gitea-merge-guard.sh -> merge-guard.sh rename
|
||||
# points at a hooks/ path that no longer exists. Only remove it if it's
|
||||
# actually dangling - the same check the claude-sandbox-* cleanup below
|
||||
# uses for bin/, so an operator's own file at this name is left alone.
|
||||
old_merge_guard="$HOME/.claude/hooks/gitea-merge-guard.sh"
|
||||
if [ -L "$old_merge_guard" ] && [ ! -e "$old_merge_guard" ]; then
|
||||
echo "Removing: dangling pre-rename symlink $old_merge_guard"
|
||||
rm -f "$old_merge_guard"
|
||||
fi
|
||||
|
||||
# Linked so it exists at a stable path for the read-only ~/.claude/hooks
|
||||
# mount, but deliberately NOT passed to register_hook below: bin/dejima
|
||||
# registers it per session, into a throwaway settings copy, and only when
|
||||
# remote access is configured. Registering it here would fire it for every
|
||||
# unsandboxed host session too, where it has no marker directory to write to.
|
||||
link_hook dejima-notify.sh
|
||||
|
||||
# deny-dangerous-bash.sh is a general (non-sandbox-gated) hook that may
|
||||
# already be owned by another install (e.g. dotfiles) - only claim it if
|
||||
# nothing else already provides it, so this repo works fully standalone
|
||||
# without clobbering an existing setup.
|
||||
deny_dest="$HOME/.claude/hooks/deny-dangerous-bash.sh"
|
||||
if [ -e "$deny_dest" ] || [ -L "$deny_dest" ]; then
|
||||
echo "Skipping: deny-dangerous-bash.sh already present at $deny_dest (leaving it as-is)"
|
||||
else
|
||||
echo "Linking: deny-dangerous-bash.sh ($SCRIPT_DIR/hooks/deny-dangerous-bash.sh -> $deny_dest)"
|
||||
ln -s "$SCRIPT_DIR/hooks/deny-dangerous-bash.sh" "$deny_dest"
|
||||
fi
|
||||
echo
|
||||
|
||||
SETTINGS="$HOME/.claude/settings.json"
|
||||
if [ ! -f "$SETTINGS" ]; then
|
||||
echo "Creating $SETTINGS"
|
||||
echo '{}' > "$SETTINGS"
|
||||
fi
|
||||
|
||||
# The registration that went with the symlink removed above. Two things
|
||||
# depend on it going: Claude Code reports a missing hook command on every
|
||||
# session start while it points at a path that no longer exists, and - until
|
||||
# the dedupe below stopped matching it - it also stood in for the
|
||||
# registration it was renamed from, so `register_hook merge-guard.sh`
|
||||
# skipped and the merge guard was registered nowhere at all. Dropped only
|
||||
# when the path it names is gone, so an operator's own file at that name
|
||||
# keeps its registration, matching the symlink check above.
|
||||
if [ ! -e "$old_merge_guard" ] && jq -e --arg s "/gitea-merge-guard.sh" \
|
||||
'[.hooks.PreToolUse[]?.hooks[]?.command // empty] | any(endswith($s))' \
|
||||
"$SETTINGS" >/dev/null 2>&1; then
|
||||
echo "Removing: stale pre-rename gitea-merge-guard.sh registration from $SETTINGS"
|
||||
settings_tmp=$(mktemp)
|
||||
jq --arg s "/gitea-merge-guard.sh" \
|
||||
'.hooks.PreToolUse = ((.hooks.PreToolUse // [])
|
||||
| map(.hooks = ((.hooks // []) | map(select((.command // "") | endswith($s) | not))))
|
||||
| map(select((.hooks | length) > 0)))' \
|
||||
"$SETTINGS" > "$settings_tmp" && mv "$settings_tmp" "$SETTINGS"
|
||||
fi
|
||||
|
||||
register_hook() {
|
||||
local event="$1" matcher="$2" script_name="$3" description="$4"
|
||||
# endswith on the leading slash, not test(): test() is an unanchored
|
||||
# regex, so a hook whose name merely ends in another's - which is what a
|
||||
# rename like gitea-merge-guard.sh -> merge-guard.sh produces - read as
|
||||
# already registered, and the new hook was silently never added.
|
||||
if jq -e --arg s "/$script_name" \
|
||||
"[.hooks.${event}[]?.hooks[]?.command // empty] | any(endswith(\$s))" \
|
||||
"$SETTINGS" >/dev/null 2>&1; then
|
||||
echo "Skipping: $script_name already registered as a $event hook"
|
||||
return
|
||||
fi
|
||||
echo "Registering $script_name as a $event hook in $SETTINGS ($description)"
|
||||
local tmp
|
||||
tmp=$(mktemp)
|
||||
jq --arg matcher "$matcher" --arg cmd "\$HOME/.claude/hooks/$script_name" \
|
||||
".hooks.${event} = ((.hooks.${event} // []) + [{\"matcher\": \$matcher, \"hooks\": [{\"type\": \"command\", \"command\": \$cmd}]}])" \
|
||||
"$SETTINGS" > "$tmp" && mv "$tmp" "$SETTINGS"
|
||||
}
|
||||
|
||||
register_hook SessionStart "startup|resume|clear|compact" sandbox-context.sh \
|
||||
"injects sandbox context on session start"
|
||||
register_hook PreToolUse Bash git-push-guard.sh \
|
||||
"blocks pushes outside the allowed host(s)/branches"
|
||||
register_hook PreToolUse Bash merge-guard.sh \
|
||||
"blocks the configured forge's merge command inside the sandbox"
|
||||
register_hook PreToolUse Bash deny-dangerous-bash.sh \
|
||||
"blocks obviously destructive Bash commands"
|
||||
echo
|
||||
|
||||
BIN_DIR="$HOME/.local/bin"
|
||||
if [[ ":$PATH:" != *":$BIN_DIR:"* ]]; then
|
||||
echo "Warning: $BIN_DIR is not on PATH - add it to your shell profile to run 'dejima' by name instead of the full path." >&2
|
||||
@@ -295,6 +188,32 @@ else
|
||||
chmod 600 "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
# Guard registration, after the starter config above rather than before it:
|
||||
# where dejima's guards go, and in what shape, is the selected harness's
|
||||
# business, and on a fresh install that selection is the one just seeded.
|
||||
# Registering before reading it would mean writing one harness's config for
|
||||
# an operator who has chosen another.
|
||||
#
|
||||
# Delegated to the harness's plugin, which is also what gates each guard on
|
||||
# the harness having declared it (see `guards` in its manifest).
|
||||
echo
|
||||
source "$SCRIPT_DIR/bin/dejima-harness-lib.sh"
|
||||
_harness="$(config_get core harness 2>/dev/null)" || _harness=""
|
||||
if [ -z "$_harness" ]; then
|
||||
echo "No [core] harness is set in $CONFIG_FILE, so no guard hooks were"
|
||||
echo "registered. Set one and re-run ./install.sh."
|
||||
else
|
||||
DEJIMA_HARNESS_NAME="$_harness"
|
||||
harness_plugin_load "$_harness"
|
||||
if declare -F harness_register_guards >/dev/null 2>&1; then
|
||||
echo "Registering $_harness's guard hooks"
|
||||
harness_register_guards "$SCRIPT_DIR" "$BACKUP_DIR"
|
||||
else
|
||||
echo "Harness '$_harness' installs no guard hooks."
|
||||
echo
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f "$(config_root)/remote.conf" ]; then
|
||||
echo
|
||||
echo "Note: $(config_root)/remote.conf is no longer read - its keys moved to the"
|
||||
|
||||
+62
-2
@@ -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
|
||||
@@ -264,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
|
||||
@@ -1381,9 +1401,9 @@ key_of() { printf '%s' "$1" | sha256sum | cut -d' ' -f1; }
|
||||
snap
|
||||
head -1 "$STATE" | grep -q '^### staging /'
|
||||
# The existing section ranges are anchored at ### gitconfig and
|
||||
# ### statusline, so putting staging first must leave them untouched.
|
||||
# ### hostexec, so putting staging first must leave them untouched.
|
||||
grep -q '^### gitconfig$' "$STATE"
|
||||
grep -q '^### statusline$' "$STATE"
|
||||
grep -q '^### hostexec$' "$STATE"
|
||||
}
|
||||
|
||||
@test "denylisted surfaces are never staged, not even transiently" {
|
||||
@@ -1910,3 +1930,43 @@ sidecar() { local r; r="$(find "$AUDITDIR" -name '*.txt' | sort | tail -1)"; r="
|
||||
run "$AUDIT" compare "$WS" "$STATE"
|
||||
[[ "$output" != *"writable"* ]]
|
||||
}
|
||||
|
||||
@test "a host-execution finding still records the id existing baselines hold" {
|
||||
# The id is what an approval is stored under. Generalising the mechanism
|
||||
# must not rename it: every baseline an operator already has says
|
||||
# "statusline:statusline.sh", and an id that no longer matches re-fires
|
||||
# a CRITICAL they approved. The namespace and leaf come from the plugin
|
||||
# for exactly this reason - they are the harness's to keep stable.
|
||||
printf '#!/bin/sh\necho original\n' > "$DEJIMA_AUDIT_STATUSLINE"
|
||||
snap
|
||||
printf '#!/bin/sh\necho changed\n' > "$DEJIMA_AUDIT_STATUSLINE"
|
||||
run cmp_
|
||||
[[ "$output" == *"HOST-CODE-EXECUTION"* ]] || { echo "$output"; return 1; }
|
||||
# Approve it, then confirm the baseline line is the historical id.
|
||||
printf 'a\n' | "$AUDIT" ack "$WS" >/dev/null 2>&1 || true
|
||||
run cat "$BASEDIR/$(printf '%s' "$WS" | sed 's/[^a-zA-Z0-9]/-/g')"
|
||||
[[ "$output" == *"safe statusline:statusline.sh "* ]] \
|
||||
|| { echo "baseline id changed: $output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a harness declaring no host-execution surface is watched for none" {
|
||||
# Not every harness runs something on the host from its own config. The
|
||||
# absence must mean nothing is checked, not that the previous harness's
|
||||
# path is.
|
||||
printf '#!/bin/sh\necho original\n' > "$DEJIMA_AUDIT_STATUSLINE"
|
||||
snap
|
||||
cat > "$CONFIG_DIR/config" <<EOF
|
||||
[core]
|
||||
harness = claude-code
|
||||
[harness.claude-code]
|
||||
projects_dir = $PROJDIR
|
||||
statusline_path =
|
||||
[audit]
|
||||
dir = $AUDITDIR
|
||||
baselines_dir = $BASEDIR
|
||||
EOF
|
||||
printf '#!/bin/sh\necho changed\n' > "$DEJIMA_AUDIT_STATUSLINE"
|
||||
run cmp_
|
||||
[[ "$output" != *"HOST-CODE-EXECUTION"* ]] \
|
||||
|| { echo "watched a surface the harness declares no path for: $output"; return 1; }
|
||||
}
|
||||
|
||||
@@ -1727,3 +1727,144 @@ _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"
|
||||
}
|
||||
|
||||
@test "install.sh registers the selected harness's guards, gated on its declarations" {
|
||||
# Which guards exist and how they are installed is the harness's, so a
|
||||
# harness declaring fewer gets fewer - and dejima does not write a
|
||||
# config belonging to a harness the operator did not choose.
|
||||
local proot; proot="$(mktemp -d)"
|
||||
cp -a "$REPO_ROOT/forges" "$REPO_ROOT/workflows" "$REPO_ROOT/harnesses" "$proot/"
|
||||
sed -i 's/^guards .*/guards = bash-deny/' \
|
||||
"$proot/harnesses/claude-code/harness.conf"
|
||||
run_install DEJIMA_PROVIDER_ROOT="$proot"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
run jq -r '[.hooks.PreToolUse[]?.hooks[]?.command] | join(" ")' \
|
||||
"$CONFIG_DIR/.claude/settings.json"
|
||||
[[ "$output" == *"deny-dangerous-bash.sh"* ]] || { echo "$output"; return 1; }
|
||||
[[ "$output" != *"git-push-guard.sh"* ]] \
|
||||
|| { echo "registered an undeclared guard: $output"; return 1; }
|
||||
[[ "$output" != *"merge-guard.sh"* ]] \
|
||||
|| { echo "registered an undeclared guard: $output"; return 1; }
|
||||
rm -rf "$proot"
|
||||
}
|
||||
|
||||
@test "install.sh registers every guard when the harness declares them all" {
|
||||
# The shipped harness, unmodified: the behaviour this replaced, kept.
|
||||
run_install
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
local cmds
|
||||
cmds="$(jq -r '[.hooks.PreToolUse[]?.hooks[]?.command,
|
||||
.hooks.SessionStart[]?.hooks[]?.command] | join(" ")' \
|
||||
"$CONFIG_DIR/.claude/settings.json")"
|
||||
local h
|
||||
for h in sandbox-context.sh git-push-guard.sh merge-guard.sh deny-dangerous-bash.sh; do
|
||||
[[ "$cmds" == *"$h"* ]] || { echo "$h not registered: $cmds"; return 1; }
|
||||
done
|
||||
# Linked for the read-only hooks mount, but registered per session by
|
||||
# bin/dejima rather than here, where it would fire for host sessions too.
|
||||
[ -L "$CONFIG_DIR/.claude/hooks/dejima-notify.sh" ]
|
||||
[[ "$cmds" != *"dejima-notify.sh"* ]] \
|
||||
|| { echo "notify registered host-wide: $cmds"; return 1; }
|
||||
}
|
||||
|
||||
@test "install.sh registers no guards when no harness is selected" {
|
||||
# Writing one harness's config for an operator who has chosen another,
|
||||
# or none, is the coupling this removed.
|
||||
# A config that already exists is never overwritten, so this is how an
|
||||
# operator upgrading from before [core] harness was required arrives.
|
||||
printf '[core]\nforge = none\nworkflow = local-only\n' > "$CONFIG_DIR/config"
|
||||
run_install
|
||||
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
|
||||
[[ "$output" == *"No [core] harness is set"* ]] || { echo "$output"; return 1; }
|
||||
[ ! -f "$CONFIG_DIR/.claude/settings.json" ] \
|
||||
|| { echo "wrote a harness config anyway"; return 1; }
|
||||
}
|
||||
|
||||
+5
-2
@@ -2498,14 +2498,17 @@ nginx_blocks() {
|
||||
}
|
||||
|
||||
@test "install.sh links the remote scripts and the notify hook, registers neither, and enables nothing" {
|
||||
grep -q '^link_hook dejima-notify.sh$' "$REPO_ROOT/install.sh"
|
||||
# The hook is linked by the harness plugin now - which file it is, and
|
||||
# where the harness reads it from, is the harness's business - while the
|
||||
# CLIs stay install.sh's, being dejima's own.
|
||||
grep -q '_link_hook dejima-notify.sh' "$REPO_ROOT/bin/harness/claude-code.sh"
|
||||
grep -q '^link_bin dejima-attach$' "$REPO_ROOT/install.sh"
|
||||
grep -q '^link_bin dejima-ttyd$' "$REPO_ROOT/install.sh"
|
||||
grep -q '^link_bin dejima-notify-watch$' "$REPO_ROOT/install.sh"
|
||||
# The notify hook is registered per session by bin/dejima, into a
|
||||
# throwaway settings copy. Registered here it would fire for every
|
||||
# unsandboxed host session too.
|
||||
run grep -nE 'register_hook.*dejima-notify' "$REPO_ROOT/install.sh"
|
||||
run grep -nE '_register .*dejima-notify' "$REPO_ROOT/bin/harness/claude-code.sh"
|
||||
[ "$status" -ne 0 ]
|
||||
# Standing up a front door is an explicit operator decision. Nothing in
|
||||
# the installer may start, enable or even copy a unit. Code only - the
|
||||
|
||||
+262
-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" {
|
||||
@@ -1166,9 +1176,13 @@ cli_payload() { cat "$BATS_TEST_TMPDIR/cli/payload"; }
|
||||
[ "$status" -eq 0 ]
|
||||
}
|
||||
|
||||
@test "install.sh links and registers the merge guard" {
|
||||
grep -qx 'link_hook merge-guard.sh' "$REPO_ROOT/install.sh"
|
||||
grep -q 'register_hook PreToolUse Bash merge-guard.sh' "$REPO_ROOT/install.sh"
|
||||
@test "the merge guard is linked and registered" {
|
||||
# In the harness plugin now: which file a guard is and where a harness
|
||||
# reads it from is the harness's, while whether it is installed at all
|
||||
# is gated on the manifest declaring the guard.
|
||||
local p="$REPO_ROOT/bin/harness/claude-code.sh"
|
||||
grep -q 'harness_has_guard merge-guard && _link_hook merge-guard.sh' "$p"
|
||||
grep -q '_register PreToolUse Bash merge-guard.sh' "$p"
|
||||
}
|
||||
|
||||
# Runs install.sh against a throwaway HOME, with a stub `docker` on PATH so
|
||||
@@ -1611,9 +1625,12 @@ registered_commands() {
|
||||
# coverage (snapshot/compare
|
||||
# against real fixtures, including the symlink-swap exploit) lives in
|
||||
# test/audit.bats; this just confirms the wiring exists.
|
||||
grep -q 'STATUSLINE_PATH=' "$REPO_ROOT/bin/dejima-audit"
|
||||
grep -q 'statusline_snapshot' "$REPO_ROOT/bin/dejima-audit"
|
||||
grep -q 'HOST-CODE-EXECUTION' "$REPO_ROOT/bin/dejima-audit"
|
||||
# The path and its label are the harness plugin's now; the mechanism
|
||||
# that snapshots and reports them is still the audit's.
|
||||
grep -q 'host_exec_snapshot' "$REPO_ROOT/bin/dejima-audit"
|
||||
grep -q 'host_exec_load' "$REPO_ROOT/bin/dejima-audit"
|
||||
grep -q 'statusline_path' "$REPO_ROOT/bin/harness/claude-code.sh"
|
||||
grep -q 'HOST-CODE-EXECUTION' "$REPO_ROOT/bin/harness/claude-code.sh"
|
||||
}
|
||||
|
||||
@test "Dockerfile installs the sandbox clipboard fallback into nvim's VIMRUNTIME plugin dir" {
|
||||
@@ -2042,6 +2059,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"
|
||||
@@ -2920,3 +2977,192 @@ 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"
|
||||
}
|
||||
|
||||
@test "install.sh installs no harness's hooks itself" {
|
||||
# Where dejima's guards go, and in what shape, is the selected harness's
|
||||
# business. install.sh knowing ~/.claude/settings.json's schema is the
|
||||
# coupling; it delegates to the harness's plugin now.
|
||||
run grep -nE '\.claude/(settings\.json|hooks)' "$REPO_ROOT/install.sh"
|
||||
[ "$status" -ne 0 ] || { echo "install.sh still writes a harness's config:"; echo "$output"; return 1; }
|
||||
grep -q 'harness_register_guards' "$REPO_ROOT/install.sh"
|
||||
grep -q '^harness_register_guards()' "$REPO_ROOT/bin/harness/claude-code.sh"
|
||||
}
|
||||
|
||||
@test "the plugin loader does not depend on its caller's SCRIPT_DIR" {
|
||||
# bin/dejima sets it to bin/ and install.sh sets it to the repo root. A
|
||||
# library that resolves paths from one of those only works for one of
|
||||
# its callers.
|
||||
sed -n '/^harness_plugin_load()/,/^}/p' "$REPO_ROOT/bin/dejima-harness-lib.sh" \
|
||||
| grep -q 'BASH_SOURCE'
|
||||
run sed -n '/^harness_plugin_load()/,/^}/p' "$REPO_ROOT/bin/dejima-harness-lib.sh"
|
||||
[[ "$output" != *'$SCRIPT_DIR'* ]] || { echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "a finding's label lives in trusted code, never in a manifest" {
|
||||
# `ack` recovers an id by stripping a fixed label from the front of a
|
||||
# finding. Those labels are a parser's grammar, and provider directories
|
||||
# are not in tcb_snapshot's list - in the self-mount case a session can
|
||||
# edit a manifest without raising a CRITICAL. So the label stays a
|
||||
# literal in code the audit does watch, which is why host-exec surfaces
|
||||
# are a plugin hook rather than a manifest key.
|
||||
grep -q 'HOST-CODE-EXECUTION' "$REPO_ROOT/bin/harness/claude-code.sh"
|
||||
run grep -rn 'HOST-CODE-EXECUTION' "$REPO_ROOT/harnesses"
|
||||
[ "$status" -ne 0 ] || { echo "a finding label is manifest data:"; echo "$output"; return 1; }
|
||||
run grep -rniE 'host_exec_paths|host_exec_label' "$REPO_ROOT/harnesses" "$REPO_ROOT/bin/dejima-provider-lib.sh"
|
||||
[ "$status" -ne 0 ] || { echo "host-exec surfaces leaked into the manifest schema:"; echo "$output"; return 1; }
|
||||
}
|
||||
|
||||
@test "the audit resolves host-execution paths from the harness, not a hardcoded one" {
|
||||
run grep -n 'STATUSLINE_PATH' "$REPO_ROOT/bin/dejima-audit"
|
||||
[ "$status" -ne 0 ] || { echo "$output"; return 1; }
|
||||
grep -q 'host_exec_load' "$REPO_ROOT/bin/dejima-audit"
|
||||
grep -q '^harness_host_exec_surfaces()' "$REPO_ROOT/bin/harness/claude-code.sh"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user