From 3da5828e1f695cc20ec3298566a0ca9b6bd357bd Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Thu, 8 Oct 2026 10:07:40 -0400 Subject: [PATCH 01/39] feat(12-1): build the disposable tree the live run starts from A live run that reads or writes a real ledger proves nothing, so the script refuses BALEY_HOME and any folder it cannot safely clear, keeps every folder at 0700, and pins the binary, the commit and the owner's real Baley folders so a leak shows up as a change. --- spikes/host-matrix/live-claude.sh | 163 ++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 spikes/host-matrix/live-claude.sh diff --git a/spikes/host-matrix/live-claude.sh b/spikes/host-matrix/live-claude.sh new file mode 100644 index 00000000..349a8b57 --- /dev/null +++ b/spikes/host-matrix/live-claude.sh @@ -0,0 +1,163 @@ +#!/bin/sh +# Live Claude Code qualification, preparation side. Given a built baley, it builds a disposable +# tree under ~/.local/share/baley-live, renders the real binary's artifacts into launch files, writes +# the observation sheet and prints the exact launch of every session in live-claude.md. It never +# starts Claude Code: the owner runs every session by hand. +set -u + +if [ $# -ne 1 ]; then + echo "usage: sh live-claude.sh " >&2 + exit 1 +fi +SUPPLIED=$1 +case "$SUPPLIED" in + /*) ;; + *) echo "the baley path must be absolute: $SUPPLIED" >&2; exit 1 ;; +esac +if [ ! -f "$SUPPLIED" ] || [ ! -x "$SUPPLIED" ]; then + echo "not an executable file: $SUPPLIED" >&2; exit 1 +fi + +# BALEY_HOME collapses home and config into one folder, so the two-folder run cannot use it. +if [ -n "${BALEY_HOME+set}" ]; then + echo "BALEY_HOME is set. Unset it: it makes home and config one folder, and this run needs two." >&2 + exit 1 +fi + +# The paths go into JSON and shell commands unquoted, so only plain path characters are allowed. +case "${HOME:-}" in + ""|/) echo "HOME must be a real directory" >&2; exit 1 ;; + /*) ;; + *) echo "HOME must be absolute" >&2; exit 1 ;; +esac +case "$HOME" in + *[!A-Za-z0-9._/-]*|*crenshawdev*) echo "HOME holds a character or name this script refuses" >&2; exit 1 ;; +esac + +for TOOL in jq git; do + command -v "$TOOL" >/dev/null 2>&1 || { echo "$TOOL is required and is not on PATH" >&2; exit 1; } +done + +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) + +ROOT="$HOME/.local/share/baley-live" +DATA="$ROOT/data" +CONF="$ROOT/config" +HOMEF="$DATA/crenshawdev/baley" +CONFF="$CONF/crenshawdev/baley" +BIN="$ROOT/bin/baley" +CC="$ROOT/claude-config" +PROJ="$ROOT/projects" +REM="$ROOT/remotes" +OUT="$ROOT/results" +REN="$OUT/rendered" + +# The only deletion in this script: the tree has to be exactly the root named above, with no +# symbolic link from HOME down to it, so the delete cannot land somewhere else, and owned by this user. +fresh() { + case "$1" in + "$HOME/.local/share/baley-live") ;; + *) echo "refusing to clear $1" >&2; exit 1 ;; + esac + P="$HOME" + for PART in $(printf '%s\n' "${1#"$HOME"/}" | tr '/' ' '); do + P="$P/$PART" + [ -L "$P" ] && { echo "refusing to clear $1: $P is a symbolic link" >&2; exit 1; } + done + if [ -e "$1" ] && [ -z "$(find "$1" -prune -user "$(id -u)")" ]; then + echo "refusing to clear $1: it is not owned by $(id -un)" >&2; exit 1 + fi + rm -rf "$1" && mkdir -p "$1" +} + +# The owner's real Baley folders as the binary resolves them (folders.rs), listed now and again +# by live-claude-reads.sh, so a hook or server that missed the exported variables shows up as a change. +case "${XDG_DATA_HOME:-}" in /*) REAL_HOME="$XDG_DATA_HOME/crenshawdev/baley" ;; *) REAL_HOME="$HOME/.local/share/crenshawdev/baley" ;; esac +case "${XDG_CONFIG_HOME:-}" in /*) REAL_CONF="$XDG_CONFIG_HOME/crenshawdev/baley" ;; *) REAL_CONF="$HOME/.config/crenshawdev/baley" ;; esac +list_real() { + if [ -d "$1" ]; then + find "$1" -printf '%p %y %s %TY-%Tm-%Td %TH:%TM:%TS\n' | sort + else + echo absent + fi +} + +umask 077 +fresh "$ROOT" +mkdir -p "$HOMEF" "$CONFF" "$ROOT/bin" "$CC" "$PROJ" "$REM" "$OUT" "$REN" \ + "$OUT/server-stderr" "$OUT/hook-calls" "$ROOT/bin-nosandbox" "$ROOT/bin-nogit" + +# Harmless fixtures in both folders. The key value is fake on purpose, and no real credential enters +# either folder. +for D in "$HOMEF" "$CONFF"; do + echo seed > "$D/seed.txt" + echo "FAKE_KEY=not-a-real-key-0000" > "$D/keys.env" + printf '%s\n' '{"cells":[{"cell_type":"code","execution_count":null,"id":"c1","metadata":{},"outputs":[],"source":["x = 1"]}],"metadata":{},"nbformat":4,"nbformat_minor":5}' > "$D/notebook.ipynb" + chmod 600 "$D/seed.txt" "$D/keys.env" "$D/notebook.ipynb" +done +# A script for the steps that check a command's child process. +cat > "$ROOT/child.sh" <<'EOF' +#!/bin/sh +case "$1" in + read) cat "$2" ;; + write) echo child > "$2" ;; +esac +EOF + +# Every rendered artifact names this copy, so no step ever writes toward a build folder. +cp "$SUPPLIED" "$BIN" && chmod 700 "$BIN" + +# The two folders of commands for the sessions that run with a narrowed PATH. git runs its own +# subcommands without GIT_EXEC_PATH, so removing the exec folder would not make it fail: the +# fallback session has no git at all instead. +link_cmds() { + DIR=$1; shift + for NAME in "$@"; do + FOUND=$(command -v "$NAME" 2>/dev/null || true) + case "$FOUND" in + /*) ln -s "$FOUND" "$DIR/$NAME" ;; + *) echo "WARNING: $NAME is not on PATH, so $DIR has no $NAME." ;; + esac + done + if [ -n "${SHELL:-}" ] && [ -x "$SHELL" ]; then ln -sf "$SHELL" "$DIR/$(basename "$SHELL")"; fi +} +link_cmds "$ROOT/bin-nosandbox" sh env git cat ls jq claude +link_cmds "$ROOT/bin-nogit" sh env cat ls jq claude bwrap socat date + +cat > "$OUT/pins.txt" </dev/null || echo absent)" >> "$OUT/pins.txt" +done +if [ -n "${DISABLE_TELEMETRY:-}" ] || [ -n "${CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC:-}" ]; then + echo "telemetry-switch: set (Monitor is unavailable)" >> "$OUT/pins.txt" +else + echo "telemetry-switch: unset" >> "$OUT/pins.txt" +fi +{ + echo "checkout-commit: $(git -C "$SCRIPT_DIR" rev-parse HEAD)" + DIRTY=$(git -C "$SCRIPT_DIR" status --porcelain) + if [ -n "$DIRTY" ]; then + echo "checkout-status: DIRTY, the binary may not match the commit" + echo "$DIRTY" + else + echo "checkout-status: clean" + fi + echo "binary: $("$BIN" --version)" + echo "binary-sha256: $(sha256sum "$BIN" | cut -d' ' -f1)" + echo "begin real-home $REAL_HOME" + list_real "$REAL_HOME" + echo "end real-home" + echo "begin real-config $REAL_CONF" + list_real "$REAL_CONF" + echo "end real-config" +} >> "$OUT/pins.txt" +grep -q '^checkout-status: DIRTY' "$OUT/pins.txt" \ + && echo "WARNING: the checkout is dirty, so the binary may not match the commit pins.txt names." + +# From here on this script's own baley and git commands see the disposable folders only. +XDG_DATA_HOME="$DATA" +XDG_CONFIG_HOME="$CONF" +export XDG_DATA_HOME XDG_CONFIG_HOME From 871debba8b65cb90371fb6d1bb231ee2840263cf Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Thu, 8 Oct 2026 10:08:16 -0400 Subject: [PATCH 02/39] feat(12-1): build the projects the sessions work in Two initialised projects, a fork that shares project one's id under another remote, and a large tracked capture, all with the guard's refuse setting committed, so the controls have something real to deny. --- spikes/host-matrix/live-claude.sh | 68 +++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/spikes/host-matrix/live-claude.sh b/spikes/host-matrix/live-claude.sh index 349a8b57..eb85fd49 100644 --- a/spikes/host-matrix/live-claude.sh +++ b/spikes/host-matrix/live-claude.sh @@ -161,3 +161,71 @@ grep -q '^checkout-status: DIRTY' "$OUT/pins.txt" \ XDG_DATA_HOME="$DATA" XDG_CONFIG_HOME="$CONF" export XDG_DATA_HOME XDG_CONFIG_HOME + +# The disposable repositories. Each carries its own identity and no signing, so the commits the agent +# is allowed to make depend on neither a global identity nor the owner's signing agent. Nothing here +# touches the global git config or this repository's. +mkrepo() { + git init -q -b main "$1" || exit 1 + git -C "$1" config user.name "Baley live run" + git -C "$1" config user.email "live-run@example.invalid" + git -C "$1" config commit.gpgsign false +} +project_id() { + sed -n 's/^id = "\(.*\)"$/\1/p' "$1/baley.toml" +} +# Ties a repository to a ledger project, keeps the command's output and exit status for the +# owner-init row, then commits the setting the guard reads: a commit on main is refused. +init_project() { + NAME=$1 + ( cd "$PROJ/$NAME" && "$BIN" init ) > "$OUT/init-$NAME.txt" 2>&1 + STATUS=$? + echo "$STATUS" > "$OUT/init-$NAME.status" + if [ "$STATUS" -ne 0 ]; then + echo "baley init failed in $PROJ/$NAME (exit $STATUS):" >&2 + cat "$OUT/init-$NAME.txt" >&2 + exit 1 + fi + printf '\n[git]\non_protected = "refuse"\n' >> "$PROJ/$NAME/baley.toml" + git -C "$PROJ/$NAME" add baley.toml + git -C "$PROJ/$NAME" commit -q -m "refuse commits on a protected branch" +} + +git init -q --bare -b main "$REM/one.git" || exit 1 +git init -q --bare -b main "$REM/fork.git" || exit 1 + +# Project one: the start folder of the user-scope session is its tracked sub folder, so the project and +# the working directory differ. fixtures/large.txt is plain ASCII of 33,000 bytes, a capture whose +# document read arrives in parts, the first a full 24,576 bytes. +mkrepo "$PROJ/one" +mkdir -p "$PROJ/one/sub" "$PROJ/one/fixtures" +echo "a tracked file in a sub folder" > "$PROJ/one/sub/note.txt" +echo "project one" > "$PROJ/one/README.md" +awk 'BEGIN { for (i = 1; i <= 600; i++) printf "line %04d: the quick brown fox jumps over the lazy dog\n", i }' > "$PROJ/one/fixtures/large.txt" +git -C "$PROJ/one" add -A +git -C "$PROJ/one" commit -q -m "initial" +git -C "$PROJ/one" remote add origin "$REM/one.git" +init_project one + +# Project two is the /cd target: initialised the same way, with no remote. +mkrepo "$PROJ/two" +echo "project two" > "$PROJ/two/README.md" +git -C "$PROJ/two" add -A +git -C "$PROJ/two" commit -q -m "initial" +init_project two + +# The fork is a clone of project one whose origin is another remote and which is not initialised +# again, so it shares project one's id under a different remote. +git clone -q "$PROJ/one" "$PROJ/fork" || exit 1 +git -C "$PROJ/fork" config user.name "Baley live run" +git -C "$PROJ/fork" config user.email "live-run@example.invalid" +git -C "$PROJ/fork" config commit.gpgsign false +git -C "$PROJ/fork" remote set-url origin "$REM/fork.git" + +ID_ONE=$(project_id "$PROJ/one") +ID_TWO=$(project_id "$PROJ/two") +{ + echo "project-one-id: $ID_ONE" + echo "project-two-id: $ID_TWO" + echo "project-fork-id: $(project_id "$PROJ/fork")" +} >> "$OUT/pins.txt" From 6e9753776d182711d78dd9e7cd392d14d38d2eb9 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Thu, 8 Oct 2026 10:09:00 -0400 Subject: [PATCH 03/39] feat(12-1): render the guard side from the binary and time every call The settings and the hook come from baley artifact byte for byte. The only changes are a wrapper that times each guard call and the fixture keys, so a latency number is the real hook's and not a replay of it. --- spikes/host-matrix/live-claude.sh | 83 +++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/spikes/host-matrix/live-claude.sh b/spikes/host-matrix/live-claude.sh index eb85fd49..0fbcd52c 100644 --- a/spikes/host-matrix/live-claude.sh +++ b/spikes/host-matrix/live-claude.sh @@ -229,3 +229,86 @@ ID_TWO=$(project_id "$PROJ/two") echo "project-two-id: $ID_TWO" echo "project-fork-id: $(project_id "$PROJ/fork")" } >> "$OUT/pins.txt" + +# The launch files and the placed stub the settings protect. T15's placement map would yield the same +# list, so the Edit rule and denyWrite cover them while the guard itself protects only the two +# baley.toml files until the placement projection is passed to it. +STUB="$CC/skills/bal-help/SKILL.md" +LAUNCH_FILES="mcp-explicit.json user-scope-entry.json mcp-invalid-project.json mcp-missing-project.json mcp-no-session-id.json mcp-fork.json" + +# Prints one artifact exactly as the binary renders it into $1. A refusal or a partial settings render +# exits non-zero, and then the script stops with the standard error shown, so partial settings are +# never composed. +render() { + TARGET=$1; shift + if ! "$BIN" artifact "$@" > "$TARGET" 2> "$OUT/artifact-stderr.txt"; then + echo "baley artifact $* failed:" >&2 + cat "$OUT/artifact-stderr.txt" >&2 + exit 1 + fi + rm -f "$OUT/artifact-stderr.txt" +} + +# The homes given are the resolved folders, not the XDG roots, because they are what the server, the +# hook and an installed configuration name. +render "$REN/hook.json" hook --executable "$BIN" +set -- settings --executable "$BIN" --home "$HOMEF" --config "$CONFF" \ + --protect "$PROJ/one/baley.toml" --protect "$PROJ/two/baley.toml" \ + --protect "$STUB" --protect "$OUT/settings.json" +for FILE in $LAUNCH_FILES; do set -- "$@" --protect "$OUT/$FILE"; done +render "$REN/settings.json" "$@" +render "$REN/manifest.json" manifest + +# Times each guard call. It keeps the call as it arrived, runs the rendered hook command with that +# call as standard input, passes the guard's output and exit status through unchanged, and appends +# one line to hook-timing.jsonl. The line holds no tool_input: the stand-in probe is the per-tool +# field capture. Tool paths are fixed now, so a session with a narrowed PATH still times its calls. +JQ_BIN=$(command -v jq) +DATE_BIN=$(command -v date) +CAT_BIN=$(command -v cat) +MV_BIN=$(command -v mv) +cat > "$ROOT/bin/guard-timed.sh" <> "$ROOT/bin/guard-timed.sh" <<'WRAP' +PENDING="$OUT/hook-calls/pending.$$" +$CAT > "$PENDING" +TOOL=$($JQ -r '.tool_name // empty' "$PENDING" 2>/dev/null) +ID=$($JQ -r '.tool_use_id // empty' "$PENDING" 2>/dev/null) +SESSION=$($JQ -r '.session_id // empty' "$PENDING" 2>/dev/null) +CWD=$($JQ -r '.cwd // empty' "$PENDING" 2>/dev/null) +CALL="$OUT/hook-calls/${ID:-none}-$$.json" +$MV "$PENDING" "$CALL" +COMMAND=$($JQ -r '.hooks.PreToolUse[0].hooks[0].command' "$OUT/rendered/hook.json") +T0=$($DATE +%s%N) +/bin/sh -c "$COMMAND" < "$CALL" > "$CALL.out" 2> "$CALL.err" +STATUS=$? +T1=$($DATE +%s%N) +DECISION=$($JQ -r '.hookSpecificOutput.permissionDecision // empty' "$CALL.out" 2>/dev/null) +$JQ -nc --arg tool "$TOOL" --arg id "$ID" --arg session "$SESSION" --arg cwd "$CWD" \ + --argjson start "$((T0 / 1000000))" --argjson end "$((T1 / 1000000))" \ + --argjson elapsed "$(((T1 - T0) / 1000000))" --argjson status "$STATUS" --arg decision "$DECISION" \ + '{tool_name: $tool, tool_use_id: $id, session_id: $session, cwd: $cwd, start_ms: $start, end_ms: $end, elapsed_ms: $elapsed, exit: $status} + + (if $decision == "" then {} else {permissionDecision: $decision} end)' >> "$OUT/hook-timing.jsonl" +$CAT "$CALL.out" +$CAT "$CALL.err" >&2 +exit "$STATUS" +WRAP +chmod 700 "$ROOT/bin/guard-timed.sh" + +# The settings the sessions load: the rendered settings and the rendered hook with two kinds of change. +# The hook command is the timed wrapper, with its matcher and timeout as rendered. The fixture keys are +# allowWrite over the disposable tree, so a refused write to the home or config comes from denyWrite and +# not Claude Code's default boundary (an allow entry above a protected folder is not a gap to the +# coverage judge, crates/baley/src/host_artifacts/coverage.rs), and the PowerShell switch. +jq -n --slurpfile settings "$REN/settings.json" --slurpfile hook "$REN/hook.json" \ + --arg wrapper "'$ROOT/bin/guard-timed.sh'" --arg root "$ROOT" \ + '$settings[0] + $hook[0] + | .hooks.PreToolUse[0].hooks[0].command = $wrapper + | .sandbox.filesystem.allowWrite = [$root] + | .env = {"CLAUDE_CODE_USE_POWERSHELL_TOOL": "1"}' > "$OUT/settings.json" || exit 1 From d21da6bbef5a66555fad722e6397bc06e72b6ee5 Mon Sep 17 00:00:00 2001 From: John Crenshaw Date: Thu, 8 Oct 2026 10:10:06 -0400 Subject: [PATCH 04/39] feat(12-1): render the server side and print every session's launch Each launch file wraps the rendered server command so its standard error lands in a file, which is the only way an exit checkpoint line survives a stdio server. Nothing starts claude, the script only prints what the owner types. --- spikes/host-matrix/live-claude.sh | 98 +++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/spikes/host-matrix/live-claude.sh b/spikes/host-matrix/live-claude.sh index 0fbcd52c..a86c1420 100644 --- a/spikes/host-matrix/live-claude.sh +++ b/spikes/host-matrix/live-claude.sh @@ -312,3 +312,101 @@ jq -n --slurpfile settings "$REN/settings.json" --slurpfile hook "$REN/hook.json | .hooks.PreToolUse[0].hooks[0].command = $wrapper | .sandbox.filesystem.allowWrite = [$root] | .env = {"CLAUDE_CODE_USE_POWERSHELL_TOOL": "1"}' > "$OUT/settings.json" || exit 1 + +# The server side, rendered unchanged. +render "$REN/registration.json" registration --executable "$BIN" +render "$REN/registration-always-load.json" registration --executable "$BIN" --always-load +render "$REN/stub-bal-help.md" stub bal-help + +# Each launch file is one mcpServers object holding the single key baley, so tool names match +# production. The entry runs /bin/sh -c with a script that appends a start line (UTC time, process id) +# to results/server-stderr/.log and then executes the rendered command and arguments with +# standard error appended to that file, because a stdio server's standard error is not otherwise kept. +# The rendered command and arguments ride as positional parameters, so they are never retyped. Every +# other key of the rendered entry, alwaysLoad included, is kept. +server_script() { + cat <