From 8450f8ca1a4d69f82ad127f8b62525d51eecd0df Mon Sep 17 00:00:00 2001 From: thanhnv Date: Thu, 23 Jul 2026 21:26:54 +0700 Subject: [PATCH] feat(install): Plan-21 hybrid global install + `casan init` (pin+verify) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopt CASAN like a normal tool (codegraph-style): install the harness ONCE per machine, then `casan init` per project writes CONFIG ONLY — the harness is no longer copied into every repo. - install.sh / install.ps1: global bootstrap (curl|sh / irm|iex or local source). Installs harness to $CASAN_HOME/versions/, writes a `casan` launcher that resolves the shared harness + the current project's .specify, and records a gate-code integrity hash. CASAN_NO_PATH_LINK for tests. - harness_hash.py: deterministic content hash over gate code (scripts/bash, scripts/python, security, level5) — the pin+verify anchor. - casan-init.py: `casan init` writes .casan/{config,version.lock,agentic.env}, .specify/ marker, and the Plan-20 client hooks — no harness copy. `verify` recomputes the harness hash LIVE and compares to the project pin (drift/ tamper -> rc 3), preserving the Plan-16 trusted-gates guarantee off-repo. - bin/casan: new `init` and `verify-harness` commands. - hybrid-install-tests.sh: 21/21 (install, config-only init, no-copy, pin, verify ok, tamper drift, bridge runs against project state via global harness). - docs: CASAN_INSTALL_HYBRID.md + Plan-21. The path model (casan-paths.sh) already separated harness/state/domain roots, so this is installer + init, not a core rewrite. Remote dist tarball, real Windows run, and signed .harness-hash are the documented next steps. Co-Authored-By: Claude Opus 4.8 --- bin/casan | 10 + docs/casan/CASAN_INSTALL_HYBRID.md | 101 ++++++ docs/plans/CASAN_PLAN_21_HYBRID_INSTALL.md | 65 ++++ install.ps1 | 128 ++++++++ install.sh | 132 ++++++++ packages/casan-devkit/casan-init.py | 294 ++++++++++++++++++ .../tests/hybrid-install-tests.sh | 69 ++++ .../scripts/python/harness_hash.py | 105 +++++++ 8 files changed, 904 insertions(+) create mode 100644 docs/casan/CASAN_INSTALL_HYBRID.md create mode 100644 docs/plans/CASAN_PLAN_21_HYBRID_INSTALL.md create mode 100644 install.ps1 create mode 100755 install.sh create mode 100755 packages/casan-devkit/casan-init.py create mode 100755 packages/casan-devkit/tests/hybrid-install-tests.sh create mode 100755 packages/casan-harness/scripts/python/harness_hash.py diff --git a/bin/casan b/bin/casan index fa43514..2d9ff3c 100755 --- a/bin/casan +++ b/bin/casan @@ -42,6 +42,8 @@ casan — CASAN governance harness CLI ($(version)) Usage: casan [args] Commands: + init [--project id] [--client all] Adopt CASAN into THIS project (config only, hybrid model) + verify-harness Verify the resolved harness matches the project pin run [action] [-- cmd...] Run a step through the harness (H4→H5→H6→exec→H4-out) gate Run the full CI harness gate (all suites) test Run the core harness test suite (run-casan4) @@ -62,7 +64,15 @@ EOF } cmd="${1:-help}"; shift || true +DEVKIT_ROOT="${CASAN_DEVKIT_ROOT:-$CASAN_APP_ROOT/packages/casan-devkit}" +[[ -f "$DEVKIT_ROOT/casan-init.py" ]] || DEVKIT_ROOT="$HARNESS/../casan-devkit" case "$cmd" in + init) + [[ -f "$DEVKIT_ROOT/casan-init.py" ]] || { echo "casan: init requires the casan-devkit package" >&2; exit 1; } + exec python3 "$DEVKIT_ROOT/casan-init.py" init "$@" ;; + verify-harness) + [[ -f "$DEVKIT_ROOT/casan-init.py" ]] || { echo "casan: verify-harness requires the casan-devkit package" >&2; exit 1; } + exec python3 "$DEVKIT_ROOT/casan-init.py" verify "$@" ;; run) exec bash "$BASH_DIR/casan-harness.sh" "$@" ;; gate) if [[ -n "${CASAN_PROJECT_MANIFEST:-}${CASAN_PROJECT_ID:-}" ]]; then diff --git a/docs/casan/CASAN_INSTALL_HYBRID.md b/docs/casan/CASAN_INSTALL_HYBRID.md new file mode 100644 index 0000000..656f49b --- /dev/null +++ b/docs/casan/CASAN_INSTALL_HYBRID.md @@ -0,0 +1,101 @@ +# Cài CASAN kiểu tool (global install + `casan init`) — Plan-21 + +Mô hình **hybrid**: cài harness **một lần** vào máy (`$CASAN_HOME`), sau đó mỗi +dự án chỉ chạy `casan init` để ghi **config riêng của dự án** — harness KHÔNG bị +copy vào từng repo. Giống trải nghiệm codegraph. + +## 1. Cài đặt (một lần cho mỗi máy) + +```bash +# macOS / Linux +curl -fsSL https:///admin/casan5/raw/branch/main/install.sh | sh + +# Windows (PowerShell) +irm https:///admin/casan5/raw/branch/main/install.ps1 | iex +``` + +Hoặc từ một checkout CASAN có sẵn: + +```bash +sh install.sh # macOS/Linux +pwsh .\install.ps1 # Windows +``` + +Installer sẽ: + +- Copy harness vào `$CASAN_HOME/versions/` (mặc định `~/.casan`, + Windows: `%LOCALAPPDATA%\casan`) và trỏ `current` vào version đó. +- Ghi **hash toàn vẹn** của gate-code (`.harness-hash`) — mỏ neo cho pin+verify. +- Tạo launcher `casan` và đưa lên PATH (`~/.local/bin` hoặc `$CASAN_HOME/bin`). + +> Windows cần **Git for Windows (Git Bash)** để *chạy* harness (xem +> [CASAN_AGENTIC_CLIENTS_WINDOWS.md](CASAN_AGENTIC_CLIENTS_WINDOWS.md)) — không +> cần WSL2. Cả hai OS cần `python3`. + +Biến môi trường hữu ích: `CASAN_HOME` (đổi nơi cài), `CASAN_SRC` (cài từ checkout +cục bộ), `CASAN_DIST_URL` (tải tarball), `CASAN_NO_PATH_LINK=1` (không tự thêm PATH). + +## 2. Adopt vào một dự án bất kỳ + +```bash +cd +casan init # project id lấy theo tên thư mục +# hoặc: +casan init --project my-app --client all --mode observe +``` + +`casan init` chỉ ghi **config per-project** (không copy harness): + +| File | Vai trò | +|---|---| +| `.casan/config.json` | project id, enforcement/integration mode, clients | +| `.casan/version.lock` | **pin** harness version + hash gate-code | +| `.casan/agentic.env` | feature flags bridge Plan-20 | +| `.specify/` | thư mục state runtime (logs/trace/admission) | +| `.claude/settings.json` | hook Claude Code (Plan-20) | +| `.codex/hooks.json`, `config.toml` | hook Codex (Plan-20) | + +Tham số: `--client claude|codex|all` (mặc định `all`), `--mode observe|enforce` +(mặc định `observe`), `--integration-mode project_hook|managed_hook|casan_owned`, +`--target ` (mặc định thư mục hiện tại). + +Sau `init`, developer gõ prompt bình thường trong client — trace H1→H7 + H6 theo +Plan-20. Repo chỉ có mấy file config nhỏ; nâng cấp harness làm ở `$CASAN_HOME`. + +## 3. Pin + Verify (giữ đảm bảo bảo mật khi harness ở ngoài repo) + +Vì harness không nằm trong repo, dự án **pin** version + hash gate-code lúc +`init`. Kiểm tra bất cứ lúc nào: + +```bash +casan verify-harness +``` + +- Khớp → `status: ok` (rc 0). +- Harness global bị đổi/tamper so với pin → `HARNESS_INTEGRITY_DRIFT` (rc 3). + +`verify-harness` **luôn tính lại hash từ file thật** (không tin hash cache), nên +sửa lén một gate script sẽ bị phát hiện. Nên chạy `verify-harness` trong CI trước +khi tin bất kỳ trace nào là certified. + +> Bước làm mạnh tiếp theo (chưa bật mặc định): ký `.harness-hash` bằng khóa tổ +> chức để verify cả *chữ ký* chứ không chỉ nội dung — dùng hạ tầng ký của Plan-16. + +## 4. So sánh với mô hình vendored cũ + +| | Vendored (`devkit/install.sh`) | Hybrid (`casan init`) | +|---|---|---| +| Repo | Nặng (copy cả harness) | Nhẹ (chỉ config) | +| Nâng cấp | Mỗi repo tự drift | 1 chỗ (`$CASAN_HOME`) | +| Bảo mật | Gate commit + ký trong repo | Gate global + **pin+verify** trong repo | +| CI/offline | Tự chứa | Cần cài harness trên runner (hoặc verify pin) | + +Cả hai vẫn dùng chung lõi harness + `casan-paths.sh` (tách `CASAN_HARNESS_ROOT` += code, `CASAN_STATE_ROOT` = state trong repo, `CASAN_DOMAIN_ROOT` = dữ liệu dự +án). Chọn mô hình theo nhu cầu triển khai. + +## 5. Kiểm thử + +```bash +bash packages/casan-devkit/tests/hybrid-install-tests.sh +``` diff --git a/docs/plans/CASAN_PLAN_21_HYBRID_INSTALL.md b/docs/plans/CASAN_PLAN_21_HYBRID_INSTALL.md new file mode 100644 index 0000000..32772b1 --- /dev/null +++ b/docs/plans/CASAN_PLAN_21_HYBRID_INSTALL.md @@ -0,0 +1,65 @@ +# CASAN Plan-21 — Hybrid Global Install + `casan init` + +> Ngày lập: 2026-07-23 +> Trạng thái: **IMPLEMENTED (MVP) — global install + `casan init` + pin/verify xanh (21/21 test)** +> Liên quan: [Plan-20](CASAN_PLAN_20_AGENTIC_CLIENT_INTEGRATION.md) (adapter/hook là base phổ quát), [CASAN_INSTALL_HYBRID.md](../casan/CASAN_INSTALL_HYBRID.md) + +## 1. Mục tiêu + +Cài CASAN như một tool bình thường (giống codegraph): cài **một lần** lên máy, +rồi trong dự án chỉ chạy `casan init`. Không còn copy toàn bộ harness vào từng +repo. Đây là câu trả lời cho "làm sao dựng 1 base chung nhất cho dự án muôn hình +vạn trạng": **harness = phần chung (agnostic), config per-project = phần biến +thể**, và entry phổ quát nhất là agentic bridge Plan-20 (chỉ cần Python + bash). + +## 2. Kiến trúc + +Tận dụng sẵn `casan-paths.sh` đã tách 3 root độc lập: + +- `CASAN_HARNESS_ROOT` = **code** → cài global (`$CASAN_HOME/versions/`). +- `CASAN_STATE_ROOT` (`.specify/`) = **state runtime** → trong repo. +- `CASAN_DOMAIN_ROOT` = **dữ liệu dự án** → trong repo (optional). + +Launcher global set `CASAN_HARNESS_ROOT`/`CASAN_DEVKIT_ROOT` về bản cài, và tìm +`CASAN_APP_ROOT` bằng cách đi lên từ CWD tới marker `.casan`/`.specify`. Nhờ vậy +harness global thao tác đúng trên state của dự án hiện tại. + +## 3. Pin + Verify (giữ đảm bảo Plan-16 khi harness ở ngoài repo) + +- Installer tính **hash toàn vẹn gate-code** (`scripts/bash`, `scripts/python`, + `security`, `level5`) và ghi `.harness-hash`. +- `casan init` **pin** version + hash vào `.casan/version.lock`. +- `casan verify-harness` **tính lại live** từ file thật và so với pin → phát hiện + drift/tamper (rc 3). Không tin hash cache. +- Làm mạnh sau: ký `.harness-hash` bằng khóa tổ chức (tái dùng hạ tầng ký Plan-16). + +## 4. Deliverable đã ship + +| Deliverable | File | +|---|---| +| Global installer (macOS/Linux) | `install.sh` | +| Global installer (Windows) | `install.ps1` | +| Integrity hash primitive | `packages/casan-harness/scripts/python/harness_hash.py` | +| `casan init` / `verify` | `packages/casan-devkit/casan-init.py` | +| CLI wiring | `bin/casan` (`init`, `verify-harness`) | +| Acceptance suite | `packages/casan-devkit/tests/hybrid-install-tests.sh` — **21/21 PASS** | +| Adoption doc | `docs/casan/CASAN_INSTALL_HYBRID.md` | + +## 5. Definition of Done + +- ✅ `install.sh` cài harness + launcher + integrity hash từ checkout cục bộ. +- ✅ `casan init` ghi CHỈ config per-project; **không** copy harness. +- ✅ `version.lock` pin đúng hash; `verify-harness` ok khi sạch, drift (rc 3) khi tamper. +- ✅ Bridge Plan-20 chạy qua harness global, state ghi vào `.specify` của dự án. +- ✅ Project id mặc định theo tên thư mục, được sanitize. + +## 6. Còn lại / bước tiếp + +- **Remote bootstrap**: `install.sh`/`install.ps1` đã hỗ trợ `CASAN_DIST_URL` + nhưng chưa có release tarball + URL Gitea công bố; cần publish artifact. +- **Windows thực**: `install.ps1` viết path-safe nhưng chưa chạy trên máy Windows + thật (host dev không có `pwsh`). +- **Ký `.harness-hash`**: verify hiện theo nội dung; thêm chữ ký để chống thay cả + hash lẫn code. +- **`casan init` domain pack**: hiện init tối giản (config + hook Plan-20); có thể + thêm `--with-domain` để scaffold domain-pack khi dự án cần traceability đầy đủ. diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..441d443 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,128 @@ +#requires -Version 5.1 +<# + CASAN global installer (Plan-21 hybrid model) — Windows PowerShell. + + irm https:///admin/casan5/raw/branch/main/install.ps1 | iex + # or, from a local CASAN source checkout: + pwsh .\install.ps1 + + Installs the harness ONCE under $env:CASAN_HOME (default %LOCALAPPDATA%\casan), + writes a `casan` launcher, and records the gate-code integrity hash. Projects + then run `casan init` to adopt CASAN with only per-project config. + + Requires: python3 on PATH, and bash (Git for Windows / Git Bash) to RUN the + harness — see docs/casan/CASAN_AGENTIC_CLIENTS_WINDOWS.md. +#> +[CmdletBinding()] +param( + [string]$Source, + [switch]$NoPathLink +) +$ErrorActionPreference = 'Stop' + +function Log($m) { Write-Host "[casan-install] $m" } +function Die($m) { Write-Error "[casan-install] ERROR: $m"; exit 1 } + +$py = (Get-Command python3 -ErrorAction SilentlyContinue) ?? (Get-Command python -ErrorAction SilentlyContinue) +if (-not $py) { Die 'python3 is required on PATH.' } + +$CasanHome = if ($env:CASAN_HOME) { $env:CASAN_HOME } else { Join-Path $env:LOCALAPPDATA 'casan' } + +# ── 1) Locate source (param, CASAN_SRC, local checkout, or CASAN_DIST_URL) ──── +$cleanup = $null +if (-not $Source) { $Source = $env:CASAN_SRC } +if ((-not $Source) -and (Test-Path './packages/casan-harness')) { $Source = (Get-Location).Path } +if ((-not $Source) -and $env:CASAN_DIST_URL) { + $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("casan-" + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Force -Path $tmp | Out-Null + $cleanup = $tmp + Log "downloading CASAN from $($env:CASAN_DIST_URL)" + $tgz = Join-Path $tmp 'casan.tar.gz' + Invoke-WebRequest -UseBasicParsing -Uri $env:CASAN_DIST_URL -OutFile $tgz + tar -xzf $tgz -C $tmp + if (Test-Path (Join-Path $tmp 'packages/casan-harness')) { $Source = $tmp } + else { + $found = Get-ChildItem -Path $tmp -Recurse -Directory -Filter 'casan-harness' -Depth 2 | Select-Object -First 1 + if ($found) { $Source = (Split-Path -Parent (Split-Path -Parent $found.FullName)) } + } +} +if ((-not $Source) -or (-not (Test-Path (Join-Path $Source 'packages/casan-harness')))) { + Die 'no CASAN source found. Run from a checkout, pass -Source , or set CASAN_DIST_URL.' +} + +$version = if (Test-Path (Join-Path $Source 'VERSION')) { (Get-Content (Join-Path $Source 'VERSION') -Raw).Trim() } else { '0.0.0' } +Log "source : $Source" +Log "version : $version" +Log "install : $CasanHome" + +# ── 2) Copy harness + devkit + CLI into a versioned dir ────────────────────── +$dest = Join-Path $CasanHome "versions\$version" +if (Test-Path $dest) { Remove-Item -Recurse -Force $dest } +New-Item -ItemType Directory -Force -Path (Join-Path $dest 'packages'), (Join-Path $dest 'bin') | Out-Null + +function Copy-Tree($rel) { + $src = Join-Path $Source $rel + if (-not (Test-Path $src)) { return } + $dst = Join-Path $dest $rel + New-Item -ItemType Directory -Force -Path $dst | Out-Null + Copy-Item -Recurse -Force (Join-Path $src '*') $dst +} +Copy-Tree 'packages\casan-harness' +Copy-Tree 'packages\casan-devkit' +Copy-Item -Force (Join-Path $Source 'bin\casan') (Join-Path $dest 'bin\casan') +Set-Content -Path (Join-Path $dest 'VERSION') -Value $version -Encoding ASCII + +Get-ChildItem -Path $dest -Recurse -Directory -Filter '__pycache__' -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue +Get-ChildItem -Path $dest -Recurse -File -Filter '*.pyc' -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue + +# ── 3) Record gate-code integrity hash ─────────────────────────────────────── +$hasher = Join-Path $dest 'packages\casan-harness\scripts\python\harness_hash.py' +$hhash = (& $py.Source $hasher compute (Join-Path $dest 'packages\casan-harness')).Trim() +if (-not $hhash) { Die 'failed to compute harness hash.' } +Set-Content -Path (Join-Path $dest '.harness-hash') -Value $hhash -Encoding ASCII +Log "integrity: $hhash" + +# ── 4) Point current -> this version ───────────────────────────────────────── +$cur = Join-Path $CasanHome 'current' +if (Test-Path $cur) { (Get-Item $cur).Delete() } +New-Item -ItemType Junction -Path $cur -Target $dest | Out-Null + +# ── 5) Launcher: casan.cmd -> casan.ps1 (sets env, invokes the bash CLI) ───── +$binDir = Join-Path $CasanHome 'bin' +New-Item -ItemType Directory -Force -Path $binDir | Out-Null +$launcherPs1 = @' +$ErrorActionPreference = "Stop" +$CasanHome = if ($env:CASAN_HOME) { $env:CASAN_HOME } else { Join-Path $env:LOCALAPPDATA "casan" } +$cur = Join-Path $CasanHome "current" +if (-not (Test-Path $cur)) { Write-Error "casan: no install at $cur (run install.ps1)"; exit 1 } +$env:CASAN_HARNESS_ROOT = Join-Path $cur "packages\casan-harness" +$env:CASAN_DEVKIT_ROOT = Join-Path $cur "packages\casan-devkit" +$env:CASAN_INSTALL_ROOT = $cur +if (-not $env:CASAN_APP_ROOT) { + $d = (Get-Location).Path + while ($d -and (Split-Path $d -Parent)) { + if ((Test-Path (Join-Path $d ".casan")) -or (Test-Path (Join-Path $d ".specify"))) { $env:CASAN_APP_ROOT = $d; break } + $d = Split-Path $d -Parent + } +} +$bash = (Get-Command bash -ErrorAction SilentlyContinue) +if (-not $bash) { Write-Error "casan: bash not found. Install Git for Windows (Git Bash)."; exit 1 } +& $bash.Source (Join-Path $cur "bin/casan") @args +exit $LASTEXITCODE +'@ +Set-Content -Path (Join-Path $binDir 'casan.ps1') -Value $launcherPs1 -Encoding UTF8 +Set-Content -Path (Join-Path $binDir 'casan.cmd') -Value "@echo off`r`npowershell -NoProfile -ExecutionPolicy Bypass -File `"%~dp0casan.ps1`" %*" -Encoding ASCII + +# ── 6) Put launcher on PATH (user scope) ───────────────────────────────────── +if (-not $NoPathLink -and $env:CASAN_NO_PATH_LINK -ne '1') { + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + if ($userPath -notlike "*$binDir*") { + [Environment]::SetEnvironmentVariable('Path', "$binDir;$userPath", 'User') + Log "added $binDir to user PATH (restart shell to pick it up)" + } +} + +Log "installed CASAN $version" +Log "launcher : $binDir\casan.cmd" +Log "next: cd ; casan init" +if ($cleanup) { Remove-Item -Recurse -Force $cleanup -ErrorAction SilentlyContinue } diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..ae54d0a --- /dev/null +++ b/install.sh @@ -0,0 +1,132 @@ +#!/bin/sh +# CASAN global installer (Plan-21 hybrid model) — macOS / Linux. +# +# curl -fsSL https:///admin/casan5/raw/branch/main/install.sh | sh +# # or, from a local CASAN source checkout: +# sh install.sh +# +# Installs the CASAN harness ONCE under $CASAN_HOME (default ~/.casan), puts a +# `casan` launcher on PATH, and records a content-integrity hash of the gate code. +# Projects then run `casan init` to adopt CASAN with only per-project config — +# the harness itself is NOT copied into every repo (that is the hybrid model). +# +# POSIX sh (runs under `curl | sh`). The harness itself needs bash + python3 to +# run; this bootstrap only needs sh, cp and python3. +set -eu + +CASAN_HOME="${CASAN_HOME:-$HOME/.casan}" + +log() { printf '[casan-install] %s\n' "$1"; } +die() { printf '[casan-install] ERROR: %s\n' "$1" >&2; exit 1; } + +command -v python3 >/dev/null 2>&1 || die "python3 is required on PATH." + +# ── 1) Locate the source (local checkout, explicit CASAN_SRC, or a dist tarball) +SRC="" +CLEANUP="" +if [ -n "${CASAN_SRC:-}" ] && [ -d "$CASAN_SRC/packages/casan-harness" ]; then + SRC="$CASAN_SRC" +elif [ -d "./packages/casan-harness" ]; then + SRC="$(pwd)" +elif [ -n "${CASAN_DIST_URL:-}" ]; then + command -v curl >/dev/null 2>&1 || die "curl is required to fetch CASAN_DIST_URL." + TMP="$(mktemp -d)" + CLEANUP="$TMP" + log "downloading CASAN from $CASAN_DIST_URL" + curl -fsSL "$CASAN_DIST_URL" -o "$TMP/casan.tar.gz" || die "download failed: $CASAN_DIST_URL" + tar -xzf "$TMP/casan.tar.gz" -C "$TMP" || die "extract failed." + # Accept either a flat tarball or a single top-level dir. + if [ -d "$TMP/packages/casan-harness" ]; then + SRC="$TMP" + else + SRC="$(find "$TMP" -maxdepth 2 -type d -name casan-harness -exec dirname {} \; -quit)" + SRC="$(dirname "$SRC" 2>/dev/null || true)" + [ -n "$SRC" ] && [ -d "$SRC/packages/casan-harness" ] || die "tarball has no packages/casan-harness." + fi +else + die "no CASAN source found. Run from a CASAN checkout, set CASAN_SRC=, or set CASAN_DIST_URL=." +fi + +VERSION="$(cat "$SRC/VERSION" 2>/dev/null || echo "0.0.0")" +log "source : $SRC" +log "version : $VERSION" +log "install : $CASAN_HOME" + +# ── 2) Copy harness + devkit + CLI into a versioned dir ────────────────────── +DEST="$CASAN_HOME/versions/$VERSION" +rm -rf "$DEST" +mkdir -p "$DEST/packages" "$DEST/bin" + +copy_tree() { # + [ -e "$SRC/$1" ] || return 0 + if command -v rsync >/dev/null 2>&1; then + rsync -a --exclude='__pycache__' --exclude='*.pyc' --exclude='.DS_Store' \ + --exclude='*.log' "$SRC/$1/" "$DEST/$1/" + else + mkdir -p "$DEST/$1" + cp -R "$SRC/$1/." "$DEST/$1/" + fi +} +copy_tree "packages/casan-harness" +copy_tree "packages/casan-devkit" +cp "$SRC/bin/casan" "$DEST/bin/casan" +chmod +x "$DEST/bin/casan" +cp "$SRC/VERSION" "$DEST/VERSION" 2>/dev/null || printf '%s\n' "$VERSION" > "$DEST/VERSION" + +# Prune stray caches the copy may have carried. +find "$DEST" -name '__pycache__' -type d -prune -exec rm -rf {} + 2>/dev/null || true +find "$DEST" -name '*.pyc' -delete 2>/dev/null || true + +# ── 3) Record the gate-code integrity hash (pin+verify anchor) ─────────────── +HASHER="$DEST/packages/casan-harness/scripts/python/harness_hash.py" +HHASH="$(python3 "$HASHER" compute "$DEST/packages/casan-harness")" || die "failed to compute harness hash." +printf '%s\n' "$HHASH" > "$DEST/.harness-hash" +log "integrity: $HHASH" + +# ── 4) Point `current` at this version ─────────────────────────────────────── +ln -sfn "$DEST" "$CASAN_HOME/current" + +# ── 5) Write the global launcher (shim) ────────────────────────────────────── +mkdir -p "$CASAN_HOME/bin" +cat > "$CASAN_HOME/bin/casan" <<'LAUNCH' +#!/usr/bin/env bash +# CASAN global launcher — resolves the shared harness and the CURRENT project. +set -uo pipefail +CASAN_HOME="${CASAN_HOME:-$HOME/.casan}" +CUR="$CASAN_HOME/current" +[[ -d "$CUR" ]] || { echo "casan: no install at $CUR (run install.sh)" >&2; exit 1; } +export CASAN_HARNESS_ROOT="$CUR/packages/casan-harness" +export CASAN_DEVKIT_ROOT="$CUR/packages/casan-devkit" +export CASAN_INSTALL_ROOT="$CUR" +# Project (app) root = nearest ancestor of CWD carrying a .casan/.specify marker. +if [[ -z "${CASAN_APP_ROOT:-}" ]]; then + _d="$PWD" + while [[ "$_d" != "/" && -n "$_d" ]]; do + if [[ -d "$_d/.casan" || -d "$_d/.specify" ]]; then export CASAN_APP_ROOT="$_d"; break; fi + _d="$(dirname "$_d")" + done +fi +exec bash "$CUR/bin/casan" "$@" +LAUNCH +chmod +x "$CASAN_HOME/bin/casan" + +# ── 6) Put the launcher on PATH (skip with CASAN_NO_PATH_LINK=1, e.g. in tests) +LINKED="" +if [ "${CASAN_NO_PATH_LINK:-0}" != "1" ]; then + for d in "$HOME/.local/bin" "/usr/local/bin"; do + if [ -d "$d" ] && printf '%s' ":$PATH:" | grep -q ":$d:"; then + if ln -sf "$CASAN_HOME/bin/casan" "$d/casan" 2>/dev/null; then LINKED="$d/casan"; break; fi + fi + done +fi + +log "installed CASAN $VERSION" +if [ -n "$LINKED" ]; then + log "launcher : $LINKED (on PATH)" +else + log "launcher : $CASAN_HOME/bin/casan" + log "add to PATH: export PATH=\"$CASAN_HOME/bin:\$PATH\" (add to your shell rc)" +fi +log "next: cd && casan init" + +[ -n "$CLEANUP" ] && rm -rf "$CLEANUP" || true diff --git a/packages/casan-devkit/casan-init.py b/packages/casan-devkit/casan-init.py new file mode 100755 index 0000000..e34edec --- /dev/null +++ b/packages/casan-devkit/casan-init.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""`casan init` / `casan verify-harness` (Plan-21 hybrid adoption). + +Adopt CASAN into an EXISTING project by writing only per-project config — the +shared harness stays under $CASAN_HOME and is NOT copied into the repo. This is +the codegraph-style flow: global install once, then `casan init` per project. + +What init writes into the target repo: + .casan/config.json project id, enforcement/integration mode, clients + .casan/version.lock pinned harness version + gate-code integrity hash + .casan/agentic.env Plan-20 bridge feature flags + .specify/ runtime state root marker (logs/traces/admissions) + .claude/settings.json Plan-20 Claude Code hooks (--client claude|all) + .codex/hooks.json+config Plan-20 Codex hooks (--client codex|all) + +`verify` recomputes the resolved harness gate-code hash and compares it to +version.lock — the pin+VERIFY half. Drift/tamper of the global harness relative +to what the project pinned is caught here (preserves the Plan-16 "gates are +trusted code" guarantee even though the harness lives outside the repo). + +stdlib-only. Resolves the harness via CASAN_HARNESS_ROOT (set by the global +launcher) or --harness. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import time + +PROJECT_RE = re.compile(r"^[a-z][a-z0-9-]{1,62}$") + + +def now_iso(): + return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + + +def resolve_harness(explicit): + for cand in (explicit, os.environ.get("CASAN_HARNESS_ROOT")): + if cand and os.path.isdir(os.path.join(cand, "scripts", "bash")): + return os.path.abspath(cand) + install = os.environ.get("CASAN_INSTALL_ROOT") + if install: + c = os.path.join(install, "packages", "casan-harness") + if os.path.isdir(c): + return os.path.abspath(c) + # Fallback: this file lives at packages/casan-devkit/casan-init.py. + c = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "casan-harness")) + return c if os.path.isdir(c) else None + + +def install_root(harness): + if os.environ.get("CASAN_INSTALL_ROOT"): + return os.path.abspath(os.environ["CASAN_INSTALL_ROOT"]) + return os.path.abspath(os.path.join(harness, "..", "..")) + + +def harness_version(harness): + for p in (os.path.join(install_root(harness), "VERSION"), + os.path.join(harness, "..", "..", "VERSION")): + try: + with open(p, "r", encoding="utf-8") as fh: + v = fh.read().strip() + if v: + return v + except (OSError, IOError): + continue + return "0.0.0" + + +def compute_live(harness): + """ALWAYS recompute the gate-code hash from the actual files on disk. Used by + verify so a tampered harness cannot hide behind a stale recorded hash.""" + sys.path.insert(0, os.path.join(harness, "scripts", "python")) + try: + import harness_hash # noqa: E402 + return harness_hash.compute(harness), "computed" + except Exception as exc: # noqa: BLE001 + return "unavailable:%s" % exc, "error" + + +def compute_harness_hash(harness): + """For PINNING at init: use the value recorded at install time if present + (it equals a live compute of the same files), else compute live. Verify must + NOT use this — it must call compute_live() to detect drift.""" + recorded = os.path.join(install_root(harness), ".harness-hash") + try: + with open(recorded, "r", encoding="utf-8") as fh: + v = fh.read().strip() + if v: + return v, "recorded" + except (OSError, IOError): + pass + return compute_live(harness) + + +def _write(path, text, backups): + if os.path.exists(path): + bak = path + ".casan-bak" + if not os.path.exists(bak): + with open(path, "r", encoding="utf-8", errors="replace") as fh: + old = fh.read() + with open(bak, "w", encoding="utf-8") as fh: + fh.write(old) + backups.append(bak) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as fh: + fh.write(text) + + +def _copy_template(src, dst, backups): + try: + with open(src, "r", encoding="utf-8") as fh: + _write(dst, fh.read(), backups) + return True + except (OSError, IOError): + return False + + +def cmd_init(args): + harness = resolve_harness(args.harness) + if not harness: + sys.stderr.write("casan init: cannot locate the harness. Install CASAN first " + "(install.sh) or set CASAN_HARNESS_ROOT.\n") + return 1 + target = os.path.abspath(args.target or os.getcwd()) + if not os.path.isdir(target): + sys.stderr.write("casan init: target is not a directory: %s\n" % target) + return 66 + + project = args.project or re.sub(r"[^a-z0-9-]", "-", os.path.basename(target).lower()).strip("-") + if not PROJECT_RE.match(project): + sys.stderr.write("casan init: --project must match ^[a-z][a-z0-9-]{1,62}$ (got %r)\n" % project) + return 64 + + clients = ["claude", "codex"] if args.client == "all" else [args.client] + version = harness_version(harness) + hhash, hsource = compute_harness_hash(harness) + created = [] + backups = [] + + def created_add(p): + created.append(os.path.relpath(p, target)) + + # ── .casan/config.json ── + cfg_dir = os.path.join(target, ".casan") + cfg = { + "schema_version": "21.1", + "project_id": project, + "created_at": now_iso(), + "enforcement_mode": args.mode, + "integration_mode": args.integration_mode, + "clients": clients, + "harness_version": version, + "adoption_model": "hybrid-global", + } + p = os.path.join(cfg_dir, "config.json") + _write(p, json.dumps(cfg, ensure_ascii=False, indent=2) + "\n", backups); created_add(p) + + # ── .casan/version.lock (pin) ── + lock = { + "harness_version": version, + "harness_hash": hhash, + "hash_algo": "sha256", + "hash_source": hsource, + "install_root": install_root(harness), + "recorded_at": now_iso(), + } + p = os.path.join(cfg_dir, "version.lock") + _write(p, json.dumps(lock, ensure_ascii=False, indent=2) + "\n", backups); created_add(p) + + # ── .casan/agentic.env (Plan-20 flags) ── + env_lines = [ + "# CASAN Plan-20 agentic bridge flags. Source before starting the client.", + "CASAN_AGENTIC_BRIDGE_ENABLED=1", + "CASAN_AGENTIC_ENFORCEMENT_MODE=%s" % args.mode, + "CASAN_AGENTIC_INTEGRATION_MODE=%s" % args.integration_mode, + "", + ] + p = os.path.join(cfg_dir, "agentic.env") + _write(p, "\n".join(env_lines), backups); created_add(p) + + # ── .specify/ state root marker ── + specify = os.path.join(target, ".specify") + os.makedirs(os.path.join(specify, "state"), exist_ok=True) + os.makedirs(os.path.join(specify, "logs"), exist_ok=True) + gi = os.path.join(specify, ".gitignore") + if not os.path.exists(gi): + _write(gi, "# CASAN runtime state — do not commit\nlogs/\nstate/\n", backups); created_add(gi) + + # ── Plan-20 client hooks from the harness adapters ── + ad = os.path.join(harness, "adapters") + if "claude" in clients: + if _copy_template(os.path.join(ad, "claude-code", "settings.template.json"), + os.path.join(target, ".claude", "settings.json"), backups): + created_add(os.path.join(target, ".claude", "settings.json")) + if "codex" in clients: + for src, dst in (("hooks.template.json", "hooks.json"), + ("config.template.toml", "config.toml")): + if _copy_template(os.path.join(ad, "codex", src), + os.path.join(target, ".codex", dst), backups): + created_add(os.path.join(target, ".codex", dst)) + + # ── manifest (so uninstall/verify know what init created) ── + manifest = { + "created": created, + "backups": [os.path.relpath(b, target) for b in backups], + "project_id": project, + "at": now_iso(), + } + p = os.path.join(cfg_dir, "init-manifest.json") + _write(p, json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", backups) + + print(json.dumps({ + "status": "initialized", + "project_id": project, + "target": target, + "harness_version": version, + "harness_hash": hhash, + "enforcement_mode": args.mode, + "clients": clients, + "created": created, + "note": ("harness NOT copied into repo (hybrid model); " + "run `casan verify-harness` to check the pin"), + }, ensure_ascii=False, indent=2)) + if hsource == "error": + sys.stderr.write("casan init: WARNING — could not compute harness hash; " + "pin verification will be unavailable.\n") + return 0 + + +def cmd_verify(args): + harness = resolve_harness(args.harness) + if not harness: + sys.stderr.write("casan verify-harness: cannot locate the harness.\n") + return 1 + target = os.path.abspath(args.target or os.getcwd()) + lock_path = os.path.join(target, ".casan", "version.lock") + if not os.path.exists(lock_path): + sys.stderr.write("casan verify-harness: no .casan/version.lock (run `casan init` first).\n") + return 1 + with open(lock_path, "r", encoding="utf-8") as fh: + lock = json.load(fh) + expected = lock.get("harness_hash") + actual, _src = compute_live(harness) # live recompute — never the cached hash + ok = (expected == actual) and expected and not str(expected).startswith("unavailable") + result = { + "status": "ok" if ok else "drift", + "expected": expected, + "actual": actual, + "harness_version_lock": lock.get("harness_version"), + "harness_version_now": harness_version(harness), + "harness_root": harness, + } + print(json.dumps(result, ensure_ascii=False, indent=2)) + if not ok: + sys.stderr.write("HARNESS_INTEGRITY_DRIFT — the resolved harness does not match the " + "project pin. The global harness changed or was tampered.\n") + return 3 + return 0 + + +def main(argv=None): + parser = argparse.ArgumentParser(prog="casan-init", description="CASAN hybrid adoption") + sub = parser.add_subparsers(dest="cmd") + + pi = sub.add_parser("init", help="adopt CASAN into the current project (config only)") + pi.add_argument("--target", help="project root (default: cwd)") + pi.add_argument("--project", help="project id (^[a-z][a-z0-9-]{1,62}$; default: dir name)") + pi.add_argument("--client", choices=["claude", "codex", "all"], default="all") + pi.add_argument("--mode", choices=["observe", "enforce"], default="observe") + pi.add_argument("--integration-mode", dest="integration_mode", + choices=["project_hook", "managed_hook", "casan_owned"], default="project_hook") + pi.add_argument("--harness", help="override harness root") + + pv = sub.add_parser("verify", help="verify the resolved harness matches the project pin") + pv.add_argument("--target", help="project root (default: cwd)") + pv.add_argument("--harness", help="override harness root") + + args = parser.parse_args(argv) + if args.cmd == "init": + return cmd_init(args) + if args.cmd == "verify": + return cmd_verify(args) + parser.print_help() + return 64 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packages/casan-devkit/tests/hybrid-install-tests.sh b/packages/casan-devkit/tests/hybrid-install-tests.sh new file mode 100755 index 0000000..92072e0 --- /dev/null +++ b/packages/casan-devkit/tests/hybrid-install-tests.sh @@ -0,0 +1,69 @@ +#!/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" + +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 .claude/settings.json .codex/hooks.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 + +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" +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" + +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)" + +echo "" +echo "===== HYBRID INSTALL SUMMARY: PASS=$PASS FAIL=$FAIL =====" +[[ "$FAIL" -eq 0 ]] || exit 1 diff --git a/packages/casan-harness/scripts/python/harness_hash.py b/packages/casan-harness/scripts/python/harness_hash.py new file mode 100755 index 0000000..4bbbce8 --- /dev/null +++ b/packages/casan-harness/scripts/python/harness_hash.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Deterministic harness integrity hash (Plan-21 hybrid install pin+verify). + +Computes a stable content hash over the CASAN gate code so a globally-installed +harness can be PINNED by a project (`.casan/version.lock`) and VERIFIED at runtime. +If the resolved global harness drifts or is tampered relative to the pin, the +project can fail closed — preserving the "gates are trusted, not arbitrary code" +guarantee from Plan-16 even when the harness lives outside the repo. + +Only GATE-relevant trees are hashed (the code that makes security/governance +decisions), never runtime state/logs: + + scripts/bash scripts/python security level5 + +stdlib-only, deterministic (sorted paths), text-mode agnostic (hashes raw bytes). + +Usage: + harness_hash.py compute # prints ":" + harness_hash.py manifest # prints JSON {file: sha256} + harness_hash.py verify # exit 0 match / 3 drift +""" + +from __future__ import annotations + +import hashlib +import json +import os +import sys + +ALGO = "sha256" +HASHED_SUBTREES = ("scripts/bash", "scripts/python", "security", "level5") +SKIP_DIR_NAMES = {"__pycache__", ".git", "node_modules"} +SKIP_SUFFIXES = (".pyc", ".pyo", ".log", ".tmp", ".DS_Store") +# Within level5, only policy/config, not regenerated runtime artifacts. +SKIP_BASENAMES = {"policy-manifest.json", "policy-manifest.sig", "project-registry.json"} + + +def _iter_files(root): + for sub in HASHED_SUBTREES: + base = os.path.join(root, sub) + if not os.path.isdir(base): + continue + for dirpath, dirnames, filenames in os.walk(base): + dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIR_NAMES) + for name in sorted(filenames): + if name in SKIP_BASENAMES: + continue + if any(name.endswith(s) for s in SKIP_SUFFIXES): + continue + full = os.path.join(dirpath, name) + rel = os.path.relpath(full, root).replace(os.sep, "/") + yield rel, full + + +def manifest(root): + out = {} + for rel, full in _iter_files(root): + try: + with open(full, "rb") as fh: + out[rel] = hashlib.sha256(fh.read()).hexdigest() + except (OSError, IOError): + out[rel] = "UNREADABLE" + return out + + +def compute(root): + m = manifest(root) + joiner = "\n".join("%s:%s" % (rel, m[rel]) for rel in sorted(m)) + digest = hashlib.sha256(joiner.encode("utf-8")).hexdigest() + return "%s:%s" % (ALGO, digest) + + +def main(argv=None): + argv = argv if argv is not None else sys.argv[1:] + if len(argv) < 2: + sys.stderr.write("usage: harness_hash.py [expected]\n") + return 64 + cmd, root = argv[0], argv[1] + if not os.path.isdir(root): + sys.stderr.write("harness_hash: not a directory: %s\n" % root) + return 66 + if cmd == "compute": + print(compute(root)) + return 0 + if cmd == "manifest": + print(json.dumps(manifest(root), ensure_ascii=False, indent=2, sort_keys=True)) + return 0 + if cmd == "verify": + if len(argv) < 3: + sys.stderr.write("harness_hash: verify needs an expected hash\n") + return 64 + actual = compute(root) + expected = argv[2].strip() + if actual == expected: + print("HARNESS_INTEGRITY_OK %s" % actual) + return 0 + sys.stderr.write("HARNESS_INTEGRITY_DRIFT expected=%s actual=%s\n" % (expected, actual)) + return 3 + sys.stderr.write("harness_hash: unknown command %s\n" % cmd) + return 64 + + +if __name__ == "__main__": + sys.exit(main())