#!/usr/bin/env bash set -uo pipefail # CASAN Plan-21 — hybrid install + `casan init` acceptance tests. # # Proves the codegraph-style flow: global install once, then per-project # `casan init` that writes CONFIG ONLY (no harness copy), with a pin+verify # integrity guarantee on the shared harness. Deterministic, offline. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" PASS=0; FAIL=0 pass() { echo "PASS: $1"; PASS=$((PASS + 1)); } fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); } WORK="$(mktemp -d)" export CASAN_HOME="$WORK/home/.casan" export CASAN_SRC="$REPO_ROOT" export CASAN_NO_PATH_LINK=1 cleanup() { rm -rf "$WORK"; } trap cleanup EXIT CASAN="$CASAN_HOME/bin/casan" echo "===== ① global install =====" if sh "$REPO_ROOT/install.sh" >/dev/null 2>&1; then pass "install.sh completes"; else fail "install.sh failed"; fi [[ -x "$CASAN" ]] && pass "global launcher created" || fail "launcher missing" [[ -f "$CASAN_HOME/current/.harness-hash" ]] && pass "integrity hash recorded at install" || fail "no .harness-hash" "$CASAN" version >/dev/null 2>&1 && pass "casan version works via launcher" || fail "casan version failed" python3 - "$CASAN_HOME/current/packages/casan-devkit/casan-init.py" <<'PY' \ && pass "client selector accepts menu numbers, aliases, repeats, and all" \ || fail "client selector normalization failed" import importlib.util,sys spec=importlib.util.spec_from_file_location("casan_init",sys.argv[1]) m=importlib.util.module_from_spec(spec); spec.loader.exec_module(m) assert m.select_clients(["1,3"],False)==["claude","vscode-copilot"] assert m.select_clients(["codex","copilot"],False)==["codex","vscode-copilot"] assert m.select_clients(["all"],False)==["claude","codex","vscode-copilot"] PY echo "===== ② casan init (config only, no harness copy) =====" PROJ="$WORK/proj/my-app"; mkdir -p "$PROJ"; echo '{"name":"x"}' > "$PROJ/package.json" ( cd "$PROJ" && "$CASAN" init --project my-app --mode enforce >/dev/null 2>&1 ) \ && pass "casan init completes" || fail "casan init failed" for f in .casan/config.json .casan/version.lock .casan/agentic.env .casan/casan-hook.py .claude/settings.json .codex/hooks.json .vscode/extensions.json .specify/.gitignore; do [[ -f "$PROJ/$f" ]] && pass "init wrote $f" || fail "init missing $f" done if [[ -d "$PROJ/packages/casan-harness" ]]; then fail "harness was copied into the repo (should not be)"; else pass "harness NOT copied into repo (hybrid model)"; fi if grep -R -q 'packages/casan-harness/adapters' "$PROJ/.claude/settings.json" "$PROJ/.codex/hooks.json"; then fail "generated hooks still target a repo-local harness"; else pass "generated hooks target the stable project bootstrap"; fi echo "===== ②b generated hooks execute through the GLOBAL harness =====" CLAUDE_BEGIN=$(printf '%s' '{"session_id":"hybrid-claude","prompt_id":"t1","prompt":"add a safe helper","cwd":"'"$PROJ"'"}' | CASAN_APP_ROOT="$PROJ" python3 "$PROJ/.casan/casan-hook.py" --client claude --event UserPromptSubmit) echo "$CLAUDE_BEGIN" | grep -q '"additionalContext"' && pass "Claude generated hook opens admission" || fail "Claude generated hook failed ($CLAUDE_BEGIN)" CLAUDE_PRE=$(printf '%s' '{"session_id":"hybrid-claude","tool_name":"Bash","tool_input":{"command":"printf ok"},"cwd":"'"$PROJ"'"}' | CASAN_APP_ROOT="$PROJ" python3 "$PROJ/.casan/casan-hook.py" --client claude --event PreToolUse) echo "$CLAUDE_PRE" | grep -q '"permissionDecision": "allow"' && pass "Claude generated hook gates an admitted tool" || fail "Claude pre-tool failed ($CLAUDE_PRE)" CODEX_BEGIN=$(printf '%s' '{"session_id":"hybrid-codex","turn_id":"t2","prompt":"review this project","cwd":"'"$PROJ"'"}' | CASAN_APP_ROOT="$PROJ" python3 "$PROJ/.casan/casan-hook.py" --client codex --event UserPromptSubmit) echo "$CODEX_BEGIN" | grep -q '"continue": true' && pass "Codex generated hook opens admission" || fail "Codex generated hook failed ($CODEX_BEGIN)" python3 - "$PROJ/.codex/hooks.json" <<'PY' && pass "Codex hook JSON matches the current nested command schema" || fail "Codex hook JSON schema is stale" import json,sys d=json.load(open(sys.argv[1])) handlers=[h for groups in d["hooks"].values() for group in groups for h in group["hooks"]] assert handlers and all(h.get("type") == "command" for h in handlers) assert all(isinstance(h.get("command"), str) for h in handlers) assert all(isinstance(h.get("timeout"), int) and "timeout_ms" not in h for h in handlers) PY echo "===== ③ version.lock pins the installed harness =====" LOCK_HASH="$(python3 -c 'import json;print(json.load(open("'"$PROJ"'/.casan/version.lock"))["harness_hash"])')" REC_HASH="$(cat "$CASAN_HOME/current/.harness-hash")" [[ -n "$LOCK_HASH" && "$LOCK_HASH" == "$REC_HASH" ]] && pass "version.lock pins the installed gate-code hash" || fail "lock hash mismatch ($LOCK_HASH vs $REC_HASH)" echo "===== ④ verify-harness: ok before tamper, drift after =====" ( cd "$PROJ" && "$CASAN" verify-harness >/dev/null 2>&1 ) && pass "verify-harness OK on a clean install" || fail "verify-harness reported drift on clean install" cp "$CASAN_HOME/current/packages/casan-harness/scripts/bash/security-check.sh" "$WORK/security-check.clean" echo "# tampered $(date)" >> "$CASAN_HOME/current/packages/casan-harness/scripts/bash/security-check.sh" VRC=0; ( cd "$PROJ" && "$CASAN" verify-harness >/dev/null 2>"$WORK/vh.err" ) || VRC=$? [[ "$VRC" -eq 3 ]] && pass "verify-harness detects tamper (rc=3)" || fail "tamper not detected (rc=$VRC)" grep -q "HARNESS_INTEGRITY_DRIFT" "$WORK/vh.err" && pass "drift message emitted" || fail "no drift message" cp "$WORK/security-check.clean" "$CASAN_HOME/current/packages/casan-harness/scripts/bash/security-check.sh" cp "$CASAN_HOME/current/packages/casan-harness/adapters/codex/codex_hook.py" "$WORK/codex-hook.clean" echo "# adapter tamper" >> "$CASAN_HOME/current/packages/casan-harness/adapters/codex/codex_hook.py" ARC=0; ( cd "$PROJ" && "$CASAN" verify-harness >/dev/null 2>&1 ) || ARC=$? [[ "$ARC" -eq 3 ]] && pass "integrity pin includes client adapters" || fail "adapter tamper was not detected" cp "$WORK/codex-hook.clean" "$CASAN_HOME/current/packages/casan-harness/adapters/codex/codex_hook.py" echo "===== ⑤ agentic bridge runs against the PROJECT state via GLOBAL harness =====" BR="$CASAN_HOME/current/packages/casan-harness/scripts/python/agentic_bridge.py" B=$(CASAN_APP_ROOT="$PROJ" CASAN_AGENTIC_ENFORCEMENT_MODE=enforce \ bash -c 'echo '\''{"op":"begin","client":"claude-code","project":"'"$PROJ"'","session":"s","prompt":"add fn","integration_mode":"project_hook"}'\'' | python3 "'"$BR"'" run') echo "$B" | grep -q '"decision": "allow"' && pass "bridge admits a normal turn via the global harness" || fail "bridge begin failed ($B)" if find "$PROJ/.specify" -name 'turn-*.json' | grep -q .; then pass "admission state lands in the PROJECT .specify"; else fail "no admission state in project"; fi if [[ -d "$CASAN_HOME/current/.specify" ]]; then fail "runtime state leaked into the global install"; else pass "no runtime state in the global install"; fi echo "===== ⑥ init defaults project id from dir name + is re-runnable =====" PROJ2="$WORK/proj2/Some_App"; mkdir -p "$PROJ2" ( cd "$PROJ2" && "$CASAN" init >/dev/null 2>&1 ) && pass "init works with a defaulted project id" || fail "init default id failed" PID=$(python3 -c 'import json;print(json.load(open("'"$PROJ2"'/.casan/config.json"))["project_id"])' 2>/dev/null) [[ "$PID" =~ ^[a-z][a-z0-9-]{1,62}$ ]] && pass "defaulted project id is sanitized ($PID)" || fail "bad default project id ($PID)" MODE=$(python3 -c 'import json;print(json.load(open("'"$PROJ2"'/.casan/config.json"))["enforcement_mode"])' 2>/dev/null) [[ "$MODE" == "enforce" ]] && pass "production init defaults to enforce mode" || fail "default mode is not enforce ($MODE)" echo "===== ⑦ init MERGES into an existing shell (agents/skills/hooks preserved) =====" EXP="$WORK/existing"; mkdir -p "$EXP/.claude/agents" "$EXP/.claude/skills" "$EXP/.codex" cat > "$EXP/.claude/settings.json" <<'EOF' { "model": "claude-opus-4-8", "hooks": { "PreToolUse": [ {"matcher":"*","hooks":[{"type":"command","command":"my-existing-hook.sh"}]} ] } } EOF echo "agent" > "$EXP/.claude/agents/reviewer.md"; echo "skill" > "$EXP/.claude/skills/deploy.md" printf '[hooks]\nenabled = true\n\n[mytool]\nfoo = 1\n' > "$EXP/.codex/config.toml" ( cd "$EXP" && "$CASAN" init --project existing-app >/dev/null 2>&1 ) py_check() { python3 - "$1" "$2" <<'PY' import json,sys d=json.load(open(sys.argv[1])) cmds=[h["command"] for ev in d.get("hooks",{}).values() for g in ev for h in g["hooks"]] sys.exit(0 if any(sys.argv[2] in c for c in cmds) else 1) PY } [ -f "$EXP/.claude/agents/reviewer.md" ] && [ -f "$EXP/.claude/skills/deploy.md" ] && pass "existing agents/skills preserved" || fail "agents/skills lost" py_check "$EXP/.claude/settings.json" "my-existing-hook" && pass "existing Claude hook preserved (not clobbered)" || fail "existing hook clobbered" py_check "$EXP/.claude/settings.json" "casan-hook.py" && pass "CASAN Claude hook merged in" || fail "CASAN hook not merged" [ "$(python3 -c 'import json;print(json.load(open("'"$EXP"'/.claude/settings.json")).get("model"))')" = "claude-opus-4-8" ] && pass "unrelated settings key (model) preserved" || fail "model key lost" grep -q '\[mytool\]' "$EXP/.codex/config.toml" && pass "existing codex [mytool] preserved" || fail "mytool lost" [ "$(grep -c '^\[hooks\]' "$EXP/.codex/config.toml")" = "1" ] && pass "codex config.toml has no duplicate [hooks] table" || fail "duplicate [hooks]" # idempotent ( cd "$EXP" && "$CASAN" init --project existing-app >/dev/null 2>&1 ) N=$(python3 -c 'import json;d=json.load(open("'"$EXP"'/.claude/settings.json"));print(sum(1 for g in d["hooks"]["PreToolUse"] for h in g["hooks"] if "casan-hook.py" in h["command"]))') [ "$N" = "1" ] && pass "re-running init is idempotent (no duplicate CASAN hook)" || fail "init duplicated CASAN hook (n=$N)" ( cd "$EXP" && "$CASAN" init --project existing-app --client claude >/dev/null 2>&1 ) if py_check "$EXP/.codex/hooks.json" "casan-hook.py"; then fail "re-selecting Claude left the CASAN Codex hook enabled"; else pass "re-running init synchronizes disabled clients"; fi py_check "$EXP/.claude/settings.json" "my-existing-hook" && pass "client re-selection still preserves user hooks" || fail "client re-selection removed user hooks" echo "===== ⑧ level-aware install + init (packaging/levels.json) =====" # core install: no devkit, casan init unavailable CORE_HOME="$WORK/home-core/.casan" CASAN_HOME="$CORE_HOME" sh "$REPO_ROOT/install.sh" --level core >/dev/null 2>&1 [ -d "$CORE_HOME/current/packages/casan-devkit" ] && fail "core install pulled in devkit" || pass "core level excludes devkit" [ "$(cat "$CORE_HOME/current/.casan-level" 2>/dev/null)" = "core" ] && pass "install records level=core" || fail "no .casan-level=core" # Always run init inside a THROWAWAY dir — never the ambient cwd. CINIT="$WORK/core-init-proj"; mkdir -p "$CINIT" CI_OUT=$( ( cd "$CINIT" && "$CORE_HOME/bin/casan" init 2>&1 | head -1 ) || true ) echo "$CI_OUT" | grep -q "requires the casan-devkit" && pass "casan init unavailable on core (clear message)" || fail "core init did not fail clearly ($CI_OUT)" # enterprise refused, platform refused-with-guidance ERC=0; CASAN_HOME="$WORK/home-e/.casan" sh "$REPO_ROOT/install.sh" --level enterprise >/dev/null 2>&1 || ERC=$? [ "$ERC" -eq 3 ] && pass "install --level enterprise refused (rc=3, no fake-complete)" || fail "enterprise not refused (rc=$ERC)" PRC=0; CASAN_HOME="$WORK/home-p/.casan" sh "$REPO_ROOT/install.sh" --level platform >/dev/null 2>&1 || PRC=$? [ "$PRC" -ne 0 ] && pass "install --level platform refused (preview service, rc=$PRC)" || fail "platform install pretended to succeed" # devkit install: init --level differentiation DK_HOME="$WORK/home-dk/.casan" CASAN_HOME="$DK_HOME" sh "$REPO_ROOT/install.sh" --level devkit >/dev/null 2>&1 DKC="$DK_HOME/bin/casan" L1="$WORK/l1"; mkdir -p "$L1"; ( cd "$L1" && "$DKC" init --level 1 --project l1 >/dev/null 2>&1 ) ( [ ! -d "$L1/.gitea" ] && [ ! -d "$L1/apps" ] ) && pass "init --level 1 = gate/hooks only (no CI/domain)" || fail "L1 wrote devkit extras" [ -f "$L1/.claude/settings.json" ] && pass "init --level 1 still writes Plan-20 hooks (core feature)" || fail "L1 missing hooks" L2="$WORK/l2"; mkdir -p "$L2"; ( cd "$L2" && "$DKC" init --level 2 --project l2 >/dev/null 2>&1 ) ( [ -f "$L2/.gitea/workflows/casan-ci.yml" ] && [ -d "$L2/apps/l2/domain" ] ) && pass "init --level 2 adds CI + domain-pack" || fail "L2 missing devkit extras" L4RC=0; L4="$WORK/l4"; mkdir -p "$L4"; ( cd "$L4" && "$DKC" init --level 4 --project l4 >/dev/null 2>&1 ) || L4RC=$? [ "$L4RC" -eq 3 ] && pass "init --level 4 (enterprise) refused (rc=3)" || fail "L4 init not refused (rc=$L4RC)" LVL=$( ( cd "$L2" && "$DKC" level show ) | python3 -c 'import json,sys;print(json.load(sys.stdin)["project_target_level"])' 2>/dev/null) [ "$LVL" = "2" ] && pass "casan level show reports project target level" || fail "level show wrong ($LVL)" echo "===== ⑨ SAFETY: init refuses to adopt a CASAN source hub into itself =====" HUB="$WORK/fakehub"; mkdir -p "$HUB/packages/casan-harness/scripts/bash" echo '#casan' > "$HUB/packages/casan-harness/scripts/bash/casan-harness.sh" GRC=0; ( cd "$HUB" && "$DKC" init --project hub >/dev/null 2>"$WORK/hub.err" ) || GRC=$? [ "$GRC" -eq 65 ] && pass "init refuses a source hub (rc=65)" || fail "source-hub not refused (rc=$GRC)" [ ! -f "$HUB/.claude/settings.json" ] && pass "no hooks written into the refused hub" || fail "hooks written into source hub" ( cd "$HUB" && "$DKC" init --project hub --force >/dev/null 2>&1 ) && pass "--force overrides the source-hub guard" || fail "--force did not override guard" echo "===== ⑩ VS Code/Copilot @casan packaging + install + doctor =====" FAKE_BIN="$WORK/fake-bin"; mkdir -p "$FAKE_BIN" cat > "$FAKE_BIN/code" <<'EOF' #!/usr/bin/env bash if [[ "${1:-}" == "--list-extensions" ]]; then printf '%s\n' 'fpt-casan.casan-governed-chat' 'GitHub.copilot' 'GitHub.copilot-chat' exit 0 fi printf '%s\n' "$*" >> "$CASAN_FAKE_CODE_LOG" exit 0 EOF chmod +x "$FAKE_BIN/code" VSP="$WORK/vscode-project"; mkdir -p "$VSP" export CASAN_FAKE_CODE_LOG="$WORK/code.log" ( cd "$VSP" && PATH="$FAKE_BIN:$PATH" "$DKC" init --project vscode-project --client vscode-copilot --mode enforce --vscode-install yes >/dev/null 2>&1 ) \ && pass "init enables the selected VS Code/Copilot integration" || fail "VS Code/Copilot init failed" grep -q -- '--install-extension .*casan-governed-chat.vsix --force' "$CASAN_FAKE_CODE_LOG" \ && pass "init installs the packaged CASAN VSIX through code CLI" || fail "VSIX install was not invoked" VSIX1="$WORK/one.vsix"; VSIX2="$WORK/two.vsix" CASAN_HARNESS_ROOT="$DK_HOME/current/packages/casan-harness" python3 "$DK_HOME/current/packages/casan-devkit/package-vscode-extension.py" --output "$VSIX1" >/dev/null CASAN_HARNESS_ROOT="$DK_HOME/current/packages/casan-harness" python3 "$DK_HOME/current/packages/casan-devkit/package-vscode-extension.py" --output "$VSIX2" >/dev/null cmp -s "$VSIX1" "$VSIX2" && pass "VSIX packaging is deterministic" || fail "VSIX package bytes drift between builds" python3 - "$VSIX1" <<'PY' && pass "VSIX contains the required production extension assets" || fail "VSIX structure is invalid" import zipfile,sys with zipfile.ZipFile(sys.argv[1]) as z: names=set(z.namelist()) assert {"[Content_Types].xml","extension.vsixmanifest","extension/package.json","extension/extension.js"} <= names PY python3 - "$VSP/.vscode/extensions.json" <<'PY' && pass "VS Code recommendations include Copilot + CASAN" || fail "VS Code recommendations incomplete" import json,sys r=set(json.load(open(sys.argv[1]))["recommendations"]) assert {"GitHub.copilot","GitHub.copilot-chat","fpt-casan.casan-governed-chat"} <= r PY CASAN_HOME="$DK_HOME" node "$REPO_ROOT/packages/casan-devkit/tests/vscode-extension-tests.js" \ "$VSP" "$REPO_ROOT/packages/casan-harness/adapters/vscode/extension/extension.js" \ && pass "VS Code @casan handler completes the real governed lifecycle" \ || fail "VS Code @casan handler contract failed" ( cd "$VSP" && PATH="$FAKE_BIN:$PATH" "$DKC" doctor --client vscode-copilot >/dev/null 2>&1 ) \ && pass "doctor proves the VS Code/Copilot adapter and installed extension" || fail "VS Code doctor failed" echo "" echo "===== HYBRID INSTALL SUMMARY: PASS=$PASS FAIL=$FAIL =====" [[ "$FAIL" -eq 0 ]] || exit 1