From 114340c7193c23242cf93e99be29bf80be32d9f2 Mon Sep 17 00:00:00 2001 From: thanhnv Date: Fri, 24 Jul 2026 11:11:44 +0700 Subject: [PATCH] feat: harden CASAN production install lifecycle --- .../central-governance/policy-bundle.yaml | 12 +- .../central-governance/policy-manifest.json | 20 +- .../central-governance/policy-manifest.sig | 4 +- Dockerfile.control-panel-api | 2 +- README.md | 24 ++ bin/casan | 26 +- docker-compose.control-panel.local.yml | 2 +- docs/casan/CASAN_INSTALL_HYBRID.md | 30 +- docs/guides/CASAN_LOCAL_PROJECT_WORKFLOW.md | 2 +- docs/packaging/ADOPTION_GUIDE.md | 2 + docs/packaging/CASAN_PACKAGING_PLAN.md | 23 +- docs/packaging/CI_GUIDE.md | 38 +- docs/packaging/DOMAIN_PACK_GUIDE.md | 2 +- docs/packaging/GITEA_PACKAGE_GUIDE.md | 4 +- install.ps1 | 18 +- install.sh | 33 +- .../backend/src/goals/goals.service.ts | 2 +- .../backend/test/goals.test.ts | 2 +- packages/casan-devkit/casan-init.py | 368 +++++++++++++++++- packages/casan-devkit/install.sh | 17 +- packages/casan-devkit/project-scaffold.py | 38 +- .../templates/gitea-workflow/ci.yml | 23 +- .../tests/adoption-install-tests.sh | 3 + .../tests/hybrid-install-tests.sh | 122 +++++- .../tests/project-scaffold-tests.py | 5 +- packages/casan-harness/README.md | 6 +- .../{level5 => config}/drift-policy.yaml | 2 +- .../{level5 => config}/harness-package.json | 14 +- .../{level5 => config}/kpi-schema.yaml | 2 +- .../{level5 => config}/model-fallback.yaml | 2 +- .../{level5 => config}/project-registry.json | 0 .../{level5 => config}/tool-registry.yaml | 2 +- .../casan-harness/scripts/bash/casan-paths.sh | 4 +- .../scripts/bash/goal-orchestrator.py | 2 +- .../scripts/bash/goal-patch-executor.py | 2 +- .../scripts/bash/project_manifest.py | 2 +- .../scripts/bash/tool-registry-gate.sh | 2 +- .../scripts/bash/verify-harness-reuse.sh | 4 +- .../scripts/powershell/tool-registry-gate.ps1 | 7 +- .../scripts/python/harness_hash.py | 5 +- .../fixtures}/provider-usage-sample.json | 0 .../tests/run-casan4-harness-tests.sh | 4 +- packaging/levels.json | 28 +- packaging/runtime-layout.json | 57 +++ scripts/casan-project.mjs | 2 +- scripts/copy-runtime.py | 205 ++++++++++ scripts/package-release.sh | 14 +- scripts/smoke-release-artifact.sh | 13 + 48 files changed, 1036 insertions(+), 165 deletions(-) rename packages/casan-harness/{level5 => config}/drift-policy.yaml (86%) rename packages/casan-harness/{level5 => config}/harness-package.json (56%) rename packages/casan-harness/{level5 => config}/kpi-schema.yaml (90%) rename packages/casan-harness/{level5 => config}/model-fallback.yaml (92%) rename packages/casan-harness/{level5 => config}/project-registry.json (100%) rename packages/casan-harness/{level5 => config}/tool-registry.yaml (95%) rename packages/casan-harness/{level5 => tests/fixtures}/provider-usage-sample.json (100%) create mode 100644 packaging/runtime-layout.json create mode 100644 scripts/copy-runtime.py diff --git a/.specify/level5/central-governance/policy-bundle.yaml b/.specify/level5/central-governance/policy-bundle.yaml index 5fe8ef5..dc2a22f 100644 --- a/.specify/level5/central-governance/policy-bundle.yaml +++ b/.specify/level5/central-governance/policy-bundle.yaml @@ -29,18 +29,18 @@ policies: owner: agentops-team required: true - - id: l5-tool-registry - path: packages/casan-harness/level5/tool-registry.yaml + - id: runtime-tool-registry + path: packages/casan-harness/config/tool-registry.yaml owner: platform-team required: true - - id: l5-fallback - path: packages/casan-harness/level5/model-fallback.yaml + - id: runtime-fallback + path: packages/casan-harness/config/model-fallback.yaml owner: platform-team required: true - - id: l5-drift - path: packages/casan-harness/level5/drift-policy.yaml + - id: runtime-drift + path: packages/casan-harness/config/drift-policy.yaml owner: quality-team required: true diff --git a/.specify/level5/central-governance/policy-manifest.json b/.specify/level5/central-governance/policy-manifest.json index 5dad6f6..b017839 100644 --- a/.specify/level5/central-governance/policy-manifest.json +++ b/.specify/level5/central-governance/policy-manifest.json @@ -27,20 +27,20 @@ "sha256": "3b8b7d1b71dd654bf600c5366b012daf7dea6415e71b50688872526bfac5af33" }, { - "bytes": 2392, - "path": "packages/casan-harness/level5/tool-registry.yaml", - "sha256": "41be3e6a813e731213642f546184ec9e37601c93cd5e2eb9ee4833ad2d6c4634" + "bytes": 2395, + "path": "packages/casan-harness/config/tool-registry.yaml", + "sha256": "38221f5fb802368d7f6b6420529a529a7b425c1b63d21420c0264577275fea56" }, { - "bytes": 722, - "path": "packages/casan-harness/level5/model-fallback.yaml", - "sha256": "731f98e9ca8182af9183de3711c0376c2e5daf38c769f27a5556d438aa53e2d7" + "bytes": 714, + "path": "packages/casan-harness/config/model-fallback.yaml", + "sha256": "b010426af5fc3a3e8a7be54ea954713e97750ccfbe78006a66afd569aa2adc06" }, { - "bytes": 389, - "path": "packages/casan-harness/level5/drift-policy.yaml", - "sha256": "3d5ff7e5f06338d1a1673758ea33907ba0d5b19907ec8034d0e25f33ca248195" + "bytes": 392, + "path": "packages/casan-harness/config/drift-policy.yaml", + "sha256": "59b5fe3ad38cef7a299f59788265d67a01ca4be0d1cea19a920378afb405490c" } ], - "generated_at": "2026-07-08T09:20:55Z" + "generated_at": "2026-07-24T04:05:50Z" } diff --git a/.specify/level5/central-governance/policy-manifest.sig b/.specify/level5/central-governance/policy-manifest.sig index 16df28c..ceed387 100644 --- a/.specify/level5/central-governance/policy-manifest.sig +++ b/.specify/level5/central-governance/policy-manifest.sig @@ -1 +1,3 @@ -*B༖¹Oê\�;YYÂxñEôwI&°:U ­f?´"DŽðÖÁÇ`U"þÒå)�þ�Pœi‘~¬�?öú~ۋ Š¡²sB=¾¼ϐ‚£´½oì>UïFÚDѶ_+|N^M9üåñZª'ÌÍKí‡ï�U¾“Ÿu@�ϡ° lÀˆ��JúÐ÷¢Â'Í=ÂìEò‰S[}•¹FõÏÉz¨—>\PÛèèÕ- �Q6öôL€ó:EŸñ‘£F€ÁJÀ6Š�¬êJe»ȫÆÙIÏå=*ßö`ÿ?ˆß̸³4Ǻ^$ÜÑý³ Tær¡�0Í> 3*D3Ê#’ \ No newline at end of file +^Ô«âqv_‚GZ¬áäÅ¿ҾÕ+êç¹Ù-¬P_ ãQ�÷ÄüQeÍt)?¯Ȃ3MXjøéWn ŒÝL¶>•µVßnS Á¹¦þ +Ïšj9N<ëìÏlj6ŠÀ…¬g\>€ºH�P“ƒšc-™à‰`an­ë}Û+?m=ÆÈtBÚhzv;ðéí„PEå +?.Ë>®<ú‡ =Ɔ­Çٜ­?üU/úíÚ\׸´綷|©õ›‘,ª@2�0Ŏ‹Ûv€æwé$Q iÇEèE¤á)†®ÿo k'Qÿ`bò5¹ӑÆK ±HËí)³_b»�ˆû¥«x:I \ No newline at end of file diff --git a/Dockerfile.control-panel-api b/Dockerfile.control-panel-api index 08b3db9..6574734 100644 --- a/Dockerfile.control-panel-api +++ b/Dockerfile.control-panel-api @@ -30,7 +30,7 @@ COPY --from=builder /app/packages/casan-control-panel/backend/dist ./packages/ca COPY packages/casan-harness/scripts ./packages/casan-harness/scripts COPY packages/casan-harness/config ./packages/casan-harness/config COPY packages/casan-harness/security ./packages/casan-harness/security -COPY packages/casan-harness/level5/project-registry.json ./packages/casan-harness/level5/project-registry.json +COPY packages/casan-harness/config/project-registry.json ./packages/casan-harness/config/project-registry.json COPY packages/casan-devkit ./packages/casan-devkit # Read-only, build-time workspace snapshots. Goal orchestration resolves only roots diff --git a/README.md b/README.md index 04e135d..b528308 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,10 @@ Gói global `devkit` được dùng vì nó chứa lệnh adoption `casan init`. - macOS/Linux: `~/.casan` - Windows: `%LOCALAPPDATA%\casan` +Bản cài là runtime allowlist tối giản: không mang theo test suites, internal CI +runners, thư mục legacy `level5`, Platform dashboard/local lab, source docs hay +release tooling. Source repository vẫn giữ tests để kiểm chứng chính CASAN. + ### 2. Adopt vào repository hiện hữu ```bash @@ -239,6 +243,9 @@ casan verify-harness casan level show ``` +- Output mặc định được tối ưu để đọc trực tiếp trong terminal. Thêm `--json` + sau command khi cần payload đầy đủ cho CI hoặc script, ví dụ + `casan doctor --json`. - `doctor`: kiểm tra config, bootstrap, hook schema, adapter smoke test, VSIX và cảnh báo trust. - `verify-harness`: tính lại live hash và so với project pin; drift trả exit @@ -258,6 +265,23 @@ casan init --level core --client none `--client none` không uninstall VSIX đã cài trên máy; nếu không còn dùng route `@casan`, gỡ extension `fpt-casan.casan-governed-chat` trong VS Code. +Gỡ CASAN khỏi project: + +```bash +# Gỡ project hooks và config CASAN; giữ hook người dùng và runtime evidence +casan uninstall + +# Đồng thời xóa .specify/logs và .specify/state +casan uninstall --purge + +# Chỉ dùng khi extension dùng chung không còn cần trên máy +casan uninstall --remove-vscode-extension +``` + +`uninstall` không tự xóa CI/domain template vì các file này có thể đã trở thành +source code của project, không tự xóa `.casan-bak`, và không mặc định gỡ VS Code +extension dùng chung cho các project khác. + Khi nâng cấp CASAN: 1. Chạy lại installer từ release đã duyệt. diff --git a/bin/casan b/bin/casan index a775712..2429007 100755 --- a/bin/casan +++ b/bin/casan @@ -28,7 +28,6 @@ if [[ -z "${HARNESS:-}" || ! -d "$HARNESS" ]]; then exit 1 fi BASH_DIR="$HARNESS/scripts/bash" -TESTS_DIR="$HARNESS/tests" CASAN_APP_ROOT="${CASAN_APP_ROOT:-$(cd "$HARNESS/../.." && pwd)}" VERSION_FILE="$_self/../VERSION" [[ -f "$VERSION_FILE" ]] || VERSION_FILE="$HARNESS/../../VERSION" @@ -43,12 +42,12 @@ Usage: casan [args] Commands: init [--client ...] [--project id] Adopt CASAN and select Claude/Codex/VS Code integrations + uninstall [--purge] Remove CASAN from this project (preserves user config) doctor [--client ...] Verify configured hooks, pin, adapters, and VS Code route level Show / change the project's packaging level 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) + gate Run production checks from the project manifest verify Verify audit chain + tool audit + policy bundle reuse Verify multi-project harness reuse (registry) project validate [--manifest path] Validate a project manifest and quality profile @@ -56,7 +55,7 @@ Commands: prompt verify Verify the adopted prompt-enforcement contract prompt trace Verify that a prompt trace is H1-H7 certified pipeline [--manifest path] Run the manifest-driven SRS→test pipeline - dashboard [port] Serve the AgentOps dashboard (default 8787) + dashboard [port] Serve the AgentOps dashboard (Platform only) version Print version help This help @@ -72,6 +71,9 @@ 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 "$@" ;; + uninstall) + [[ -f "$DEVKIT_ROOT/casan-init.py" ]] || { echo "casan: uninstall requires the casan-devkit package" >&2; exit 1; } + exec python3 "$DEVKIT_ROOT/casan-init.py" uninstall "$@" ;; 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 "$@" ;; @@ -89,11 +91,12 @@ case "$cmd" in esac ;; run) exec bash "$BASH_DIR/casan-harness.sh" "$@" ;; gate) - if [[ -n "${CASAN_PROJECT_MANIFEST:-}${CASAN_PROJECT_ID:-}" ]]; then - exec bash "$BASH_DIR/project-gate.sh" "$@" + if [[ -z "${CASAN_PROJECT_MANIFEST:-}${CASAN_PROJECT_ID:-}" \ + && ! -f "$CASAN_APP_ROOT/apps/okr/domain/project.manifest.json" ]]; then + echo "casan: gate requires CASAN_PROJECT_MANIFEST or CASAN_PROJECT_ID" >&2 + exit 64 fi - exec bash "$BASH_DIR/ci-harness-gate.sh" "$@" ;; - test) exec bash "$TESTS_DIR/run-casan4-harness-tests.sh" "$@" ;; + exec bash "$BASH_DIR/project-gate.sh" "$@" ;; verify) rc=0 bash "$BASH_DIR/verify-audit-chain.sh" "$@" || rc=$? @@ -125,7 +128,12 @@ case "$cmd" in RUNNER="$CASAN_APP_ROOT/scripts/run-casan-pipeline.mjs" [[ -f "$RUNNER" ]] || { echo "casan: pipeline runner is not installed" >&2; exit 1; } exec node "$RUNNER" "$@" ;; - dashboard) exec bash "$BASH_DIR/dashboard-serve.sh" "$@" ;; + dashboard) + [[ -f "$BASH_DIR/dashboard-serve.sh" ]] || { + echo "casan: dashboard requires the Platform bundle" >&2 + exit 1 + } + exec bash "$BASH_DIR/dashboard-serve.sh" "$@" ;; version|-v|--version) version ;; help|-h|--help) usage ;; *) echo "casan: unknown command '$cmd'" >&2; usage >&2; exit 64 ;; diff --git a/docker-compose.control-panel.local.yml b/docker-compose.control-panel.local.yml index aaf0e25..867fa90 100644 --- a/docker-compose.control-panel.local.yml +++ b/docker-compose.control-panel.local.yml @@ -62,7 +62,7 @@ services: - ./apps/okr:/app/apps/okr - ./apps/service-desk:/app/apps/service-desk - ./apps/projects:/app/apps/projects - - ./packages/casan-harness/level5/project-registry.json:/app/packages/casan-harness/level5/project-registry.json + - ./packages/casan-harness/config/project-registry.json:/app/packages/casan-harness/config/project-registry.json expose: - "3010" networks: diff --git a/docs/casan/CASAN_INSTALL_HYBRID.md b/docs/casan/CASAN_INSTALL_HYBRID.md index a56fc53..187261f 100644 --- a/docs/casan/CASAN_INSTALL_HYBRID.md +++ b/docs/casan/CASAN_INSTALL_HYBRID.md @@ -42,6 +42,11 @@ Installer sẽ: - Tạo launcher `casan` (tự định vị install của chính nó) và đưa lên PATH (`~/.local/bin` hoặc `$CASAN_HOME/bin`). +Installer dùng allowlist `packaging/runtime-layout.json`. Runtime global không +chứa `tests/`, legacy `level5/`, internal test/CI runners, Platform dashboard/local +lab, source docs hay bản sao installer. Policy cần thiết đã được chuẩn hóa vào +`packages/casan-harness/config/`. + > 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`. @@ -94,6 +99,16 @@ Tham số `--client` có thể lặp hoặc comma-separated: tiếp trong terminal, CLI hiển thị menu chọn. Trong automation không có TTY, mặc định tương thích ngược là `claude,codex`. +Các command dành cho người vận hành (`init`, `doctor`, `verify-harness`, +`level show`, `uninstall`) mặc định in bản tóm tắt dễ đọc. Thêm `--json` sau +command để lấy payload đầy đủ cho automation, ví dụ: + +```bash +casan init --client claude,codex --json +casan doctor --json +casan level show --json +``` + `--mode observe|enforce` mặc định `enforce`; dùng `observe` chỉ cho pilot telemetry-only. `--integration-mode` nhận `project_hook|managed_hook|casan_owned`; `--target ` mặc định là thư mục @@ -154,6 +169,18 @@ casan doctor --client codex casan doctor --client vscode-copilot ``` +### Gỡ khỏi project + +```bash +casan uninstall +``` + +Command này xóa CASAN project hooks, bootstrap và config nhưng giữ nguyên hook +người dùng, CI/domain files, `.casan-bak`, VS Code extension dùng chung và +`.specify` evidence. Dùng `--purge` nếu chủ động muốn xóa runtime logs/state; +dùng `--remove-vscode-extension` nếu chắc chắn không project nào khác trên máy +còn dùng route `@casan`. + ## 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 @@ -163,7 +190,8 @@ Vì harness không nằm trong repo, dự án **pin** version + hash gate-code l casan verify-harness ``` -- Khớp → `status: ok` (rc 0). +- Khớp → hiển thị `Harness integrity verified` (rc 0); `--json` trả + `"status": "ok"`. - 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 diff --git a/docs/guides/CASAN_LOCAL_PROJECT_WORKFLOW.md b/docs/guides/CASAN_LOCAL_PROJECT_WORKFLOW.md index ee3da53..91e6e50 100644 --- a/docs/guides/CASAN_LOCAL_PROJECT_WORKFLOW.md +++ b/docs/guides/CASAN_LOCAL_PROJECT_WORKFLOW.md @@ -177,7 +177,7 @@ apps// └── test/ # test riêng ``` -Sau đó thêm project vào `packages/casan-harness/level5/project-registry.json` với `domain_root` trỏ tới `apps//domain`, rồi chạy: +Sau đó thêm project vào `packages/casan-harness/config/project-registry.json` với `domain_root` trỏ tới `apps//domain`, rồi chạy: ```bash packages/casan-harness/scripts/bash/verify-harness-reuse.sh diff --git a/docs/packaging/ADOPTION_GUIDE.md b/docs/packaging/ADOPTION_GUIDE.md index df75dbb..5f5bd2e 100644 --- a/docs/packaging/ADOPTION_GUIDE.md +++ b/docs/packaging/ADOPTION_GUIDE.md @@ -24,6 +24,8 @@ This copies the core harness + `bin/casan` into `../my-project`, scaffolds `apps/ticketing/domain/` from the domain-pack template, installs the prompt entrypoints and standalone `.gitea/workflows/casan-prompt-enforcement.yml`, and registers the project in `project-registry.json`. Existing domain files, registry state, and project CI are preserved. +The copied harness follows the production allowlist and excludes CASAN's own tests, +legacy `level5/`, internal CI runners, and Platform-only helpers. ## Option A2 — New production project shell diff --git a/docs/packaging/CASAN_PACKAGING_PLAN.md b/docs/packaging/CASAN_PACKAGING_PLAN.md index 63adbca..a48d433 100644 --- a/docs/packaging/CASAN_PACKAGING_PLAN.md +++ b/docs/packaging/CASAN_PACKAGING_PLAN.md @@ -9,7 +9,7 @@ contents + maturity: [`packaging/levels.json`](../../packaging/levels.json). | Lvl | Package names | Status | What it is | |---|---|:--:|---| -| **1 — Core Harness** | `casan-core`, `casan-harness` | ✅ implemented | H1–H7 harness, security + action gates, evidence pack, audit, cost/telemetry, hardening tests, policy/config defaults, `bin/casan` CLI | +| **1 — Core Harness** | `casan-core`, `casan-harness` | ✅ implemented | Minimal H1–H7 production runtime: security + action gates, evidence pack, audit, cost/telemetry, policy/config defaults, adapters, `bin/casan` CLI | | **2 — DevKit / Adoption Kit** | `casan-devkit`, `casan-project-kit` | ✅ implemented | Level 1 + project templates, domain-pack scaffold, Gitea workflow template, harness Dockerfile, install script, adoption/CI/domain-pack guides | | **3 — Platform Components** | `casan-platform`, `casan-control-panel` | 🟡 preview | Control Panel, Dashboard, Run History, governed chat MVP and **Evidence Pack Viewer**. Attack Battery Viewer, Gitea evidence publishing, and managed rollout are still pending. | | **4 — Enterprise / Governed Console** | `casan-enterprise`, `casan-governed-console` | 📋 future | Promotion layer requiring managed deployment, KMS/Object Lock operations, HA/DR/SLA, external review and compliance/support evidence. | @@ -38,6 +38,26 @@ Docker images (see `DOCKER_GUIDE.md`): `casan-harness:X.Y.Z` ✅ · `casan-platf Every bundle carries `BUNDLE-MANIFEST.txt` + `SHA256SUMS`; preview bundles also carry `PREVIEW-INCOMPLETE.txt`. +## Production runtime boundary + +`packaging/runtime-layout.json` is the canonical allowlist used by macOS/Linux, +Windows, vendored adoption, project scaffolding, and release packaging. + +Production Core/DevKit artifacts never include: + +- `packages/casan-harness/tests/` or `packages/casan-devkit/tests/`; +- internal CI/adversarial runners (`ci-harness-gate.sh`, `security-gate.sh`, + `test-integrity.py`, cloud smoke); +- the legacy `packages/casan-harness/level5/` directory; +- Platform-only dashboard/local-infrastructure helpers; +- source documentation, installer copies, Dockerfiles, caches, or release tooling + inside the installed version directory. + +Policies formerly under `level5/` live in `packages/casan-harness/config/`. +Test fixtures live under the source-only `tests/fixtures/`. The source hub retains +its tests so CASAN itself remains verifiable; only the install/release boundary is +minimal. + ## Who adopts what - **Governance-harness-only / BJT initial / CI gate** → `casan-core`. - **New project adopting CASAN** → `casan-devkit` (install.sh scaffolds domain + CI). @@ -54,6 +74,7 @@ packages/casan-enterprise/ # L4 structure-only (future; README) bin/casan # CLI scripts/package-release.sh # release packager packaging/levels.json # bundle contents + maturity (source of truth) +packaging/runtime-layout.json # exact production runtime allowlist docs/packaging/ # this plan + adoption/CI/domain-pack/gitea/docker guides ``` diff --git a/docs/packaging/CI_GUIDE.md b/docs/packaging/CI_GUIDE.md index b83e62a..a68eb64 100644 --- a/docs/packaging/CI_GUIDE.md +++ b/docs/packaging/CI_GUIDE.md @@ -11,37 +11,31 @@ cp packages/casan-devkit/templates/gitea-workflow/ci.yml .gitea/workflows/casan- ## 2. What it runs ```yaml -- bash packages/casan-harness/scripts/bash/ci-harness-gate.sh # all governance suites -- bash packages/casan-harness/scripts/bash/verify-audit-chain.sh -- bash packages/casan-harness/scripts/bash/sign-policy-bundle.sh verify +- casan verify-harness +- casan gate ``` -## 3. Environment knobs -| Var | Default | Use | -|---|---|---| -| `CASAN_CI_RUN_FRONTEND` | 0 | 1 if you have a frontend workspace | -| `CASAN_CI_RUN_BACKEND` | 0 | 1 to run backend tests | -| `CASAN_CI_RUN_INFRA_LAB` | 0 | 1 to run the Docker infra lab | -| `CASAN_CI_STEP_TIMEOUT_SEC` | 600 | raise to 1200 — some suites are model-backed and run ~450s; 600 flakes under load | -| `CASAN_CI_SUITE_FILTER` | — | regex to run a subset of suites | -| `CASAN_DOMAIN_ROOT` | apps/okr/domain | point at your project's domain | +`CASAN_PROJECT_MANIFEST` must point to the project's manifest. Build and test +commands come from that reviewed manifest; the production package does not contain +CASAN's own source-hub test suites. -## 4. Runner +## 3. Runner Uses `runs-on: ci-runner` (a self-hosted Gitea Actions runner). To set one up on your host, see `.gitea/vps-setup-runbook.md` and `scripts/setup-ci-runner.sh`. The runner needs -`bash`, `python3`, `openssl` (and `node`/`npm` only if you enable apps/okr/frontend/backend tests). +`bash`, `python3`, `openssl`, the approved global CASAN release, and the toolchain +declared by the project manifest. -## 5. Expected result -`CI_GATE_SUMMARY PASS= FAIL=0 SKIP=`. Any FAIL fails the job (exit 1). The gate is +## 4. Expected result +`PROJECT_GATE_SUMMARY ... FAIL=0`. Any FAIL fails the job (exit 1). The gate is **fail-closed**: missing signatures/policy in enforced mode (`CASAN_PROFILE=prod` or `CASAN_VERIFY_STRICT=1`) also fail. -## 6. This repo's own CI -The source hub's active pipeline is `.gitea/workflows/ci.yml` (frontend tests → security -gate → deploy). It already runs the gate from the promoted root layout -(`packages/casan-harness/...`). Use it as a worked example. +## 5. This repo's own CI +The source hub's active pipeline still runs internal regression and adversarial suites. +Those suites validate CASAN itself and are intentionally excluded from customer/runtime +artifacts. ## Tips -- Keep the gate green as an invariant; every new control must ship a fail-able adversarial test. +- Keep the project gate green as an invariant. - Do not stage runtime log artifacts (`.specify/logs`, evidence) produced by gate runs. -- For a quick local pre-push check: `bin/casan gate` (or a filtered subset). +- For a quick local pre-push check: `casan gate`. diff --git a/docs/packaging/DOMAIN_PACK_GUIDE.md b/docs/packaging/DOMAIN_PACK_GUIDE.md index cea2bf2..364208e 100644 --- a/docs/packaging/DOMAIN_PACK_GUIDE.md +++ b/docs/packaging/DOMAIN_PACK_GUIDE.md @@ -42,7 +42,7 @@ apps//domain/ export CASAN_PROJECT_MANIFEST=apps//domain/project.manifest.json bin/casan gate # runs domain-dependent suites against your pack ``` -`domain_root` is also recorded per-project in `packages/casan-harness/level5/project-registry.json` +`domain_root` is also recorded per-project in `packages/casan-harness/config/project-registry.json` so `bin/casan reuse` can prove multi-project reuse. ## Reference example diff --git a/docs/packaging/GITEA_PACKAGE_GUIDE.md b/docs/packaging/GITEA_PACKAGE_GUIDE.md index 1b61cb8..07d3bd5 100644 --- a/docs/packaging/GITEA_PACKAGE_GUIDE.md +++ b/docs/packaging/GITEA_PACKAGE_GUIDE.md @@ -65,7 +65,7 @@ curl -fsSL -XPOST -H "Authorization: token $GITEA_TOKEN" \ ## 5. Recommended cadence - Tag `vX.Y.Z` on `main` → CI green → build bundles → publish `core` + `devkit` (always), `platform` as `-preview`, `all-in-one-demo` for demos. Never publish `enterprise`. -- Keep `VERSION` and `packages/casan-harness/level5/harness-package.json` version in lockstep. +- Keep `VERSION` and `packages/casan-harness/config/harness-package.json` version in lockstep. ## Automated release (recommended — already wired) `.gitea/workflows/release.yml` does all of the above automatically on a version tag. You @@ -80,7 +80,7 @@ never hand a token to anyone — it lives in a CI secret. ```bash # bump the version everywhere first echo 1.0.1 > VERSION # must match the tag -# (also bump packages/casan-harness/level5/harness-package.json "version") +# (also bump packages/casan-harness/config/harness-package.json "version") git commit -am "release v1.0.1" git tag -a v1.0.1 -m "CASAN v1.0.1" git push origin main --follow-tags diff --git a/install.ps1 b/install.ps1 index d333ba2..df5cf66 100644 --- a/install.ps1 +++ b/install.ps1 @@ -55,22 +55,20 @@ Log "source : $Source" Log "version : $version" Log "install : $CasanHome" -# ── 2) Copy harness + devkit + CLI into a versioned dir ────────────────────── +# ── 2) Copy the production runtime allowlist 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' +$runtimeCopier = Join-Path $Source 'scripts\copy-runtime.py' +if (-not (Test-Path $runtimeCopier)) { Die 'missing production runtime copier: scripts/copy-runtime.py' } +& $py.Source $runtimeCopier --source-root $Source --destination-root $dest --component harness --component devkit --clean +if ($LASTEXITCODE -ne 0) { Die 'failed to assemble the production runtime.' } Copy-Item -Force (Join-Path $Source 'bin\casan') (Join-Path $dest 'bin\casan') Set-Content -Path (Join-Path $dest 'VERSION') -Value $version -Encoding ASCII +Set-Content -Path (Join-Path $dest '.casan-level') -Value 'devkit' -Encoding ASCII +New-Item -ItemType Directory -Force -Path (Join-Path $dest 'packaging') | Out-Null +Copy-Item -Force (Join-Path $Source 'packaging\runtime-layout.json') (Join-Path $dest 'packaging\runtime-layout.json') 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 diff --git a/install.sh b/install.sh index becbca7..b5d1d53 100755 --- a/install.sh +++ b/install.sh @@ -75,34 +75,29 @@ log "version : $VERSION" log "level : $LEVEL (core=harness+CLI, devkit=+adoption tooling)" log "install : $CASAN_HOME" -# ── 2) Copy harness + devkit + CLI into a versioned dir ────────────────────── +# ── 2) Copy the production runtime allowlist 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 -} -# L1 core: harness + CLI + installer + level manifest. -copy_tree "packages/casan-harness" +RUNTIME_COPIER="$SRC/scripts/copy-runtime.py" +[ -f "$RUNTIME_COPIER" ] || die "missing production runtime copier: scripts/copy-runtime.py" +python3 "$RUNTIME_COPIER" \ + --source-root "$SRC" --destination-root "$DEST" --component harness --clean \ + || die "failed to assemble the production harness runtime." + +# L1 core: production harness + CLI. 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" -for f in install.sh install.ps1; do [ -f "$SRC/$f" ] && cp "$SRC/$f" "$DEST/$f"; done -mkdir -p "$DEST/packaging"; [ -f "$SRC/packaging/levels.json" ] && cp "$SRC/packaging/levels.json" "$DEST/packaging/levels.json" +mkdir -p "$DEST/packaging" +cp "$SRC/packaging/runtime-layout.json" "$DEST/packaging/runtime-layout.json" -# L2 devkit: adoption tooling (templates, domain-pack, casan init, package-release). +# L2 devkit: production adoption tooling only. if [ "$LEVEL" = "devkit" ]; then - copy_tree "packages/casan-devkit" - mkdir -p "$DEST/scripts"; [ -f "$SRC/scripts/package-release.sh" ] && cp "$SRC/scripts/package-release.sh" "$DEST/scripts/package-release.sh" - [ -d "$SRC/docs/packaging" ] && { mkdir -p "$DEST/docs"; cp -R "$SRC/docs/packaging" "$DEST/docs/packaging"; } + python3 "$RUNTIME_COPIER" \ + --source-root "$SRC" --destination-root "$DEST" --component devkit --clean \ + || die "failed to assemble the production DevKit runtime." fi printf '%s\n' "$LEVEL" > "$DEST/.casan-level" diff --git a/packages/casan-control-panel/backend/src/goals/goals.service.ts b/packages/casan-control-panel/backend/src/goals/goals.service.ts index 5d90956..0e3224b 100644 --- a/packages/casan-control-panel/backend/src/goals/goals.service.ts +++ b/packages/casan-control-panel/backend/src/goals/goals.service.ts @@ -112,7 +112,7 @@ const CONNECTIONS_CLI = join(HARNESS_BIN, 'model-connections.py'); const ORCHESTRATOR_CLI = join(HARNESS_BIN, 'goal-orchestrator.py'); const PATCH_EXECUTOR_CLI = join(HARNESS_BIN, 'goal-patch-executor.py'); const RBAC_CLI = join(HARNESS_BIN, 'rbac-check.py'); -const PROJECT_REGISTRY = join(APP_ROOT, 'packages', 'casan-harness', 'level5', 'project-registry.json'); +const PROJECT_REGISTRY = join(APP_ROOT, 'packages', 'casan-harness', 'config', 'project-registry.json'); const PROJECT_SCAFFOLDER = join(APP_ROOT, 'packages', 'casan-devkit', 'project-scaffold.py'); function parseJson(value: string): T | null { diff --git a/packages/casan-control-panel/backend/test/goals.test.ts b/packages/casan-control-panel/backend/test/goals.test.ts index 06ff015..910ff0c 100644 --- a/packages/casan-control-panel/backend/test/goals.test.ts +++ b/packages/casan-control-panel/backend/test/goals.test.ts @@ -8,7 +8,7 @@ import { GoalsService } from '../src/goals/goals.service.js'; const admin = { actor: 'goal-admin', role: 'org-admin', project: 'default', tenant: 'goal-test' }; const root = resolve(import.meta.dirname, '../../../..'); -const registry = resolve(root, 'packages/casan-harness/level5/project-registry.json'); +const registry = resolve(root, 'packages/casan-harness/config/project-registry.json'); test('goal project selector exposes only active allowlisted registry entries', () => { const result = new GoalsService().projects(admin); diff --git a/packages/casan-devkit/casan-init.py b/packages/casan-devkit/casan-init.py index 076cd15..ac27884 100755 --- a/packages/casan-devkit/casan-init.py +++ b/packages/casan-devkit/casan-init.py @@ -56,6 +56,141 @@ VSCODE_EXTENSION_IDS = { } PROMPT_MARKER_START = "" PROMPT_MARKER_END = "" +CLIENT_LABELS = { + "claude": "Claude Code", + "codex": "Codex", + "vscode-copilot": "VS Code / @casan", +} + + +def _color(code, text): + if (not sys.stdout.isatty() or os.environ.get("NO_COLOR") is not None or + os.environ.get("TERM") == "dumb"): + return text + return "\033[%sm%s\033[0m" % (code, text) + + +def _mark(ok): + return _color("32" if ok else "31", "✓" if ok else "✗") + + +def _warn_mark(): + return _color("33", "!") + + +def _heading(text): + print(_color("1;36", text)) + + +def _details(rows): + width = max((len(label) for label, _value in rows), default=0) + for label, value in rows: + print(" %-*s %s" % (width, label, value)) + + +def _client_names(clients): + return ", ".join(CLIENT_LABELS.get(client, client) for client in clients) or "None" + + +def _emit_json_or_human(args, payload, renderer): + if getattr(args, "json", False): + print(json.dumps(payload, ensure_ascii=False, indent=2)) + else: + renderer(payload) + + +def _render_init(result): + _heading("%s CASAN initialized" % _mark(True)) + _details([ + ("Project", result["project_id"]), + ("Location", result["target"]), + ("Level", "%s (%s)" % ( + result["target_level_name"].capitalize(), result["target_level"])), + ("Mode", result["enforcement_mode"]), + ("Clients", _client_names(result["clients"])), + ("Files", "%d created or updated" % len(result["created"])), + ("Harness", result["harness_version"]), + ]) + print() + print(_color("1", "Integrations")) + for path, status in result["hook_merges"].items(): + ok = status not in (None, "absent") + print(" %s %-26s %s" % (_mark(ok), path, status or "unavailable")) + extension = result["vscode_extension"] + if extension.get("status") not in ("skipped",): + ok = extension.get("status") == "installed" + print(" %s %-26s %s" % ( + _mark(ok), "VS Code extension", extension.get("status"))) + print() + print(_color("1", "Next steps")) + print(" 1. Run `casan doctor`") + if "codex" in result["clients"]: + print(" 2. In Codex, open `/hooks` and trust this project's hook.") + + +def _render_verify(result): + ok = result["status"] == "ok" + _heading("%s Harness integrity %s" % ( + _mark(ok), "verified" if ok else "check failed")) + _details([ + ("Version", str(result.get("harness_version_now") or "unknown")), + ("Location", str(result.get("harness_root") or "unavailable")), + ("Expected", str(result.get("expected") or "unavailable")), + ("Actual", str(result.get("actual") or "unavailable")), + ]) + + +def _render_level(result): + _heading("CASAN packaging level") + _details([ + ("Installed", str(result.get("installed_level") or "unknown")), + ("Project", ("%s (%s)" % ( + result.get("project_target_level_name"), + result.get("project_target_level"))) + if result.get("project_target_level") else "not initialized"), + ("Status", str(result.get("project_level_status") or "unknown")), + ]) + print() + for level, description in result["levels"].items(): + print(" %-14s %s" % (level, description)) + + +def _render_doctor(result): + ready = result["status"] == "ready" + _heading("CASAN Doctor") + print(" %s Harness integrity" % _mark(result["integrity"]["ok"])) + print(" %s Project bootstrap" % _mark(result["bootstrap"]["ok"])) + for client, item in result["client_checks"].items(): + label = CLIENT_LABELS.get(client, client) + print(" %s %s" % (_mark(item.get("ready", False)), label)) + reason = item.get("smoke", {}).get("reason") + if reason and not item.get("ready"): + print(" %s" % reason) + if result["warnings"]: + print() + print(_color("1;33", "Warnings")) + for warning in result["warnings"]: + print(" %s %s" % (_warn_mark(), warning)) + print() + status = _color("1;32", "READY") if ready else _color("1;31", "NOT READY") + print("Status: %s" % status) + + +def _render_uninstall(result): + _heading("%s CASAN removed from project" % _mark(True)) + _details([ + ("Project", result.get("project_id") or "unknown"), + ("Location", result["target"]), + ("Hooks", "%d integration files cleaned" % len(result["hook_changes"])), + ("Files", "%d CASAN-owned files removed" % len(result["removed"])), + ("Evidence", "removed" if result["purged"] else "retained"), + ("VS Code extension", result["vscode_extension"]["status"]), + ]) + if result["retained"]: + print() + print(_color("1", "Retained for safety")) + for path in result["retained"]: + print(" - %s" % path) @contextmanager @@ -225,6 +360,8 @@ def _load_json_or(path, default): def _backup_once(path, backups): + if backups is None: + return bak = path + ".casan-bak" if os.path.exists(path) and not os.path.exists(bak): with open(path, "r", encoding="utf-8", errors="replace") as fh: @@ -670,10 +807,22 @@ def cmd_init(args): hhash, hsource = compute_harness_hash(harness) created = [] backups = [] + previous_manifest = _load_json_or( + os.path.join(target, ".casan", "init-manifest.json"), {}) + owned_files = { + str(path).replace("\\", "/") + for path in previous_manifest.get("owned_files", []) + if isinstance(path, str) + } def created_add(p): created.append(os.path.relpath(p, target)) + def mark_owned_if_absent(p): + if not os.path.exists(p): + owned_files.add( + os.path.relpath(p, target).replace(os.sep, "/")) + # ── .casan/config.json ── cfg_dir = os.path.join(target, ".casan") cfg = { @@ -696,6 +845,7 @@ def cmd_init(args): "target_level_name": lvl_name, } p = os.path.join(cfg_dir, "config.json") + mark_owned_if_absent(p) _write(p, json.dumps(cfg, ensure_ascii=False, indent=2) + "\n", backups); created_add(p) # ── .casan/version.lock (pin) ── @@ -708,6 +858,7 @@ def cmd_init(args): "recorded_at": now_iso(), } p = os.path.join(cfg_dir, "version.lock") + mark_owned_if_absent(p) _write(p, json.dumps(lock, ensure_ascii=False, indent=2) + "\n", backups); created_add(p) # ── .casan/agentic.env (Plan-20 flags) ── @@ -720,6 +871,7 @@ def cmd_init(args): "", ] p = os.path.join(cfg_dir, "agentic.env") + mark_owned_if_absent(p) _write(p, "\n".join(env_lines), backups); created_add(p) # Stable project-local bootstrap. It loads the config above, resolves the @@ -730,6 +882,7 @@ def cmd_init(args): if not os.path.isfile(bootstrap_source): sys.stderr.write("casan init: missing project hook bootstrap template\n") return 1 + mark_owned_if_absent(bootstrap_target) with open(bootstrap_source, "r", encoding="utf-8") as fh: _write(bootstrap_target, fh.read(), backups) try: @@ -746,6 +899,7 @@ def cmd_init(args): os.makedirs(os.path.join(specify, "logs"), exist_ok=True) gi = os.path.join(specify, ".gitignore") if not os.path.exists(gi): + mark_owned_if_absent(gi) _write(gi, "# CASAN runtime state — do not commit\nlogs/\nstate/\n", backups); created_add(gi) # ── Plan-20 client hooks — MERGED into any existing config, never clobbered. @@ -756,6 +910,7 @@ def cmd_init(args): claude_dst = os.path.join(target, ".claude", "settings.json") if "claude" in clients: dst = claude_dst + mark_owned_if_absent(dst) remove_json_hooks(dst, "claude_hook.py", backups) r = merge_json_hooks(dst, os.path.join(ad, "claude-code", "settings.template.json"), "casan-hook.py", backups) @@ -770,6 +925,7 @@ def cmd_init(args): codex_hooks_dst = os.path.join(target, ".codex", "hooks.json") if "codex" in clients: dsth = codex_hooks_dst + mark_owned_if_absent(dsth) remove_json_hooks(dsth, "codex_hook.py", backups) r = merge_json_hooks(dsth, os.path.join(ad, "codex", "hooks.template.json"), "casan-hook.py", backups) @@ -787,6 +943,7 @@ def cmd_init(args): os.path.join(target, ".codex", "config.toml"), backups) vscode_file = os.path.join(target, ".vscode", "extensions.json") + mark_owned_if_absent(vscode_file) vscode_merge = merge_vscode_recommendations(vscode_file, clients, backups) if vscode_merge not in ("unchanged", "absent"): created_add(vscode_file) @@ -812,24 +969,31 @@ def cmd_init(args): ci_src = os.path.join(dk, "templates", "gitea-workflow", "ci.yml") ci_dst = os.path.join(target, ".gitea", "workflows", "casan-ci.yml") if _copy_if_absent(ci_src, ci_dst, None, target, created): + owned_files.add(os.path.relpath( + ci_dst, target).replace(os.sep, "/")) level_extras.append(".gitea/workflows/casan-ci.yml") dom_src = os.path.join(dk, "templates", "domain-pack") dom_dst = os.path.join(target, "apps", project, "domain") + created_before_domain = len(created) n = _copy_tree_missing(dom_src, dom_dst, target, created) if n: + owned_files.update( + path.replace(os.sep, "/") + for path in created[created_before_domain:]) level_extras.append("apps/%s/domain (%d files)" % (project, n)) # ── manifest (so uninstall/verify know what init created) ── manifest = { "created": created, "backups": [os.path.relpath(b, target) for b in backups], + "owned_files": sorted(owned_files), "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({ + result = { "status": "initialized", "project_id": project, "target": target, @@ -846,7 +1010,8 @@ def cmd_init(args): "level_extras": level_extras, "note": ("harness NOT copied into repo (hybrid model); selected client hooks " "MERGED and unselected CASAN hooks removed; run `casan doctor`"), - }, ensure_ascii=False, indent=2)) + } + _emit_json_or_human(args, result, _render_init) if legacy_migration.get("manual_review"): sys.stderr.write( "casan init: WARNING — legacy CASAN prose outside managed markers " @@ -885,7 +1050,7 @@ def cmd_verify(args): "harness_version_now": harness_version(harness), "harness_root": harness, } - print(json.dumps(result, ensure_ascii=False, indent=2)) + _emit_json_or_human(args, result, _render_verify) 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") @@ -919,7 +1084,7 @@ def cmd_level(args): "4 enterprise": "future — not shipped", }, } - print(json.dumps(out, ensure_ascii=False, indent=2)) + _emit_json_or_human(args, out, _render_level) return 0 @@ -1091,10 +1256,180 @@ def cmd_doctor(args): "with Plan-20 hooks and needs manual review: %s." % ", ".join(legacy_conflicts)) checks["status"] = "ready" if ready else "not_ready" - print(json.dumps(checks, ensure_ascii=False, indent=2)) + _emit_json_or_human(args, checks, _render_doctor) return 0 if ready else 2 +def _clean_integration_json(path, metadata_key, metadata_prefix, remove_if_empty): + """Remove CASAN template metadata and delete a CASAN-created empty file.""" + if not os.path.isfile(path): + return False + doc = _load_json_or(path, None) + if not isinstance(doc, dict): + return False + metadata = doc.get(metadata_key) + if isinstance(metadata, str) and metadata.startswith(metadata_prefix): + doc.pop(metadata_key, None) + hooks = doc.get("hooks") + if isinstance(hooks, dict) and not hooks: + doc.pop("hooks", None) + recommendations = doc.get("recommendations") + if isinstance(recommendations, list) and not recommendations: + doc.pop("recommendations", None) + with owner_writable(path): + if not doc and remove_if_empty: + os.unlink(path) + else: + with open(path, "w", encoding="utf-8") as fh: + fh.write(json.dumps(doc, ensure_ascii=False, indent=2) + "\n") + return True + + +def _remove_owned_file(path, removed, target): + if not os.path.isfile(path) and not os.path.islink(path): + return + with owner_writable(path): + os.unlink(path) + removed.append(os.path.relpath(path, target).replace(os.sep, "/")) + + +def _prune_empty_directory(path): + try: + os.rmdir(path) + except OSError: + pass + + +def _uninstall_vscode_extension(enabled): + if not enabled: + return { + "status": "retained_shared", + "reason": "use --remove-vscode-extension to remove it from this machine", + } + code = shutil.which("code") + if not code: + return {"status": "not_removed", "reason": "code_cli_not_found"} + result = subprocess.run( + [code, "--uninstall-extension", "fpt-casan.casan-governed-chat"], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False) + return { + "status": "uninstalled" if result.returncode == 0 else "failed", + "command": code, + "detail": (result.stdout or result.stderr).strip(), + } + + +def cmd_uninstall(args): + """Remove one project's CASAN adoption without clobbering user config.""" + target = os.path.abspath(args.target or os.getcwd()) + config_path = os.path.join(target, ".casan", "config.json") + manifest_path = os.path.join(target, ".casan", "init-manifest.json") + config = _load_json_or(config_path, {}) + manifest = _load_json_or(manifest_path, {}) + specify_gitignore = os.path.join(target, ".specify", ".gitignore") + try: + with open(specify_gitignore, "r", encoding="utf-8") as fh: + has_casan_evidence = fh.read().startswith( + "# CASAN runtime state — do not commit") + except OSError: + has_casan_evidence = False + if (not config and not manifest and + not (args.purge and has_casan_evidence)): + sys.stderr.write( + "casan uninstall: project is not initialized; nothing to remove.\n") + return 1 + + created = { + str(path).replace("\\", "/") + for path in manifest.get("created", []) + if isinstance(path, str) + } + owned_files = { + str(path).replace("\\", "/") + for path in manifest.get("owned_files", []) + if isinstance(path, str) + } + hook_changes = {} + integration_files = ( + (".claude/settings.json", "claude_hook.py", "casan-hook.py"), + (".codex/hooks.json", "codex_hook.py", "casan-hook.py"), + ) + for relative, legacy_marker, current_marker in integration_files: + path = os.path.join(target, *relative.split("/")) + legacy = remove_json_hooks(path, legacy_marker, None) + current = remove_json_hooks(path, current_marker, None) + status = "removed" if "removed" in (legacy, current) else current + hook_changes[relative] = status + + codex_config = os.path.join(target, ".codex", "config.toml") + hook_changes[".codex/config.toml"] = clean_legacy_codex_config( + codex_config, None) + + vscode_path = os.path.join(target, ".vscode", "extensions.json") + hook_changes[".vscode/extensions.json"] = merge_vscode_recommendations( + vscode_path, [], None) + + _clean_integration_json( + os.path.join(target, ".claude", "settings.json"), + "//", "CASAN Plan-20", ".claude/settings.json" in owned_files) + _clean_integration_json( + os.path.join(target, ".codex", "hooks.json"), + "description", "CASAN Plan-20", ".codex/hooks.json" in owned_files) + _clean_integration_json( + vscode_path, "_casan_unused", "", + ".vscode/extensions.json" in owned_files) + + removed = [] + for relative in ( + ".casan/config.json", + ".casan/version.lock", + ".casan/agentic.env", + ".casan/casan-hook.py", + ".casan/init-manifest.json"): + _remove_owned_file( + os.path.join(target, *relative.split("/")), removed, target) + + if args.purge: + for relative in (".specify/logs", ".specify/state"): + path = os.path.join(target, *relative.split("/")) + if os.path.isdir(path): + with owner_writable(path): + shutil.rmtree(path) + removed.append(relative) + if has_casan_evidence: + _remove_owned_file(specify_gitignore, removed, target) + + for relative in ( + ".casan", ".claude", ".codex", ".vscode", ".specify"): + _prune_empty_directory(os.path.join(target, relative)) + + retained = [] + if not args.purge and os.path.isdir(os.path.join(target, ".specify")): + retained.append(".specify/ runtime evidence (use --purge to remove)") + for item in manifest.get("created", []): + normalized = str(item).replace("\\", "/") + if (normalized.startswith(".gitea/") or + normalized.startswith("apps/")): + retained.append(normalized + " (may contain project changes)") + backups = manifest.get("backups", []) + if backups: + retained.append("%d .casan-bak backup(s)" % len(backups)) + + result = { + "status": "uninstalled", + "project_id": config.get("project_id") or manifest.get("project_id"), + "target": target, + "hook_changes": hook_changes, + "removed": removed, + "purged": bool(args.purge), + "vscode_extension": _uninstall_vscode_extension( + args.remove_vscode_extension), + "retained": retained, + } + _emit_json_or_human(args, result, _render_uninstall) + return 0 + + def main(argv=None): parser = argparse.ArgumentParser(prog="casan-init", description="CASAN hybrid adoption") sub = parser.add_subparsers(dest="cmd") @@ -1122,21 +1457,42 @@ def main(argv=None): pi.add_argument("--force", action="store_true", help="bypass the source-hub safety guard (adopt CASAN into a CASAN checkout)") pi.add_argument("--harness", help="override harness root") + pi.add_argument("--json", action="store_true", + help="emit the complete machine-readable result") 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") + pv.add_argument("--json", action="store_true", + help="emit the complete machine-readable result") pl = sub.add_parser("level", help="show installed + project packaging level") pl.add_argument("--show", action="store_true", help="(default) show levels") pl.add_argument("--target", help="project root (default: cwd)") pl.add_argument("--harness", help="override harness root") + pl.add_argument("--json", action="store_true", + help="emit the complete machine-readable result") pd = sub.add_parser("doctor", help="verify selected client integrations end-to-end") pd.add_argument("--target", help="project root (default: cwd)") pd.add_argument("--client", action="append", help="client(s) to check; default: clients enabled in config") pd.add_argument("--harness", help="override harness root") + pd.add_argument("--json", action="store_true", + help="emit the complete machine-readable result") + + pu = sub.add_parser( + "uninstall", + help="remove CASAN from a project while preserving user configuration") + pu.add_argument("--target", help="project root (default: cwd)") + pu.add_argument( + "--purge", action="store_true", + help="also remove .specify runtime logs and state") + pu.add_argument( + "--remove-vscode-extension", action="store_true", + help="also uninstall the shared @casan VS Code extension from this machine") + pu.add_argument("--json", action="store_true", + help="emit the complete machine-readable result") args = parser.parse_args(argv) if args.cmd == "init": @@ -1153,6 +1509,8 @@ def main(argv=None): return cmd_level(args) if args.cmd == "doctor": return cmd_doctor(args) + if args.cmd == "uninstall": + return cmd_uninstall(args) parser.print_help() return 64 diff --git a/packages/casan-devkit/install.sh b/packages/casan-devkit/install.sh index 3c587be..e2360da 100755 --- a/packages/casan-devkit/install.sh +++ b/packages/casan-devkit/install.sh @@ -40,10 +40,17 @@ fi echo "==> installing CASAN core into $TARGET (project=$PROJECT domain=$DOMAIN)" mkdir -p "$TARGET/packages" "$TARGET/bin" "$TARGET/apps/$PROJECT/domain" -# 1) core harness + CLI -RSYNC_EXCLUDES=(--exclude='__pycache__' --exclude='*.pyc' --exclude='.DS_Store' --exclude='*.log') -[[ -f "$TARGET/packages/casan-harness/level5/project-registry.json" ]] && RSYNC_EXCLUDES+=(--exclude='level5/project-registry.json') -rsync -a "${RSYNC_EXCLUDES[@]}" "$SRC/packages/casan-harness/" "$TARGET/packages/casan-harness/" +# 1) production core harness + CLI. Migrate the old Level-5 registry before +# cleaning source-only/test content from an existing vendored installation. +OLD_REG="$TARGET/packages/casan-harness/level5/project-registry.json" +NEW_REG="$TARGET/packages/casan-harness/config/project-registry.json" +if [[ -f "$OLD_REG" && ! -f "$NEW_REG" ]]; then + mkdir -p "$(dirname "$NEW_REG")" + cp "$OLD_REG" "$NEW_REG" +fi +python3 "$SRC/scripts/copy-runtime.py" \ + --source-root "$SRC" --destination-root "$TARGET" --component harness --clean \ + --preserve packages/casan-harness/config/project-registry.json cp "$SRC/bin/casan" "$TARGET/bin/casan"; chmod +x "$TARGET/bin/casan" [[ -f "$SRC/VERSION" ]] && cp "$SRC/VERSION" "$TARGET/VERSION" @@ -107,7 +114,7 @@ path.write_text(path.read_text(encoding="utf-8").replace("__PROJECT_ID__", proje PY # 5) register in project-registry.json (append if absent) -REG="$TARGET/packages/casan-harness/level5/project-registry.json" +REG="$NEW_REG" python3 - "$REG" "$PROJECT" "$DOMAIN" <<'PY' import json, os, sys reg, pid, dom = sys.argv[1], sys.argv[2], sys.argv[3] diff --git a/packages/casan-devkit/project-scaffold.py b/packages/casan-devkit/project-scaffold.py index d6f2f40..69fce76 100644 --- a/packages/casan-devkit/project-scaffold.py +++ b/packages/casan-devkit/project-scaffold.py @@ -25,6 +25,35 @@ class ScaffoldError(RuntimeError): pass +def runtime_exclusions(component: str) -> set[str]: + path = SOURCE_ROOT / "packaging" / "runtime-layout.json" + try: + layout = json.loads(path.read_text(encoding="utf-8")) + values = layout["components"][component]["exclude"] + except (OSError, ValueError, KeyError, TypeError) as error: + raise ScaffoldError(f"production runtime layout is unavailable: {error}") from error + return { + str(value).replace("\\", "/").strip("/") + for value in values + if isinstance(value, str) and value.strip("/") + } + + +def excluded(relative: Path, exclusions: set[str]) -> bool: + value = relative.as_posix() + return ( + any(part in { + "__pycache__", "node_modules", "dist", "build", "coverage", + } for part in relative.parts) + or relative.name == ".DS_Store" + or relative.suffix in {".pyc", ".pyo", ".log", ".tmp"} + or any( + value == item or value.startswith(item.rstrip("/") + "/") + for item in exclusions + ) + ) + + def atomic_write(path: Path, content: bytes, mode: int = 0o644) -> str: path.parent.mkdir(parents=True, exist_ok=True) if path.is_symlink(): @@ -89,14 +118,17 @@ def copy_domain_pack(target: Path, slug: str, name: str) -> tuple[int, int]: def install_harness(target: Path) -> tuple[int, int]: created = unchanged = 0 harness_source = SOURCE_ROOT / "packages" / "casan-harness" + exclusions = runtime_exclusions("harness") for source in sorted(harness_source.rglob("*")): - if not source.is_file() or "__pycache__" in source.parts or source.suffix == ".pyc": + if not source.is_file(): continue relative = source.relative_to(harness_source) + if excluded(relative, exclusions): + continue destination = target / "packages" / "casan-harness" / relative # The target registry is adoption state, not immutable harness code. Preserve it # after the first install so repeated scaffolds and upgrades remain idempotent. - if relative.as_posix() == "level5/project-registry.json" and destination.exists(): + if relative.as_posix() == "config/project-registry.json" and destination.exists(): unchanged += 1 continue mode = stat.S_IMODE(source.stat().st_mode) @@ -114,7 +146,7 @@ def install_harness(target: Path) -> tuple[int, int]: def register_project(target: Path, slug: str, name: str) -> None: - registry_path = target / "packages" / "casan-harness" / "level5" / "project-registry.json" + registry_path = target / "packages" / "casan-harness" / "config" / "project-registry.json" data = json.loads(registry_path.read_text(encoding="utf-8")) harness_version = next((item.get("harness_version") for item in data.get("projects", []) if item.get("harness_version")), "1.0.0") # A shipped harness may carry source-hub examples. Never register dangling diff --git a/packages/casan-devkit/templates/gitea-workflow/ci.yml b/packages/casan-devkit/templates/gitea-workflow/ci.yml index f352920..a8bdca4 100644 --- a/packages/casan-devkit/templates/gitea-workflow/ci.yml +++ b/packages/casan-devkit/templates/gitea-workflow/ci.yml @@ -1,7 +1,6 @@ -# CASAN harness gate — Gitea Actions workflow (adoption template). -# Copy to .gitea/workflows/casan-ci.yml in your project. Assumes the CASAN core harness -# lives at packages/casan-harness/ (via casan-devkit install.sh) and domain data at -# apps//domain/. Runs the full governance gate on every push/PR. +# CASAN production gate — Gitea Actions workflow (adoption template). +# The runner installs the approved CASAN release globally and the project provides +# CASAN_PROJECT_MANIFEST. Internal CASAN product tests are intentionally not shipped. name: CASAN Gate on: @@ -15,11 +14,7 @@ jobs: runs-on: ci-runner timeout-minutes: 45 env: - CASAN_CI_RUN_FRONTEND: "0" # set 1 if your project has a frontend workspace - CASAN_CI_RUN_BACKEND: "0" # set 1 if your project has backend tests - CASAN_CI_RUN_INFRA_LAB: "0" - CASAN_CI_STEP_TIMEOUT_SEC: "1200" # headroom; some suites are model-backed - # CASAN_DOMAIN_ROOT: apps//domain # uncomment + set for your project + CASAN_PROJECT_MANIFEST: apps//domain/project.manifest.json steps: - name: Checkout uses: actions/checkout@v4 @@ -30,10 +25,8 @@ jobs: command -v python3 >/dev/null || { apt-get update && apt-get install -y python3; } python3 --version - - name: Run CASAN harness gate - run: bash packages/casan-harness/scripts/bash/ci-harness-gate.sh + - name: Verify installed CASAN runtime + run: casan verify-harness - - name: Verify audit chain + policy bundle - run: | - bash packages/casan-harness/scripts/bash/verify-audit-chain.sh - bash packages/casan-harness/scripts/bash/sign-policy-bundle.sh verify + - name: Run project governance gate + run: casan gate diff --git a/packages/casan-devkit/tests/adoption-install-tests.sh b/packages/casan-devkit/tests/adoption-install-tests.sh index 9711a15..99ef515 100755 --- a/packages/casan-devkit/tests/adoption-install-tests.sh +++ b/packages/casan-devkit/tests/adoption-install-tests.sh @@ -36,6 +36,9 @@ assert 'CASAN_PROJECT_ID: "sample-project"' in workflow assert "__PROJECT_ID__" not in workflow assert (root / "bin/casan-chat").exists() assert (root / "bin/casan-chat.ps1").exists() +assert not (root / "packages/casan-harness/tests").exists() +assert not (root / "packages/casan-harness/level5").exists() +assert not (root / "packages/casan-harness/scripts/bash/ci-harness-gate.sh").exists() for relative in ("docs/casan/CASAN_ADOPTION_WINDOWS.md", "docs/casan/CASAN_PROMPT_ENFORCEMENT.md"): text = (root / relative).read_text(encoding="utf-8") assert "sample-project" in text diff --git a/packages/casan-devkit/tests/hybrid-install-tests.sh b/packages/casan-devkit/tests/hybrid-install-tests.sh index 8973d14..8297448 100755 --- a/packages/casan-devkit/tests/hybrid-install-tests.sh +++ b/packages/casan-devkit/tests/hybrid-install-tests.sh @@ -27,6 +27,57 @@ 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" +python3 - "$CASAN_HOME/current" <<'PY' \ + && pass "global install has the exact production directory layout" \ + || fail "global install contains missing or source-only paths" +from pathlib import Path +import json +import sys + +root = Path(sys.argv[1]) +assert {path.name for path in root.iterdir()} == { + ".casan-level", ".harness-hash", "VERSION", "bin", "packages", "packaging", +} +assert {path.name for path in (root / "packages").iterdir()} == { + "casan-harness", "casan-devkit", +} +assert {path.name for path in (root / "packages/casan-harness").iterdir()} == { + "adapters", "agentops", "config", "governance", "memory", "prompts", + "schemas", "scripts", "security", "templates", +} +assert {path.name for path in (root / "packages/casan-devkit").iterdir()} == { + "casan-init.py", "package-vscode-extension.py", "project-scaffold.py", + "quality-profiles", "schemas", "templates", +} +assert {path.name for path in (root / "packaging").iterdir()} == { + "runtime-layout.json", +} +for forbidden in ( + "packages/casan-harness/tests", + "packages/casan-harness/level5", + "packages/casan-harness/scripts/bash/ci-harness-gate.sh", + "packages/casan-harness/scripts/bash/security-gate.sh", + "packages/casan-harness/scripts/bash/test-integrity.py", + "packages/casan-harness/scripts/bash/dashboard-server.py", + "packages/casan-devkit/tests", + "packages/casan-devkit/install.sh", + "install.sh", + "install.ps1", + "docs", +): + assert not (root / forbidden).exists(), forbidden +for required in ( + "packages/casan-harness/config/project-registry.json", + "packages/casan-harness/config/tool-registry.yaml", + "packages/casan-harness/scripts/bash/project-gate.sh", + "packages/casan-harness/scripts/python/agentic_bridge.py", +): + assert (root / required).is_file(), required +registry = json.loads( + (root / "packages/casan-harness/config/project-registry.json").read_text( + encoding="utf-8")) +assert registry["projects"] == [] +PY "$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" \ @@ -39,6 +90,33 @@ assert m.select_clients(["codex","copilot"],False)==["codex","vscode-copilot"] assert m.select_clients(["all"],False)==["claude","codex","vscode-copilot"] PY +RELEASE_DIST="$WORK/release-dist" +CASAN_DIST_DIR="$RELEASE_DIST" bash "$REPO_ROOT/scripts/package-release.sh" core >/dev/null +python3 - "$RELEASE_DIST/casan-core-v$(cat "$REPO_ROOT/VERSION").tar.gz" <<'PY' \ + && pass "core release artifact contains production runtime only" \ + || fail "core release artifact leaked source-only content" +import sys +import tarfile + +with tarfile.open(sys.argv[1], "r:gz") as archive: + names = [name.split("/", 1)[1] for name in archive.getnames() if "/" in name] +for forbidden in ( + "packages/casan-harness/tests", + "packages/casan-harness/level5", + "packages/casan-harness/scripts/bash/ci-harness-gate.sh", + "packages/casan-harness/scripts/bash/test-integrity.py", +): + assert not any(name == forbidden or name.startswith(forbidden + "/") for name in names), forbidden +for required in ( + "packages/casan-harness/config/tool-registry.yaml", + "packages/casan-harness/scripts/bash/casan-harness.sh", + "packaging/runtime-layout.json", + "scripts/copy-runtime.py", + "install.sh", +): + assert required in names, required +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 ) \ @@ -160,7 +238,7 @@ L2="$WORK/l2"; mkdir -p "$L2"; ( cd "$L2" && "$DKC" init --level 2 --project l2 ( [ -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=$( ( cd "$L2" && "$DKC" level show --json ) | 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 =====" @@ -269,6 +347,48 @@ CASAN_HOME="$DK_HOME" node "$REPO_ROOT/packages/casan-devkit/tests/vscode-extens ( 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 "===== ⑪ professional output + safe project uninstall =====" +UN="$WORK/uninstall-project" +mkdir -p "$UN/.claude" +cat > "$UN/.claude/settings.json" <<'EOF' +{"model":"claude-opus-4-8","hooks":{"PreToolUse":[{"matcher":"*","hooks":[{"type":"command","command":"my-user-hook.sh"}]}]}} +EOF +INIT_OUT=$(cd "$UN" && "$DKC" init --project uninstall-project --client claude --non-interactive) +echo "$INIT_OUT" | grep -q "CASAN initialized" \ + && pass "init defaults to concise human-readable output" \ + || fail "init human output missing ($INIT_OUT)" +if echo "$INIT_OUT" | head -1 | grep -q '^[[:space:]]*{'; then + fail "init still defaults to raw JSON" +else + pass "init no longer dumps raw JSON by default" +fi +( cd "$UN" && "$DKC" doctor --json ) | python3 -c \ + 'import json,sys; d=json.load(sys.stdin); assert d["status"] == "ready"' \ + && pass "doctor --json preserves the machine-readable contract" \ + || fail "doctor --json is not valid/ready" +UN_OUT=$(cd "$UN" && "$DKC" uninstall) +echo "$UN_OUT" | grep -q "CASAN removed from project" \ + && pass "uninstall emits a concise completion summary" \ + || fail "uninstall summary missing ($UN_OUT)" +[ ! -f "$UN/.casan/config.json" ] \ + && pass "uninstall removes CASAN-owned project config" \ + || fail "uninstall left CASAN config enabled" +py_check "$UN/.claude/settings.json" "my-user-hook" \ + && pass "uninstall preserves user-authored hooks" \ + || fail "uninstall removed a user hook" +if py_check "$UN/.claude/settings.json" "casan-hook.py"; then + fail "uninstall left the CASAN Claude hook enabled" +else + pass "uninstall removes only the CASAN hook" +fi +[ -d "$UN/.specify" ] \ + && pass "uninstall retains runtime evidence unless --purge is explicit" \ + || fail "uninstall removed evidence without --purge" +( cd "$UN" && "$DKC" uninstall --purge >/dev/null ) \ + && [ ! -d "$UN/.specify" ] \ + && pass "a follow-up uninstall --purge removes retained evidence" \ + || fail "uninstall --purge did not remove retained evidence" + echo "" echo "===== HYBRID INSTALL SUMMARY: PASS=$PASS FAIL=$FAIL =====" [[ "$FAIL" -eq 0 ]] || exit 1 diff --git a/packages/casan-devkit/tests/project-scaffold-tests.py b/packages/casan-devkit/tests/project-scaffold-tests.py index 2658f52..8a5e3fc 100644 --- a/packages/casan-devkit/tests/project-scaffold-tests.py +++ b/packages/casan-devkit/tests/project-scaffold-tests.py @@ -75,10 +75,13 @@ class ProjectScaffoldTests(unittest.TestCase): args = options(directory, with_harness=True) first = SCAFFOLD.scaffold(args) second = SCAFFOLD.scaffold(args) - registry = json.loads((Path(directory) / "packages/casan-harness/level5/project-registry.json").read_text(encoding="utf-8")) + registry = json.loads((Path(directory) / "packages/casan-harness/config/project-registry.json").read_text(encoding="utf-8")) self.assertEqual([item["project_id"] for item in registry["projects"]], ["inventory-app"]) self.assertGreater(first["created"], 100) self.assertEqual(second["created"], 0) + self.assertFalse((Path(directory) / "packages/casan-harness/tests").exists()) + self.assertFalse((Path(directory) / "packages/casan-harness/level5").exists()) + self.assertFalse((Path(directory) / "packages/casan-harness/scripts/bash/ci-harness-gate.sh").exists()) def test_invalid_identifiers_and_broad_target_fail_closed(self): with tempfile.TemporaryDirectory() as directory: diff --git a/packages/casan-harness/README.md b/packages/casan-harness/README.md index cf08383..07c17f1 100644 --- a/packages/casan-harness/README.md +++ b/packages/casan-harness/README.md @@ -8,9 +8,9 @@ Layout: - `scripts/` — bash + powershell gate logic (H1→H7), path resolver `casan-paths.sh` - `security/` — filter/policy rules (prompt-filter, pii-rules, output-policy, ...) - `governance/`, `agentops/` — H5/H6 code -- `level5/` — L5 config (drift/kpi/model-fallback/tool-registry YAMLs) -- `templates/`, `config/` — spec/plan templates, loop-policy -- `tests/` — reproducible harness test suites + integrity manifest +- `config/` — runtime policy, registry, model fallback, and tool metadata +- `templates/` — spec/plan templates +- `tests/` — source-hub verification only; excluded from production installs Runtime state (logs, audit chain, tenant state) is NOT part of this package — it stays with the app under `CASAN_STATE_ROOT`. Domain data (golden-runs, corpus, input) lives in diff --git a/packages/casan-harness/level5/drift-policy.yaml b/packages/casan-harness/config/drift-policy.yaml similarity index 86% rename from packages/casan-harness/level5/drift-policy.yaml rename to packages/casan-harness/config/drift-policy.yaml index 9aeb034..719fa4b 100644 --- a/packages/casan-harness/level5/drift-policy.yaml +++ b/packages/casan-harness/config/drift-policy.yaml @@ -1,5 +1,5 @@ version: 1.0 -description: CASAN Level 5 drift detection policy +description: CASAN production drift detection policy golden_dir: apps/okr/domain/golden-runs evidence_dir: docs/output/casan/level5-evidence diff --git a/packages/casan-harness/level5/harness-package.json b/packages/casan-harness/config/harness-package.json similarity index 56% rename from packages/casan-harness/level5/harness-package.json rename to packages/casan-harness/config/harness-package.json index d650062..22709a9 100644 --- a/packages/casan-harness/level5/harness-package.json +++ b/packages/casan-harness/config/harness-package.json @@ -5,11 +5,11 @@ "H4-security", "H5-governance", "H6-agentops", - "L5-drift-detection", - "L5-model-fallback", - "L5-tool-registry", - "L5-rollback", - "L5-business-feedback" + "drift-detection", + "model-fallback", + "tool-registry", + "rollback", + "business-feedback" ], "compatibility": { "speckit": ">=1.0.0", @@ -18,7 +18,7 @@ }, "entrypoints": { "wrapper": "packages/casan-harness/scripts/bash/casan-harness.sh", - "test": "packages/casan-harness/tests/run-casan4-harness-tests.sh", - "dashboard": "docs/output/casan/agentops-dashboard.html" + "project_gate": "packages/casan-harness/scripts/bash/project-gate.sh", + "agentic_bridge": "packages/casan-harness/scripts/python/agentic_bridge.py" } } diff --git a/packages/casan-harness/level5/kpi-schema.yaml b/packages/casan-harness/config/kpi-schema.yaml similarity index 90% rename from packages/casan-harness/level5/kpi-schema.yaml rename to packages/casan-harness/config/kpi-schema.yaml index 14d8923..0e7928d 100644 --- a/packages/casan-harness/level5/kpi-schema.yaml +++ b/packages/casan-harness/config/kpi-schema.yaml @@ -1,5 +1,5 @@ version: 1.0 -description: CASAN Level 5 business KPI feedback schema +description: CASAN business KPI feedback schema kpis: - id: cycle_time_minutes diff --git a/packages/casan-harness/level5/model-fallback.yaml b/packages/casan-harness/config/model-fallback.yaml similarity index 92% rename from packages/casan-harness/level5/model-fallback.yaml rename to packages/casan-harness/config/model-fallback.yaml index 6dab422..fddd295 100644 --- a/packages/casan-harness/level5/model-fallback.yaml +++ b/packages/casan-harness/config/model-fallback.yaml @@ -1,5 +1,5 @@ version: 1.0 -description: CASAN Level 5 model/tool fallback policy +description: CASAN model/tool fallback policy routes: - step_pattern: "review|test|security" diff --git a/packages/casan-harness/level5/project-registry.json b/packages/casan-harness/config/project-registry.json similarity index 100% rename from packages/casan-harness/level5/project-registry.json rename to packages/casan-harness/config/project-registry.json diff --git a/packages/casan-harness/level5/tool-registry.yaml b/packages/casan-harness/config/tool-registry.yaml similarity index 95% rename from packages/casan-harness/level5/tool-registry.yaml rename to packages/casan-harness/config/tool-registry.yaml index 7a9d8de..e8cccea 100644 --- a/packages/casan-harness/level5/tool-registry.yaml +++ b/packages/casan-harness/config/tool-registry.yaml @@ -1,5 +1,5 @@ version: 1.0 -description: CASAN Level 5 tool registry with ownership, schema, risk, idempotency, and rollback metadata +description: CASAN production tool registry with ownership, schema, risk, idempotency, and rollback metadata tools: - id: agent_step diff --git a/packages/casan-harness/scripts/bash/casan-paths.sh b/packages/casan-harness/scripts/bash/casan-paths.sh index a8611b7..6ca9db0 100644 --- a/packages/casan-harness/scripts/bash/casan-paths.sh +++ b/packages/casan-harness/scripts/bash/casan-paths.sh @@ -41,8 +41,8 @@ _casan_find_app_root() { return 1 } -# Harness (CODE) root: the tree that holds scripts/, security/, templates/, tests/, -# config/, level5/*.yaml. Derived from this file's location so it follows the move. +# Harness (CODE) root: the production tree that holds scripts/, security/, +# templates/, config/, adapters/, and schemas/. Derived from this file's location. if [[ -z "${CASAN_HARNESS_ROOT:-}" ]]; then CASAN_HARNESS_ROOT="$(cd "$_casan_paths_self/../.." && pwd)" fi diff --git a/packages/casan-harness/scripts/bash/goal-orchestrator.py b/packages/casan-harness/scripts/bash/goal-orchestrator.py index ca91737..01653df 100644 --- a/packages/casan-harness/scripts/bash/goal-orchestrator.py +++ b/packages/casan-harness/scripts/bash/goal-orchestrator.py @@ -36,7 +36,7 @@ BIN = os.path.join(ROOT, "packages", "casan-harness", "scripts", "bash") MODEL_ROUTER = os.environ.get("CASAN_GOAL_MODEL_ROUTER") or os.path.join(BIN, "model-router.sh") SECURITY = os.path.join(BIN, "security-check.sh") STATE_ROOT = os.environ.get("CASAN_STATE_ROOT") or os.path.join(ROOT, ".specify") -PROJECT_REGISTRY = os.path.join(ROOT, "packages", "casan-harness", "level5", "project-registry.json") +PROJECT_REGISTRY = os.path.join(ROOT, "packages", "casan-harness", "config", "project-registry.json") APPROVAL_INBOX = os.path.join(BIN, "approval-inbox.py") _MANIFEST_SPEC = importlib.util.spec_from_file_location( "casan_goal_project_manifest", os.path.join(BIN, "project_manifest.py") diff --git a/packages/casan-harness/scripts/bash/goal-patch-executor.py b/packages/casan-harness/scripts/bash/goal-patch-executor.py index 74d7a69..8974994 100644 --- a/packages/casan-harness/scripts/bash/goal-patch-executor.py +++ b/packages/casan-harness/scripts/bash/goal-patch-executor.py @@ -77,7 +77,7 @@ def _manifest_for_files(files: list[str]) -> dict: if selected or project: return PROJECT_MANIFEST.load(ROOT, selected, project) - registry = load(os.path.join(ROOT, "packages", "casan-harness", "level5", "project-registry.json")) + registry = load(os.path.join(ROOT, "packages", "casan-harness", "config", "project-registry.json")) candidates = [] for entry in registry.get("projects", []): manifest_path = entry.get("manifest") diff --git a/packages/casan-harness/scripts/bash/project_manifest.py b/packages/casan-harness/scripts/bash/project_manifest.py index adcb3ab..23ee2b1 100644 --- a/packages/casan-harness/scripts/bash/project_manifest.py +++ b/packages/casan-harness/scripts/bash/project_manifest.py @@ -70,7 +70,7 @@ def load(root: str, manifest_path: str | None = None, project_id: str | None = N if selected: path = within(root, selected, "manifest") elif requested: - registry = _json(os.path.join(root, "packages/casan-harness/level5/project-registry.json"), "project registry") + registry = _json(os.path.join(root, "packages/casan-harness/config/project-registry.json"), "project registry") entry = next((item for item in registry.get("projects", []) if item.get("project_id") == requested), None) if not entry: _fail(f"project is not registered: {requested}") diff --git a/packages/casan-harness/scripts/bash/tool-registry-gate.sh b/packages/casan-harness/scripts/bash/tool-registry-gate.sh index acf70c2..b61466e 100755 --- a/packages/casan-harness/scripts/bash/tool-registry-gate.sh +++ b/packages/casan-harness/scripts/bash/tool-registry-gate.sh @@ -14,7 +14,7 @@ fi SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/casan-paths.sh" PROJECT_ROOT="$CASAN_APP_ROOT" -REGISTRY="$CASAN_HARNESS_ROOT/level5/tool-registry.yaml" +REGISTRY="$CASAN_HARNESS_ROOT/config/tool-registry.yaml" LOG_DIR="$CASAN_STATE_ROOT/logs/level5" AUDIT_DIR="$CASAN_STATE_ROOT/logs/audit" mkdir -p "$LOG_DIR" "$AUDIT_DIR" diff --git a/packages/casan-harness/scripts/bash/verify-harness-reuse.sh b/packages/casan-harness/scripts/bash/verify-harness-reuse.sh index 45a8dcb..73d6143 100755 --- a/packages/casan-harness/scripts/bash/verify-harness-reuse.sh +++ b/packages/casan-harness/scripts/bash/verify-harness-reuse.sh @@ -6,8 +6,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/casan-paths.sh" PROJECT_ROOT="$CASAN_APP_ROOT" -REGISTRY="$CASAN_HARNESS_ROOT/level5/project-registry.json" -PACKAGE="$CASAN_HARNESS_ROOT/level5/harness-package.json" +REGISTRY="$CASAN_HARNESS_ROOT/config/project-registry.json" +PACKAGE="$CASAN_HARNESS_ROOT/config/harness-package.json" python - "$REGISTRY" "$PACKAGE" <<'PY' import json diff --git a/packages/casan-harness/scripts/powershell/tool-registry-gate.ps1 b/packages/casan-harness/scripts/powershell/tool-registry-gate.ps1 index 958c917..68ec936 100644 --- a/packages/casan-harness/scripts/powershell/tool-registry-gate.ps1 +++ b/packages/casan-harness/scripts/powershell/tool-registry-gate.ps1 @@ -1,5 +1,5 @@ #!/usr/bin/env pwsh -# CASAN L5 Tool Registry Gate - PowerShell port of tool-registry-gate.sh +# CASAN Tool Registry Gate - PowerShell port of tool-registry-gate.sh # Usage: # tool-registry-gate.ps1 [idempotency-key] # @@ -13,8 +13,9 @@ param( $ErrorActionPreference = "Stop" $scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent -$projectRoot = (Resolve-Path (Join-Path $scriptDir "../../..")).Path -$registry = Join-Path $projectRoot ".specify/level5/tool-registry.yaml" +$harnessRoot = if ($env:CASAN_HARNESS_ROOT) { $env:CASAN_HARNESS_ROOT } else { (Resolve-Path (Join-Path $scriptDir "../..")).Path } +$projectRoot = if ($env:CASAN_APP_ROOT) { $env:CASAN_APP_ROOT } else { (Resolve-Path (Join-Path $scriptDir "../../../..")).Path } +$registry = Join-Path $harnessRoot "config/tool-registry.yaml" $logDir = Join-Path $projectRoot ".specify/logs/level5" $txLog = Join-Path $logDir "tool-registry.jsonl" $toolCallLog = Join-Path $projectRoot ".specify/logs/audit/tool-calls.jsonl" diff --git a/packages/casan-harness/scripts/python/harness_hash.py b/packages/casan-harness/scripts/python/harness_hash.py index 6c873b8..f30ecbd 100755 --- a/packages/casan-harness/scripts/python/harness_hash.py +++ b/packages/casan-harness/scripts/python/harness_hash.py @@ -11,7 +11,7 @@ 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 adapters schemas + scripts/bash scripts/python security config adapters schemas stdlib-only, deterministic (sorted paths), text-mode agnostic (hashes raw bytes). @@ -30,10 +30,9 @@ import sys ALGO = "sha256" HASHED_SUBTREES = ( - "scripts/bash", "scripts/python", "security", "level5", "adapters", "schemas") + "scripts/bash", "scripts/python", "security", "config", "adapters", "schemas") 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"} diff --git a/packages/casan-harness/level5/provider-usage-sample.json b/packages/casan-harness/tests/fixtures/provider-usage-sample.json similarity index 100% rename from packages/casan-harness/level5/provider-usage-sample.json rename to packages/casan-harness/tests/fixtures/provider-usage-sample.json diff --git a/packages/casan-harness/tests/run-casan4-harness-tests.sh b/packages/casan-harness/tests/run-casan4-harness-tests.sh index 96661f6..42ab289 100755 --- a/packages/casan-harness/tests/run-casan4-harness-tests.sh +++ b/packages/casan-harness/tests/run-casan4-harness-tests.sh @@ -141,7 +141,7 @@ HALLU_COUNT="$(tail -n 1 "$CASAN_STATE_ROOT/logs/cost/metrics.jsonl" | sed -n 's [[ "${HALLU_COUNT:-0}" -ge 3 ]] && pass "H6 detects hallucination signals (count=$HALLU_COUNT)" || fail "H6 did not detect hallucination signals (count=${HALLU_COUNT:-0})" # H6: imported provider telemetry becomes the authoritative cost source -"$SCRIPTS/import-provider-telemetry.sh" "$CASAN_HARNESS_ROOT/level5/provider-usage-sample.json" > "$EVIDENCE_DIR/05c-provider-import.stdout" +"$SCRIPTS/import-provider-telemetry.sh" "$CASAN_HARNESS_ROOT/tests/fixtures/provider-usage-sample.json" > "$EVIDENCE_DIR/05c-provider-import.stdout" CASAN_AGENT_NAME=demo.agent CASAN_STEP_NAME=speckit.implement \ "$SCRIPTS/agent-metrics.sh" "$METRICS_IN" "$EVIDENCE_DIR/05c-provider-output.txt" > "$EVIDENCE_DIR/05c-provider-metrics.stdout" assert_contains "$CASAN_STATE_ROOT/logs/cost/metrics.jsonl" '"cost_source":"provider_telemetry"' @@ -261,7 +261,7 @@ assert_contains "$LEVEL5_DIR/14-business-kpi.stdout" "status=pass" assert_contains "$LEVEL5_DIR/16-policy-verify.stdout" "POLICY_SIGNATURE_VALID" # L5: real provider usage telemetry import path -"$SCRIPTS/import-provider-telemetry.sh" "$CASAN_HARNESS_ROOT/level5/provider-usage-sample.json" > "$LEVEL5_DIR/17-provider-telemetry.stdout" +"$SCRIPTS/import-provider-telemetry.sh" "$CASAN_HARNESS_ROOT/tests/fixtures/provider-usage-sample.json" > "$LEVEL5_DIR/17-provider-telemetry.stdout" assert_contains "$LEVEL5_DIR/17-provider-telemetry.stdout" "PROVIDER_TELEMETRY_IMPORTED" # L5: shared harness package is registered by multiple projects diff --git a/packaging/levels.json b/packaging/levels.json index eff7128..8001d91 100644 --- a/packaging/levels.json +++ b/packaging/levels.json @@ -7,29 +7,25 @@ "level": 1, "package_names": ["casan-core", "casan-harness"], "status": "implemented", - "summary": "H1-H7 harness core, security/action gates, evidence pack, audit, cost/telemetry, hardening tests, policy/config defaults, lightweight CLI.", + "summary": "Minimal H1-H7 production runtime: security/action gates, evidence pack, audit, cost/telemetry, policy/config defaults, adapters, and lightweight CLI. Source-only tests and Platform helpers are excluded.", "target_users": ["governance-harness-only projects", "BJT initial adoption", "CI gate usage"], "includes": [ - "packages/casan-harness/scripts", - "packages/casan-harness/security", - "packages/casan-harness/governance", - "packages/casan-harness/agentops", - "packages/casan-harness/config", - "packages/casan-harness/level5", - "packages/casan-harness/memory", - "packages/casan-harness/templates", - "packages/casan-harness/tests", - "packages/casan-harness/adapters", - "packages/casan-harness/schemas", - "packages/casan-harness/init-options.json", - "packages/casan-harness/README.md", + "packages/casan-harness", "bin/casan", "install.sh", "install.ps1", "packaging/levels.json", + "packaging/runtime-layout.json", + "scripts/copy-runtime.py", "VERSION" ], - "excludes_globs": ["**/__pycache__/**", "**/*.pyc", "**/.DS_Store"], + "excludes_globs": [ + "**/__pycache__/**", + "**/*.pyc", + "**/.DS_Store", + "packages/casan-harness/tests/**", + "packages/casan-harness/level5/**" + ], "artifact": "casan-core-v{VERSION}.tar.gz", "docker_image": "casan-harness:{VERSION}" }, @@ -37,7 +33,7 @@ "level": 2, "package_names": ["casan-devkit", "casan-project-kit"], "status": "implemented", - "summary": "Level 1 + project templates, domain-pack scaffold, Gitea workflow templates, harness Dockerfile, install script, adoption/CI/domain-pack guides.", + "summary": "Level 1 + production adoption commands, project templates, domain-pack scaffold, Gitea workflow templates, and adoption/CI/domain-pack guides.", "target_users": ["new projects adopting CASAN", "internal teams needing a repeatable setup"], "extends": "core", "includes": [ diff --git a/packaging/runtime-layout.json b/packaging/runtime-layout.json new file mode 100644 index 0000000..1348323 --- /dev/null +++ b/packaging/runtime-layout.json @@ -0,0 +1,57 @@ +{ + "schema_version": 1, + "description": "Production runtime allowlist for global CASAN installs and release bundles. Source-only tests, development tooling, and Platform helpers must not cross this boundary.", + "components": { + "harness": { + "source": "packages/casan-harness", + "exclude": [ + "README.md", + "init-options.json", + "level5", + "tests", + "scripts/bash/chat-cloud-smoke.sh", + "scripts/bash/ci-harness-gate.sh", + "scripts/bash/dashboard-serve.sh", + "scripts/bash/dashboard-server.py", + "scripts/bash/guideH4-H5-H6.md", + "scripts/bash/infra-lab.sh", + "scripts/bash/local-full.sh", + "scripts/bash/security-gate.sh", + "scripts/bash/test-integrity.py" + ], + "transforms": { + "config/project-registry.json": "empty_project_registry" + }, + "required": [ + "adapters/claude-code/claude_hook.py", + "adapters/codex/codex_hook.py", + "adapters/vscode/vscode_hook.py", + "config/project-registry.json", + "config/tool-registry.yaml", + "scripts/bash/casan-harness.sh", + "scripts/bash/project-gate.sh", + "scripts/bash/security-check.sh", + "scripts/bash/tool-registry-gate.sh", + "scripts/python/agentic_bridge.py", + "scripts/python/harness_hash.py" + ] + }, + "devkit": { + "source": "packages/casan-devkit", + "exclude": [ + "Dockerfile.harness", + "README.md", + "install.sh", + "tests", + "windows" + ], + "required": [ + "casan-init.py", + "package-vscode-extension.py", + "project-scaffold.py", + "templates/domain-pack/domain-pack.yaml", + "templates/project/casan-hook.py" + ] + } + } +} diff --git a/scripts/casan-project.mjs b/scripts/casan-project.mjs index 503e252..8eb6529 100644 --- a/scripts/casan-project.mjs +++ b/scripts/casan-project.mjs @@ -63,7 +63,7 @@ function locateManifest(root, { manifestPath, projectId } = {}) { const requestedProject = projectId || process.env.CASAN_PROJECT_ID; if (requestedProject) { - const registryPath = join(root, 'packages/casan-harness/level5/project-registry.json'); + const registryPath = join(root, 'packages/casan-harness/config/project-registry.json'); const registry = readJson(registryPath, 'project registry'); const entry = registry.projects?.find((item) => item.project_id === requestedProject || item.manifest === requestedProject); if (!entry) fail(`project is not registered: ${requestedProject}`); diff --git a/scripts/copy-runtime.py b/scripts/copy-runtime.py new file mode 100644 index 0000000..df8f823 --- /dev/null +++ b/scripts/copy-runtime.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +"""Copy one CASAN production component through the canonical runtime allowlist.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import shutil +import sys + + +IGNORED_NAMES = { + "__pycache__", ".DS_Store", "node_modules", "dist", "build", "coverage", +} +IGNORED_SUFFIXES = {".pyc", ".pyo", ".log", ".tmp"} + + +class LayoutError(RuntimeError): + pass + + +def load_layout(source_root: Path) -> dict: + path = source_root / "packaging" / "runtime-layout.json" + try: + layout = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as error: + raise LayoutError(f"cannot load {path}: {error}") from error + if layout.get("schema_version") != 1: + raise LayoutError("runtime layout schema_version must be 1") + return layout + + +def is_excluded(relative: Path, exclusions: set[str]) -> bool: + normalized = relative.as_posix() + if any(part in IGNORED_NAMES for part in relative.parts): + return True + if relative.suffix in IGNORED_SUFFIXES: + return True + return any( + normalized == excluded or normalized.startswith(excluded.rstrip("/") + "/") + for excluded in exclusions + ) + + +def transformed_content(path: Path, transform: str) -> bytes: + if transform != "empty_project_registry": + raise LayoutError(f"unknown runtime transform: {transform}") + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as error: + raise LayoutError(f"cannot transform project registry: {error}") from error + payload["projects"] = [] + return (json.dumps(payload, ensure_ascii=False, indent=2) + "\n").encode("utf-8") + + +def copy_component( + source_root: Path, + destination_root: Path, + name: str, + preserved: set[str] | None = None, + clean: bool = False) -> dict: + layout = load_layout(source_root) + component = layout.get("components", {}).get(name) + if not isinstance(component, dict): + raise LayoutError(f"unknown runtime component: {name}") + + relative_source = Path(str(component.get("source", ""))) + source = (source_root / relative_source).resolve() + destination = destination_root / relative_source + if not source.is_dir(): + raise LayoutError(f"component source is missing: {source}") + if source_root not in source.parents: + raise LayoutError(f"component source escapes source root: {source}") + + exclusions = { + str(path).replace("\\", "/").strip("/") + for path in component.get("exclude", []) + if isinstance(path, str) and path.strip("/") + } + unsafe = [ + path for path in exclusions + if Path(path).is_absolute() or ".." in Path(path).parts + ] + if unsafe: + raise LayoutError( + f"runtime component {name} has unsafe exclusions: {', '.join(unsafe)}") + transforms = component.get("transforms", {}) + if not isinstance(transforms, dict): + raise LayoutError(f"runtime component {name} transforms must be an object") + removed = 0 + if clean and destination.exists(): + for excluded in sorted(exclusions, reverse=True): + candidate = destination / excluded + if candidate.is_dir() and not candidate.is_symlink(): + shutil.rmtree(candidate) + removed += 1 + elif candidate.exists() or candidate.is_symlink(): + candidate.unlink() + removed += 1 + for candidate in sorted(destination.rglob("*"), reverse=True): + if (candidate.name in IGNORED_NAMES or + (candidate.is_file() and candidate.suffix in IGNORED_SUFFIXES)): + if candidate.is_dir() and not candidate.is_symlink(): + shutil.rmtree(candidate) + else: + candidate.unlink() + removed += 1 + copied = 0 + skipped = 0 + preserved = preserved or set() + for path in sorted(source.rglob("*")): + relative = path.relative_to(source) + if is_excluded(relative, exclusions): + skipped += path.is_file() + continue + target = destination / relative + if path.is_dir(): + continue + if not path.is_file(): + continue + target_relative = (relative_source / relative).as_posix() + if target_relative in preserved and target.exists(): + skipped += 1 + continue + target.parent.mkdir(parents=True, exist_ok=True) + transform = transforms.get(relative.as_posix()) + if transform: + target.write_bytes(transformed_content(path, str(transform))) + shutil.copymode(path, target) + else: + shutil.copy2(path, target) + copied += 1 + + if clean and destination.exists(): + for candidate in sorted(destination.rglob("*"), reverse=True): + if candidate.is_dir() and not candidate.is_symlink(): + try: + candidate.rmdir() + except OSError: + pass + + missing = [ + required for required in component.get("required", []) + if not (destination / required).is_file() + ] + if missing: + raise LayoutError( + f"runtime component {name} is incomplete; missing: {', '.join(missing)}") + return { + "component": name, + "source": str(source), + "destination": str(destination), + "copied_files": copied, + "excluded_files": skipped, + "removed_paths": removed, + } + + +def parser() -> argparse.ArgumentParser: + value = argparse.ArgumentParser(description=__doc__) + value.add_argument("--source-root", required=True) + value.add_argument("--destination-root", required=True) + value.add_argument("--component", action="append", required=True) + value.add_argument( + "--preserve", action="append", default=[], + help="destination-root-relative file to retain when it already exists") + value.add_argument( + "--clean", action="store_true", + help="remove excluded source-only paths left by an older installation") + value.add_argument("--json", action="store_true") + return value + + +def main(argv: list[str] | None = None) -> int: + args = parser().parse_args(argv) + source_root = Path(args.source_root).expanduser().resolve() + destination_root = Path(args.destination_root).expanduser().resolve() + destination_root.mkdir(parents=True, exist_ok=True) + preserved = { + str(path).replace("\\", "/").strip("/") + for path in args.preserve + if str(path).strip("/") + } + try: + results = [ + copy_component( + source_root, destination_root, component, preserved, args.clean) + for component in args.component + ] + except LayoutError as error: + sys.stderr.write(f"copy-runtime: {error}\n") + return 1 + if args.json: + print(json.dumps({"status": "ok", "components": results}, indent=2)) + else: + for result in results: + print( + "copy-runtime: {component} copied={copied_files} " + "excluded={excluded_files} removed={removed_paths}".format(**result)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/package-release.sh b/scripts/package-release.sh index 273933d..ccf45fc 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -16,7 +16,7 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" MANIFEST="packaging/levels.json" VERSION="$(cat VERSION 2>/dev/null || echo 0.0.0)" -DIST="$ROOT/dist" +DIST="${CASAN_DIST_DIR:-$ROOT/dist}" BUNDLE="${1:-}" if [[ -z "$BUNDLE" ]]; then @@ -87,6 +87,18 @@ copy_path() { # fi local dest="$PKGDIR/$1" mkdir -p "$(dirname "$dest")" + case "$1" in + packages/casan-harness) + python3 "$ROOT/scripts/copy-runtime.py" \ + --source-root "$ROOT" --destination-root "$PKGDIR" --component harness + return + ;; + packages/casan-devkit) + python3 "$ROOT/scripts/copy-runtime.py" \ + --source-root "$ROOT" --destination-root "$PKGDIR" --component devkit + return + ;; + esac # copy, dropping caches/artifacts if [[ -d "$src" ]]; then rsync -a --exclude='__pycache__' --exclude='*.pyc' --exclude='.DS_Store' "$src/" "$dest/" 2>/dev/null \ diff --git a/scripts/smoke-release-artifact.sh b/scripts/smoke-release-artifact.sh index 875f7c1..1c41477 100755 --- a/scripts/smoke-release-artifact.sh +++ b/scripts/smoke-release-artifact.sh @@ -37,6 +37,19 @@ for path in "${required[@]}"; do } done +for path in \ + packages/casan-harness/tests \ + packages/casan-harness/level5 \ + packages/casan-harness/scripts/bash/ci-harness-gate.sh \ + packages/casan-harness/scripts/bash/security-gate.sh \ + packages/casan-harness/scripts/bash/test-integrity.py \ + packages/casan-devkit/tests; do + [[ ! -e "$ROOT/$path" ]] || { + echo "ARTIFACT_SMOKE_FAIL reason=source_only_path_present path=$path" >&2 + exit 1 + } +done + (cd "$ROOT" && shasum -a 256 -c SHA256SUMS >/dev/null) VERSION="$(tr -d '[:space:]' < "$ROOT/VERSION")" CLI_VERSION="$(cd "$ROOT" && ./bin/casan version)"