feat(install): Plan-21 hybrid global install + casan init (pin+verify)
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/<ver>, 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
f6d28a3163
commit
8450f8ca1a
@@ -42,6 +42,8 @@ casan — CASAN governance harness CLI ($(version))
|
|||||||
Usage: casan <command> [args]
|
Usage: casan <command> [args]
|
||||||
|
|
||||||
Commands:
|
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 <in> <out> [action] [-- cmd...] Run a step through the harness (H4→H5→H6→exec→H4-out)
|
run <in> <out> [action] [-- cmd...] Run a step through the harness (H4→H5→H6→exec→H4-out)
|
||||||
gate Run the full CI harness gate (all suites)
|
gate Run the full CI harness gate (all suites)
|
||||||
test Run the core harness test suite (run-casan4)
|
test Run the core harness test suite (run-casan4)
|
||||||
@@ -62,7 +64,15 @@ EOF
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd="${1:-help}"; shift || true
|
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
|
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" "$@" ;;
|
run) exec bash "$BASH_DIR/casan-harness.sh" "$@" ;;
|
||||||
gate)
|
gate)
|
||||||
if [[ -n "${CASAN_PROJECT_MANIFEST:-}${CASAN_PROJECT_ID:-}" ]]; then
|
if [[ -n "${CASAN_PROJECT_MANIFEST:-}${CASAN_PROJECT_ID:-}" ]]; then
|
||||||
|
|||||||
@@ -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://<your-gitea>/admin/casan5/raw/branch/main/install.sh | sh
|
||||||
|
|
||||||
|
# Windows (PowerShell)
|
||||||
|
irm https://<your-gitea>/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/<version>` (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 <dự-án-của-bạn>
|
||||||
|
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 <dir>` (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
|
||||||
|
```
|
||||||
@@ -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/<ver>`).
|
||||||
|
- `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 đủ.
|
||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
#requires -Version 5.1
|
||||||
|
<#
|
||||||
|
CASAN global installer (Plan-21 hybrid model) — Windows PowerShell.
|
||||||
|
|
||||||
|
irm https://<your-gitea>/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 <dir>, 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 <your-project>; casan init"
|
||||||
|
if ($cleanup) { Remove-Item -Recurse -Force $cleanup -ErrorAction SilentlyContinue }
|
||||||
Executable
+132
@@ -0,0 +1,132 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# CASAN global installer (Plan-21 hybrid model) — macOS / Linux.
|
||||||
|
#
|
||||||
|
# curl -fsSL https://<your-gitea>/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=<dir>, or set CASAN_DIST_URL=<tarball>."
|
||||||
|
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() { # <relpath>
|
||||||
|
[ -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 <your-project> && casan init"
|
||||||
|
|
||||||
|
[ -n "$CLEANUP" ] && rm -rf "$CLEANUP" || true
|
||||||
Executable
+294
@@ -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())
|
||||||
+69
@@ -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
|
||||||
+105
@@ -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 <harness_root> # prints "<algo>:<hex>"
|
||||||
|
harness_hash.py manifest <harness_root> # prints JSON {file: sha256}
|
||||||
|
harness_hash.py verify <harness_root> <expected> # 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 <compute|manifest|verify> <harness_root> [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())
|
||||||
Reference in New Issue
Block a user