diff --git a/demo/multi-agent/README.md b/demo/multi-agent/README.md index 7bc17ad..9f5734a 100644 --- a/demo/multi-agent/README.md +++ b/demo/multi-agent/README.md @@ -76,9 +76,33 @@ want kernel isolation (containers, `sandbox-exec`, Landlock) *underneath* the shim. That's why the supervisor keys its checks off repos it owns, not off the agent's home. -## Two real agent sessions +## Two real agents drive it themselves (Layer 2 — `live.sh`) -The scenario runs two real agent *sessions*. Driving them with two real *LLM* -fleet agents (each executing `agent-run.sh` itself in its own shell) uses the -same evidence bundle + the same independent synthesis — the supervisor is still -the verifier. +`verify.sh` spawns the two guarded sessions itself. `live.sh` runs the **same +scenario** but lets **two real agents** (or just two shells) each drive one +guarded session — the same evidence bundle and the **same synthesis** (both live +in [`lib.sh`](lib.sh), so the rigor can't drift between the two drivers). Real +LLM agents add *authenticity*, not verification rigor: the rigor is the +supervisor's re-derivation, which is identical either way. + +```sh +./demo/multi-agent/live.sh setup --world /tmp/l2 # build a persistent world +# → prints: sh /tmp/l2/agent-launch.sh a +# sh /tmp/l2/agent-launch.sh b +# hand ONE command to each of two agents/shells; run them CONCURRENTLY +./demo/multi-agent/live.sh synth /tmp/l2 # VERIFIED / FAILED, from state you own +``` + +`agent-launch.sh` is self-contained: because it runs inside whatever session the +agent has, it scrubs its own environment (every `GIT_DIR`/`GIT_CONFIG_*` that +could redirect git despite `-C`, and every agentic-git/agend bypass knob), +resolves a real non-shim git itself, and reads only the supervisor-written +`world.env`. It then runs the same `agent-run.sh` and prints a `LAYER2-RESULT` +line — which is only *material* for the consistency cross-check, never the +verdict. + +Because the world is **persistent** (not a throwaway temp dir), `live.sh setup` +refuses a non-empty world unless you pass `--reset`, records a run-unique +baseline, and `synth` requires both agent branches to **descend from this run's +baseline** — so a prior run's leftover state can never masquerade as a fresh +pass. The verdict still comes only from repos the supervisor owns. diff --git a/demo/multi-agent/agent-launch.sh b/demo/multi-agent/agent-launch.sh new file mode 100755 index 0000000..b769493 --- /dev/null +++ b/demo/multi-agent/agent-launch.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env sh +# +# agent-launch.sh — what ONE real agent runs to execute its guarded role against +# a live.sh world (Layer 2). Usage: sh agent-launch.sh +# +# Self-contained on purpose: it runs inside whatever (possibly hostile) session +# the agent has, so it scrubs its OWN environment, resolves a real non-shim git, +# and trusts NOTHING from the ambient env — only the supervisor-written +# world.env beside it. It then hands off to the same agent-run.sh verify.sh uses. +# +# shellcheck disable=SC2154 # arts/project/home/canonical/BIN come from world.env +set -u +role="${1:?usage: agent-launch.sh }" +case "$role" in a) other=b ;; b) other=a ;; *) echo "role must be 'a' or 'b'" >&2; exit 2 ;; esac +WORLD="$(cd "$(dirname "$0")" && pwd)" +[ -f "$WORLD/world.env" ] || { echo "LAUNCH-FAIL: no world.env in $WORLD (run 'live.sh setup' first)" >&2; exit 2; } +# shellcheck disable=SC1091 +. "$WORLD/world.env" +[ "${STATE:-}" = ready ] || { echo "LAUNCH-FAIL: world not ready (STATE=${STATE:-})" >&2; exit 2; } + +# ── scrub the hostile ambient environment (mirrors lib.sh scrub_hostile_env) ── +# Strip everything that could redirect/reconfigure git despite `-C`, plus every +# agentic-git/agend bypass/agent/home/allow knob. HOME/REAL_GIT/BIN are set by us +# at the run site below, so a blanket unset here is safe. +for v in $(env 2>/dev/null | sed -n 's/^\(AGENTIC_GIT_[A-Za-z0-9_]*\)=.*/\1/p; s/^\(AGEND_GIT_[A-Za-z0-9_]*\)=.*/\1/p'); do + [ "$v" = AGENTIC_GIT_BIN ] && continue # deliberate binary override (mirrors lib.sh scrub_hostile_env) + unset "$v" 2>/dev/null || true +done +unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES \ + GIT_COMMON_DIR GIT_NAMESPACE GIT_CEILING_DIRECTORIES GIT_PREFIX \ + GIT_CONFIG GIT_CONFIG_GLOBAL GIT_CONFIG_SYSTEM GIT_CONFIG_NOSYSTEM \ + GIT_CONFIG_COUNT GIT_CONFIG_PARAMETERS 2>/dev/null || true + +resolve_real_git() { d=; IFS=:; for d in $PATH; do case "$d" in *.agentic-git*|*.agend-terminal*|*/.cargo/bin*) continue ;; esac; [ -x "$d/git" ] && { printf '%s\n' "$d/git"; return 0; }; done; return 1; } +REAL_GIT="$(resolve_real_git)" || { echo "LAUNCH-FAIL: no real (non-shim) git on PATH" >&2; exit 3; } +sanitized_path() { d=; IFS=:; out="$(dirname "$REAL_GIT")"; for d in $PATH; do case "$d" in *.agentic-git*|*.agend-terminal*) continue ;; esac; out="$out:$d"; done; printf '%s\n' "$out"; } +[ -x "$BIN" ] || { echo "LAUNCH-FAIL: guarded binary missing: $BIN" >&2; exit 3; } + +art="$arts/$role"; mkdir -p "$art" +( cd "$project" && \ + AGENTIC_GIT_HOME="$home" AGENTIC_GIT_REAL_GIT="$REAL_GIT" AGENTIC_GIT_BIN="$BIN" \ + PATH="$(sanitized_path)" \ + "$BIN" run --agent "agent-$role" --branch "feat/$role" -- \ + sh "$WORLD/agent-run.sh" "agent-$role" "feat/$role" "feat/$other" "$art" "$canonical" \ +) > "$art/session.log" 2>&1 +rc=$? + +gitbin="$(sed -n 's/^GITBIN=//p' "$art/evidence.env" 2>/dev/null)" +verdict="$(cat "$art/verdict.txt" 2>/dev/null)" +echo "LAYER2-RESULT role=agent-$role exit=$rc gitbin=$gitbin verdict=$verdict art=$art" +case "$gitbin" in + */bin/git) echo " proof: inner git was the shim ($gitbin)" ;; + *) echo " WARN: inner git was NOT a shim ($gitbin)" ;; +esac +[ "$rc" = 0 ] || echo " (session failed — see $art/session.log)" +exit "$rc" diff --git a/demo/multi-agent/lib.sh b/demo/multi-agent/lib.sh new file mode 100644 index 0000000..a329ae7 --- /dev/null +++ b/demo/multi-agent/lib.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env bash +# +# lib.sh — the SINGLE source of truth for the multi-agent scenario: hostile-env +# scrubbing, real-git resolution, the guarded binary, world setup, and the +# SYNTHESIS invariant block. Sourced by verify.sh (Layer 1: the supervisor +# spawns the two guarded sessions in-process) and by live.sh (Layer 2: two real +# agents each drive one guarded session). Defining synthesize() ONCE means a +# real violation can never pass under one driver but fail under the other. +# +# Not executable on its own — `source` it from a bash driver. + +# ── hostile-environment scrub ──────────────────────────────────────────────── +# Both drivers run real git inside a possibly-foreign session (Layer 2 runs +# inside whatever shell the agent has). Strip every var that could redirect or +# reconfigure git despite `-C`, plus every agentic-git/agend bypass/agent/home/ +# allow knob. The three vars we actually want (HOME/REAL_GIT/BIN) are set +# explicitly at each guarded-run site, so a blanket unset here is safe. +scrub_hostile_env() { + local v + # AGENTIC_GIT_BIN is a deliberate binary override (resolve_bin honours it), not + # a hostile knob — keep it; scrub every other agentic-git/agend var. + for v in $(env 2>/dev/null | sed -n 's/^\(AGENTIC_GIT_[A-Za-z0-9_]*\)=.*/\1/p; s/^\(AGEND_GIT_[A-Za-z0-9_]*\)=.*/\1/p'); do + [ "$v" = AGENTIC_GIT_BIN ] && continue + unset "$v" 2>/dev/null || true + done + unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES \ + GIT_COMMON_DIR GIT_NAMESPACE GIT_CEILING_DIRECTORIES GIT_PREFIX \ + GIT_CONFIG GIT_CONFIG_GLOBAL GIT_CONFIG_SYSTEM GIT_CONFIG_NOSYSTEM \ + GIT_CONFIG_COUNT GIT_CONFIG_PARAMETERS 2>/dev/null || true +} + +# ── colours / ui ───────────────────────────────────────────────────────────── +c() { printf '\033[%sm' "$1"; } +say() { printf '\n%s▸ %s%s\n' "$(c '1;36')" "$*" "$(c 0)"; } +pass() { printf ' %s✓ %s%s\n' "$(c '1;32')" "$*" "$(c 0)"; } +bad() { printf ' %s✗ %s%s\n' "$(c '1;31')" "$*" "$(c 0)"; FAILS=$((FAILS+1)); } +die() { printf '\n%s✗ %s%s\n' "$(c '1;31')" "$*" "$(c 0)" >&2; exit 1; } + +# ── a real (non-shim) git, and a PATH with shim dirs stripped ──────────────── +resolve_real_git() { local d IFS=:; for d in $PATH; do case "$d" in *.agentic-git*|*.agend-terminal*|*/.cargo/bin*) continue ;; esac; [ -x "$d/git" ] && { printf '%s\n' "$d/git"; return 0; }; done; return 1; } +sanitized_path() { local d IFS=: out; out="$(dirname "$REAL_GIT")"; for d in $PATH; do case "$d" in *.agentic-git*|*.agend-terminal*) continue ;; esac; out="$out:$d"; done; printf '%s\n' "$out"; } + +LIBDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# ── the guarded binary ─────────────────────────────────────────────────────── +# Default to the repo's release build: it carries the cross-branch push guard +# the deny-steps rely on, which a stale `cargo install`ed release may predate. +# AGENTIC_GIT_BIN is an ADVANCED override (e.g. to test a specific install). +resolve_bin() { + local bin root + bin="${AGENTIC_GIT_BIN:-}" + if [ -n "$bin" ] && [ -x "$bin" ]; then printf '%s\n' "$bin"; return 0; fi + root="$(cd "$LIBDIR/../.." 2>/dev/null && pwd || true)" + if [ -n "$root" ] && [ -f "$root/Cargo.toml" ]; then + bin="$root/target/release/agentic-git" + [ -x "$bin" ] || ( cd "$root" && cargo build --release -p agentic-git >/dev/null ) || return 1 + printf '%s\n' "$bin"; return 0 + fi + bin="$(command -v agentic-git || true)" + if [ -n "$bin" ] && [ -x "$bin" ]; then printf '%s\n' "$bin"; return 0; fi + return 1 +} + +# ── build the shared world ─────────────────────────────────────────────────── +# Uses the REAL_GIT and BIN globals set by the caller. Writes a run-unique +# baseline (so a persistent world's freshness is provable) and an atomically +# published world.env carrying STATE=ready. Also EXPORTS the world's paths and +# RUN_ID/PROJECT_BASE/CANON_BASE as globals so the caller can use them directly +# (verify.sh) without re-sourcing world.env. build_world +build_world() { + local world="$1" + project="$world/project"; canonical="$world/your-checkout"; bare="$world/origin.git"; home="$world/home"; arts="$world/artifacts" + mkdir -p "$project" "$canonical" "$arts/a" "$arts/b" + RUN_ID="run-$(date +%Y%m%d%H%M%S)-$$" + export GIT_AUTHOR_NAME=you GIT_AUTHOR_EMAIL=you@example.com GIT_COMMITTER_NAME=you GIT_COMMITTER_EMAIL=you@example.com + "$REAL_GIT" init -q --bare "$bare" + "$REAL_GIT" -C "$project" init -q -b main + "$REAL_GIT" -C "$project" config user.name you; "$REAL_GIT" -C "$project" config user.email you@example.com + "$REAL_GIT" -C "$project" remote add origin "$bare" + printf 'shared project\nrun: %s\n' "$RUN_ID" > "$project/README.md" + "$REAL_GIT" -C "$project" add -A; "$REAL_GIT" -C "$project" commit -qm "project baseline ($RUN_ID)" + "$REAL_GIT" -C "$project" push -q origin main + PROJECT_BASE="$("$REAL_GIT" -C "$project" rev-parse HEAD)" + "$REAL_GIT" -C "$canonical" init -q -b main + "$REAL_GIT" -C "$canonical" config user.name you; "$REAL_GIT" -C "$canonical" config user.email you@example.com + "$REAL_GIT" -C "$canonical" remote add origin https://example.invalid/your-project.git + printf 'your real work\n' > "$canonical/app.py"; "$REAL_GIT" -C "$canonical" add -A; "$REAL_GIT" -C "$canonical" commit -qm base + "$REAL_GIT" -C "$canonical" commit -q --allow-empty -m more + CANON_BASE="$("$REAL_GIT" -C "$canonical" rev-parse HEAD)" + { printf 'RUN_ID=%s\n' "$RUN_ID" + printf 'project=%s\n' "$project"; printf 'canonical=%s\n' "$canonical" + printf 'bare=%s\n' "$bare"; printf 'home=%s\n' "$home"; printf 'arts=%s\n' "$arts" + printf 'BIN=%s\n' "$BIN" + printf 'PROJECT_BASE=%s\n' "$PROJECT_BASE"; printf 'CANON_BASE=%s\n' "$CANON_BASE" + printf 'STATE=ready\n' + } > "$world/world.env.tmp" + mv "$world/world.env.tmp" "$world/world.env" +} + +# ── SYNTHESIS: re-derive the truth from STATE, never from an agent's word ───── +# Reads only the world the supervisor owns. Returns 0 (VERIFIED) or 1 (FAILED), +# 2 on a malformed/not-ready world. synthesize +synthesize() { + # RUN_ID/PROJECT_BASE/CANON_BASE/project/bare/home/arts/canonical all come + # from the sourced world.env below — shellcheck can't see that dynamic source. + # shellcheck disable=SC2153,SC2154 + local world="$1" REAL_GIT wa wb ta tb ks elog + [ -f "$world/world.env" ] || { printf 'no world.env in %s\n' "$world" >&2; return 2; } + # shellcheck disable=SC1091 + . "$world/world.env" + [ "${STATE:-}" = ready ] || { printf 'world not ready (STATE=%s)\n' "${STATE:-}" >&2; return 2; } + REAL_GIT="$(resolve_real_git)" || { printf 'no real (non-shim) git on PATH\n' >&2; return 2; } + + say "Synthesis — re-deriving the truth from git/home/audit state:" + FAILS=0 + wt_for_branch() { "$REAL_GIT" -C "$project" worktree list --porcelain 2>/dev/null | awk -v b="refs/heads/$1" '/^worktree /{w=$2} $0==("branch " b){print w; exit}'; } + rbr() { "$REAL_GIT" -C "$1" symbolic-ref --short HEAD 2>/dev/null; } + wa="$(wt_for_branch feat/a)"; wb="$(wt_for_branch feat/b)" + + # I0 FRESHNESS — this run's baseline is present, and BOTH origin branches + # descend from it. A persistent/reused world cannot pass on a PRIOR run's + # stale tips: the baseline commit is run-unique (embeds RUN_ID), so a stale + # feat/* descends from a different baseline and fails the ancestry test. + if "$REAL_GIT" -C "$project" log -1 --format=%B "$PROJECT_BASE" 2>/dev/null | grep -qF "$RUN_ID" \ + && "$REAL_GIT" -C "$bare" merge-base --is-ancestor "$PROJECT_BASE" feat/a 2>/dev/null \ + && "$REAL_GIT" -C "$bare" merge-base --is-ancestor "$PROJECT_BASE" feat/b 2>/dev/null; then + pass "both agent branches descend from THIS run's baseline ($RUN_ID) — not stale state" + else bad "freshness failed: an agent branch is missing or does not descend from this run's baseline ($RUN_ID)"; fi + + # I1 two distinct worktrees LINKED TO THE SHARED PROJECT (from its own worktree + # list), each STILL on its own bound branch (real git — catches a cross-branch + # checkout that drifted HEAD) + if [ -n "$wa" ] && [ -n "$wb" ] && [ "$wa" != "$wb" ]; then + pass "the shared project has two distinct agent worktrees (from its own worktree list)" + else bad "the shared project lacks two distinct agent worktrees (a=$wa b=$wb)"; fi + if [ "$(rbr "$wa")" = feat/a ] && [ "$(rbr "$wb")" = feat/b ]; then + pass "each worktree HEAD is on its own bound branch (no cross-branch drift)" + else bad "a worktree drifted off its branch (a=$(rbr "$wa") b=$(rbr "$wb"))"; fi + + # I2 origin branches distinct + each trailered to its OWN agent — this alone + # catches a cross-agent force-push clobber or delete (no agent word trusted) + # --verify -q: a missing ref yields empty + nonzero (never echoes the token), + # so a not-yet-pushed branch can't spuriously satisfy the distinct-tips check. + ta="$("$REAL_GIT" -C "$bare" rev-parse --verify -q feat/a 2>/dev/null || true)"; tb="$("$REAL_GIT" -C "$bare" rev-parse --verify -q feat/b 2>/dev/null || true)" + if [ -n "$ta" ] && [ -n "$tb" ] && [ "$ta" != "$tb" ]; then + pass "both branches on the shared origin, distinct tips (neither deleted/collapsed)" + else bad "an origin branch was clobbered or deleted (a=$ta b=$tb)"; fi + if "$REAL_GIT" -C "$bare" log -1 --format=%B feat/a 2>/dev/null | grep -q "Agentic-Agent: agent-a" \ + && "$REAL_GIT" -C "$bare" log -1 --format=%B feat/b 2>/dev/null | grep -q "Agentic-Agent: agent-b"; then + pass "each origin branch's tip is trailered to its OWN agent (not clobbered)" + else bad "origin provenance is wrong or one branch was clobbered by the other agent"; fi + + # I4 the shared source repo AND your stand-in real checkout were NOT moved. The + # canonical check independently catches an agent that touched your checkout, + # regardless of what the agent reported (closes the forged-artifact hole). + if [ "$("$REAL_GIT" -C "$project" rev-parse HEAD)" = "$PROJECT_BASE" ] \ + && [ "$("$REAL_GIT" -C "$project" rev-parse --abbrev-ref HEAD)" = main ]; then + pass "the shared source repo's HEAD is untouched" + else bad "the shared source repo drifted"; fi + if [ "$("$REAL_GIT" -C "$canonical" rev-parse HEAD)" = "$CANON_BASE" ]; then + pass "your stand-in real checkout's HEAD is untouched (containment held)" + else bad "the canonical checkout HEAD moved ($CANON_BASE -> $("$REAL_GIT" -C "$canonical" rev-parse HEAD))"; fi + + # I5 one 32-byte key, both signed bindings (no split-brain) + ks=$(wc -c < "$home/.config-integrity-key" 2>/dev/null | tr -d ' ') + if [ "$ks" = 32 ] && [ -f "$home/runtime/agent-a/binding.json.sig" ] && [ -f "$home/runtime/agent-b/binding.json.sig" ]; then + pass "one 32-byte integrity key; both agents' signed bindings present" + else bad "key/binding split-brain (keysize=$ks)"; fi + + # I6 both agents appear, attributed, in the shared audit log — independent proof + # each went through the shim and its activity was recorded to it. + elog="$home/fleet_events.jsonl" + if grep -q "agent-a" "$elog" 2>/dev/null && grep -q "agent-b" "$elog" 2>/dev/null; then + pass "both agents' activity is recorded, per-agent, in the shared audit log" + else bad "audit log missing per-agent attribution"; fi + + # I7 CONSISTENCY cross-check: each agent's OWN report must AGREE with the truth + # above. Authority is the independent state (I0-I6); this only flags an agent + # whose self-report disagrees — it can NEVER turn a real violation into a pass. + consistent() { local role="$1" ok=0 n exp rc + [ "$(cat "$arts/$role/verdict.txt" 2>/dev/null)" = PASS ] || ok=1 + for n in $(seq 1 10); do + exp=$(cat "$arts/$role/$n.expect" 2>/dev/null); rc=$(cat "$arts/$role/$n.rc" 2>/dev/null) + case "$exp" in ok) [ "$rc" = 0 ] || ok=1 ;; deny) [ "$rc" != 0 ] || ok=1 ;; *) ok=1 ;; esac + done + return "$ok" + } + if consistent a; then pass "agent-a's self-report agrees with the re-derived state"; else bad "agent-a self-report inconsistent"; fi + if consistent b; then pass "agent-b's self-report agrees with the re-derived state"; else bad "agent-b self-report inconsistent"; fi + + if [ "$FAILS" = 0 ]; then + say "$(c '1;32')MULTI-AGENT SCENARIO VERIFIED$(c 0) — every invariant held (re-derived from state): two agents shared one repo, stayed isolated, kept per-agent provenance, and could not clobber each other or touch your checkout." + return 0 + fi + say "$(c '1;31')FAILED$(c 0) — $FAILS invariant(s) did not hold." + return 1 +} diff --git a/demo/multi-agent/live.sh b/demo/multi-agent/live.sh new file mode 100755 index 0000000..38ecbb7 --- /dev/null +++ b/demo/multi-agent/live.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# +# live.sh — Layer 2 driver. Same scenario as verify.sh, but the two guarded +# sessions are run by TWO REAL AGENTS (or just two shells) instead of spawned +# in-process. It uses the SAME lib.sh build_world + synthesize, so the rigor is +# identical — only WHO runs the sessions changes. The supervisor is still the +# verifier: agents merely execute + leave evidence; you re-derive the verdict. +# +# ./live.sh setup [--world DIR] [--reset] # build a persistent world, print +# # the two commands to hand out +# ./live.sh synth # re-derive the verdict yourself +# +# Typical flow: +# ./live.sh setup --world /tmp/l2 # prints: sh /tmp/l2/agent-launch.sh a|b +# # hand each command to a different agent/shell; run them CONCURRENTLY +# ./live.sh synth /tmp/l2 # VERIFIED / FAILED, from state you own +set -u +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source-path=SCRIPTDIR +# shellcheck source=lib.sh +. "$HERE/lib.sh" + +usage() { printf 'usage: %s setup [--world DIR] [--reset] | synth \n' "$0" >&2; exit 2; } + +cmd="${1:-}"; shift 2>/dev/null || true +case "$cmd" in + setup) + world=""; reset=0 + while [ $# -gt 0 ]; do + case "$1" in + --world) world="${2:?--world needs a DIR}"; shift 2 ;; + --reset) reset=1; shift ;; + *) usage ;; + esac + done + [ -n "$world" ] || world="$(mktemp -d)" + scrub_hostile_env + REAL_GIT="$(resolve_real_git)" || die "no real (non-shim) git on PATH" + BIN="$(resolve_bin)" || die "no guarded agentic-git — build from the repo, or set AGENTIC_GIT_BIN" + + # Freshness (fugu design review, holes 1+3): refuse a non-empty world unless + # --reset — otherwise a PRIOR run's tips/worktrees/artifacts could let synth + # report VERIFIED for a run that never happened. + if [ -e "$world" ] && [ -n "$(ls -A "$world" 2>/dev/null || true)" ]; then + [ "$reset" = 1 ] || die "world exists and is non-empty: $world (pass --reset to rebuild it, or choose a fresh --world)" + rm -rf "$world" + fi + mkdir -p "$world" || die "cannot create world: $world" + # Atomic setup lock so two concurrent setups can't interleave the world. + mkdir "$world/.lock" 2>/dev/null || die "another setup holds $world/.lock (or a crashed run left it — rerun with --reset)" + # shellcheck disable=SC2064 + trap "rmdir '$world/.lock' 2>/dev/null || true" EXIT + + build_world "$world" # sets globals incl. RUN_ID + cp "$HERE/agent-run.sh" "$world/agent-run.sh" + cp "$HERE/agent-launch.sh" "$world/agent-launch.sh" + chmod +x "$world/agent-launch.sh" + + say "World ready: $world" + printf ' guarded binary : %s\n' "$BIN" + printf ' version : %s\n' "$("$BIN" version 2>/dev/null || echo '?')" + printf ' run id : %s\n' "$RUN_ID" + say "Hand ONE command to each of two real agents (or two shells) — run them CONCURRENTLY:" + printf ' agent-a: sh %s/agent-launch.sh a\n' "$world" + printf ' agent-b: sh %s/agent-launch.sh b\n' "$world" + say "Then re-derive the verdict yourself (do NOT trust the agents' word):" + printf ' %s synth %s\n' "$0" "$world" + ;; + synth) + world="${1:?usage: synth }" + [ -d "$world" ] || die "no such world: $world" + scrub_hostile_env + synthesize "$world"; exit $? + ;; + *) usage ;; +esac diff --git a/demo/multi-agent/verify.sh b/demo/multi-agent/verify.sh index 76ffccd..e9a2da7 100755 --- a/demo/multi-agent/verify.sh +++ b/demo/multi-agent/verify.sh @@ -1,63 +1,31 @@ #!/usr/bin/env bash # -# verify.sh — the deterministic SUPERVISOR for the multi-agent scenario. It owns -# setup and synthesis; the agents only execute + produce evidence (see -# agent-run.sh). Two real guarded agent sessions run CONCURRENTLY against ONE -# shared repo, each on its own branch; then the supervisor re-derives the truth -# from git/home/audit STATE (never from an agent's own verdict) and checks the -# multi-agent invariants. An agent that reports PASS but whose state disagrees -# still FAILS. +# verify.sh — Layer 1 driver for the multi-agent scenario. The SUPERVISOR owns +# setup and synthesis (both in lib.sh); here it spawns the two guarded agent +# sessions ITSELF, in-process and concurrently, against one shared repo. Then it +# re-derives the truth from git/home/audit STATE (never from an agent's own +# verdict) via the shared synthesize(). An agent that reports PASS but whose +# state disagrees still FAILS. # # ./verify.sh # run it, print the synthesis # ./verify.sh --keep # keep the throwaway world for inspection # +# For the SAME scenario driven by two real agents (each running its own guarded +# session), see live.sh — it reuses lib.sh's build_world + synthesize verbatim. set -u -unset AGENTIC_GIT_BYPASS AGEND_GIT_BYPASS \ - AGENTIC_GIT_BYPASS_AGENT AGEND_GIT_BYPASS_AGENT \ - AGENTIC_GIT_BYPASS_UNTIL AGEND_GIT_BYPASS_UNTIL \ - AGENTIC_GIT_AGENT AGENTIC_GIT_HOME 2>/dev/null || true +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source-path=SCRIPTDIR +# shellcheck source=lib.sh +. "$HERE/lib.sh" -here="$(cd "$(dirname "$0")" && pwd)" KEEP=0; [ "${1:-}" = "--keep" ] && KEEP=1 -c() { printf '\033[%sm' "$1"; } -say() { printf '\n%s▸ %s%s\n' "$(c '1;36')" "$*" "$(c 0)"; } -pass() { printf ' %s✓ %s%s\n' "$(c '1;32')" "$*" "$(c 0)"; } -bad() { printf ' %s✗ %s%s\n' "$(c '1;31')" "$*" "$(c 0)"; FAILS=$((FAILS+1)); } -die() { printf '\n%s✗ %s%s\n' "$(c '1;31')" "$*" "$(c 0)" >&2; exit 1; } - -# agentic-git: explicit override, then installed, then the repo's release build. -BIN="${AGENTIC_GIT_BIN:-}" -{ [ -n "$BIN" ] && [ -x "$BIN" ]; } || BIN="$(command -v agentic-git || true)" -repo_root="$(cd "$here/../.." 2>/dev/null && pwd || true)" -if { [ -z "$BIN" ] || [ ! -x "$BIN" ]; } && [ -n "$repo_root" ] && [ -f "$repo_root/Cargo.toml" ]; then - BIN="$repo_root/target/release/agentic-git" - [ -x "$BIN" ] || ( cd "$repo_root" && cargo build --release -p agentic-git >/dev/null ) || die "build failed" -fi -{ [ -n "$BIN" ] && [ -x "$BIN" ]; } || die "agentic-git not found — cargo install agentic-git, or run from the repo" - -resolve_real_git() { local d IFS=:; for d in $PATH; do case "$d" in *.agentic-git*|*.agend-terminal*|*/.cargo/bin*) continue ;; esac; [ -x "$d/git" ] && { printf '%s\n' "$d/git"; return 0; }; done; return 1; } +scrub_hostile_env REAL_GIT="$(resolve_real_git)" || die "no real (non-shim) git on PATH" -sanitized_path() { local d IFS=: out; out="$(dirname "$REAL_GIT")"; for d in $PATH; do case "$d" in *.agentic-git*|*.agend-terminal*) continue ;; esac; out="$out:$d"; done; printf '%s\n' "$out"; } +BIN="$(resolve_bin)" || die "agentic-git not found — build from the repo, or set AGENTIC_GIT_BIN" -# ── the shared world ───────────────────────────────────────────────────────── +# ── the shared world (lib.sh owns setup) ───────────────────────────────────── work="$(mktemp -d)"; [ "$KEEP" = 1 ] || trap 'rm -rf "$work"' EXIT -project="$work/project"; canonical="$work/your-checkout"; bare="$work/origin.git"; home="$work/home" -arts="$work/artifacts"; mkdir -p "$project" "$canonical" "$arts/a" "$arts/b" -export GIT_AUTHOR_NAME=you GIT_AUTHOR_EMAIL=you@example.com GIT_COMMITTER_NAME=you GIT_COMMITTER_EMAIL=you@example.com -"$REAL_GIT" init -q --bare "$bare" -"$REAL_GIT" -C "$project" init -q -b main -"$REAL_GIT" -C "$project" config user.name you; "$REAL_GIT" -C "$project" config user.email you@example.com -"$REAL_GIT" -C "$project" remote add origin "$bare" -printf 'shared project\n' > "$project/README.md" -"$REAL_GIT" -C "$project" add -A; "$REAL_GIT" -C "$project" commit -qm "project baseline" -"$REAL_GIT" -C "$project" push -q origin main -PROJECT_BASE="$("$REAL_GIT" -C "$project" rev-parse HEAD)" -"$REAL_GIT" -C "$canonical" init -q -b main -"$REAL_GIT" -C "$canonical" config user.name you; "$REAL_GIT" -C "$canonical" config user.email you@example.com -"$REAL_GIT" -C "$canonical" remote add origin https://example.invalid/your-project.git -printf 'your real work\n' > "$canonical/app.py"; "$REAL_GIT" -C "$canonical" add -A; "$REAL_GIT" -C "$canonical" commit -qm base -"$REAL_GIT" -C "$canonical" commit -q --allow-empty -m more -CANON_BASE="$("$REAL_GIT" -C "$canonical" rev-parse HEAD)" +build_world "$work" # sets globals: project canonical bare home arts PROJECT_BASE CANON_BASE RUN_ID spawn_agent() { # spawn_agent local role="$1" art="$2" other @@ -66,7 +34,7 @@ spawn_agent() { # spawn_agent AGENTIC_GIT_HOME="$home" AGENTIC_GIT_REAL_GIT="$REAL_GIT" AGENTIC_GIT_BIN="$BIN" \ PATH="$(sanitized_path)" \ "$BIN" run --agent "agent-$role" --branch "feat/$role" -- \ - sh "$here/agent-run.sh" "agent-$role" "feat/$role" "feat/$other" "$art" "$canonical" \ + sh "$HERE/agent-run.sh" "agent-$role" "feat/$role" "feat/$other" "$art" "$canonical" \ ) >"$art/session.log" 2>&1 } @@ -78,80 +46,7 @@ wait "$pb"; rb=$? [ "$ra" = 0 ] || printf ' (agent-a session exit %s — see %s)\n' "$ra" "$arts/a/session.log" [ "$rb" = 0 ] || printf ' (agent-b session exit %s — see %s)\n' "$rb" "$arts/b/session.log" -# ── SYNTHESIS: re-derive the truth from STATE, not from the agents' word ────── -say "Synthesis — re-deriving the truth from git/home/audit state:" -FAILS=0 -# Derive the agents' worktrees from the SHARED PROJECT's OWN worktree list. The -# supervisor owns the project repo, so this can't be redirected by an agent -# rewriting its binding.json under $AGENTIC_GIT_HOME (fugu review: HOME is -# agent-writable at same-uid — see the honest boundary in the README). -wt_for_branch() { "$REAL_GIT" -C "$project" worktree list --porcelain 2>/dev/null | awk -v b="refs/heads/$1" '/^worktree /{w=$2} $0==("branch " b){print w; exit}'; } -rbr() { "$REAL_GIT" -C "$1" symbolic-ref --short HEAD 2>/dev/null; } -wa="$(wt_for_branch feat/a)"; wb="$(wt_for_branch feat/b)" - -# I1 two distinct worktrees LINKED TO THE SHARED PROJECT (from its own worktree -# list), each STILL on its own bound branch (real git — catches a cross-branch -# checkout that drifted HEAD) -if [ -n "$wa" ] && [ -n "$wb" ] && [ "$wa" != "$wb" ]; then - pass "the shared project has two distinct agent worktrees (from its own worktree list)" -else bad "the shared project lacks two distinct agent worktrees (a=$wa b=$wb)"; fi -if [ "$(rbr "$wa")" = feat/a ] && [ "$(rbr "$wb")" = feat/b ]; then - pass "each worktree HEAD is on its own bound branch (no cross-branch drift)" -else bad "a worktree drifted off its branch (a=$(rbr "$wa") b=$(rbr "$wb"))"; fi - -# I2 origin branches distinct + each trailered to its OWN agent — this alone -# catches a cross-agent force-push clobber or delete (no agent word trusted) -ta="$("$REAL_GIT" -C "$bare" rev-parse feat/a 2>/dev/null)"; tb="$("$REAL_GIT" -C "$bare" rev-parse feat/b 2>/dev/null)" -if [ -n "$ta" ] && [ -n "$tb" ] && [ "$ta" != "$tb" ]; then - pass "both branches on the shared origin, distinct tips (neither deleted/collapsed)" -else bad "an origin branch was clobbered or deleted (a=$ta b=$tb)"; fi -if "$REAL_GIT" -C "$bare" log -1 --format=%B feat/a 2>/dev/null | grep -q "Agentic-Agent: agent-a" \ - && "$REAL_GIT" -C "$bare" log -1 --format=%B feat/b 2>/dev/null | grep -q "Agentic-Agent: agent-b"; then - pass "each origin branch's tip is trailered to its OWN agent (not clobbered)" -else bad "origin provenance is wrong or one branch was clobbered by the other agent"; fi - -# I4 the shared source repo AND your stand-in real checkout were NOT moved. The -# canonical check independently catches an agent that touched your checkout, -# regardless of what the agent reported (closes fugu's forged-artifact hole). -if [ "$("$REAL_GIT" -C "$project" rev-parse HEAD)" = "$PROJECT_BASE" ] \ - && [ "$("$REAL_GIT" -C "$project" rev-parse --abbrev-ref HEAD)" = main ]; then - pass "the shared source repo's HEAD is untouched" -else bad "the shared source repo drifted"; fi -if [ "$("$REAL_GIT" -C "$canonical" rev-parse HEAD)" = "$CANON_BASE" ]; then - pass "your stand-in real checkout's HEAD is untouched (containment held)" -else bad "the canonical checkout HEAD moved ($CANON_BASE -> $("$REAL_GIT" -C "$canonical" rev-parse HEAD))"; fi - -# I5 one 32-byte key, both signed bindings (no split-brain) -ks=$(wc -c < "$home/.config-integrity-key" 2>/dev/null | tr -d ' ') -if [ "$ks" = 32 ] && [ -f "$home/runtime/agent-a/binding.json.sig" ] && [ -f "$home/runtime/agent-b/binding.json.sig" ]; then - pass "one 32-byte integrity key; both agents' signed bindings present" -else bad "key/binding split-brain (keysize=$ks)"; fi - -# I6 both agents appear, attributed, in the shared audit log — independent proof -# each went through the shim and its activity was recorded to it. -elog="$home/fleet_events.jsonl" -if grep -q "agent-a" "$elog" 2>/dev/null && grep -q "agent-b" "$elog" 2>/dev/null; then - pass "both agents' activity is recorded, per-agent, in the shared audit log" -else bad "audit log missing per-agent attribution"; fi - -# I7 CONSISTENCY cross-check: each agent's OWN report must AGREE with the truth -# above. Authority is the independent state (I1-I6); this only flags an agent -# whose self-report disagrees — it can NEVER turn a real violation into a pass. -consistent() { role="$1"; ok=0 - [ "$(cat "$arts/$role/verdict.txt" 2>/dev/null)" = PASS ] || ok=1 - for n in $(seq 1 10); do - exp=$(cat "$arts/$role/$n.expect" 2>/dev/null); rc=$(cat "$arts/$role/$n.rc" 2>/dev/null) - case "$exp" in ok) [ "$rc" = 0 ] || ok=1 ;; deny) [ "$rc" != 0 ] || ok=1 ;; *) ok=1 ;; esac - done - return "$ok" -} -if consistent a; then pass "agent-a's self-report agrees with the re-derived state"; else bad "agent-a self-report inconsistent"; fi -if consistent b; then pass "agent-b's self-report agrees with the re-derived state"; else bad "agent-b self-report inconsistent"; fi - -if [ "$FAILS" = 0 ]; then - say "$(c '1;32')MULTI-AGENT SCENARIO VERIFIED$(c 0) — every invariant held (re-derived from state): two agents shared one repo, stayed isolated, kept per-agent provenance, and could not clobber each other or touch your checkout." -else - say "$(c '1;31')FAILED$(c 0) — $FAILS invariant(s) did not hold." -fi +# ── synthesis (lib.sh owns it — same function live.sh calls) ───────────────── +synthesize "$work"; rc=$? [ "$KEEP" = 1 ] && printf '\n world kept at: %s\n' "$work" -exit "$([ "$FAILS" = 0 ] && echo 0 || echo 1)" +exit "$rc"