feat: harden CASAN production install lifecycle

This commit is contained in:
thanhnv
2026-07-24 11:11:44 +07:00
parent c7671ab198
commit 114340c719
48 changed files with 1036 additions and 165 deletions
@@ -29,18 +29,18 @@ policies:
owner: agentops-team owner: agentops-team
required: true required: true
- id: l5-tool-registry - id: runtime-tool-registry
path: packages/casan-harness/level5/tool-registry.yaml path: packages/casan-harness/config/tool-registry.yaml
owner: platform-team owner: platform-team
required: true required: true
- id: l5-fallback - id: runtime-fallback
path: packages/casan-harness/level5/model-fallback.yaml path: packages/casan-harness/config/model-fallback.yaml
owner: platform-team owner: platform-team
required: true required: true
- id: l5-drift - id: runtime-drift
path: packages/casan-harness/level5/drift-policy.yaml path: packages/casan-harness/config/drift-policy.yaml
owner: quality-team owner: quality-team
required: true required: true
@@ -27,20 +27,20 @@
"sha256": "3b8b7d1b71dd654bf600c5366b012daf7dea6415e71b50688872526bfac5af33" "sha256": "3b8b7d1b71dd654bf600c5366b012daf7dea6415e71b50688872526bfac5af33"
}, },
{ {
"bytes": 2392, "bytes": 2395,
"path": "packages/casan-harness/level5/tool-registry.yaml", "path": "packages/casan-harness/config/tool-registry.yaml",
"sha256": "41be3e6a813e731213642f546184ec9e37601c93cd5e2eb9ee4833ad2d6c4634" "sha256": "38221f5fb802368d7f6b6420529a529a7b425c1b63d21420c0264577275fea56"
}, },
{ {
"bytes": 722, "bytes": 714,
"path": "packages/casan-harness/level5/model-fallback.yaml", "path": "packages/casan-harness/config/model-fallback.yaml",
"sha256": "731f98e9ca8182af9183de3711c0376c2e5daf38c769f27a5556d438aa53e2d7" "sha256": "b010426af5fc3a3e8a7be54ea954713e97750ccfbe78006a66afd569aa2adc06"
}, },
{ {
"bytes": 389, "bytes": 392,
"path": "packages/casan-harness/level5/drift-policy.yaml", "path": "packages/casan-harness/config/drift-policy.yaml",
"sha256": "3d5ff7e5f06338d1a1673758ea33907ba0d5b19907ec8034d0e25f33ca248195" "sha256": "59b5fe3ad38cef7a299f59788265d67a01ca4be0d1cea19a920378afb405490c"
} }
], ],
"generated_at": "2026-07-08T09:20:55Z" "generated_at": "2026-07-24T04:05:50Z"
} }
@@ -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К#’ ^Ô«â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
+1 -1
View File
@@ -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/scripts ./packages/casan-harness/scripts
COPY packages/casan-harness/config ./packages/casan-harness/config COPY packages/casan-harness/config ./packages/casan-harness/config
COPY packages/casan-harness/security ./packages/casan-harness/security 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 COPY packages/casan-devkit ./packages/casan-devkit
# Read-only, build-time workspace snapshots. Goal orchestration resolves only roots # Read-only, build-time workspace snapshots. Goal orchestration resolves only roots
+24
View File
@@ -56,6 +56,10 @@ Gói global `devkit` được dùng vì nó chứa lệnh adoption `casan init`.
- macOS/Linux: `~/.casan` - macOS/Linux: `~/.casan`
- Windows: `%LOCALAPPDATA%\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 ### 2. Adopt vào repository hiện hữu
```bash ```bash
@@ -239,6 +243,9 @@ casan verify-harness
casan level show 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à - `doctor`: kiểm tra config, bootstrap, hook schema, adapter smoke test, VSIX và
cảnh báo trust. cảnh báo trust.
- `verify-harness`: tính lại live hash và so với project pin; drift trả exit - `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 `--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. `@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: Khi nâng cấp CASAN:
1. Chạy lại installer từ release đã duyệt. 1. Chạy lại installer từ release đã duyệt.
+17 -9
View File
@@ -28,7 +28,6 @@ if [[ -z "${HARNESS:-}" || ! -d "$HARNESS" ]]; then
exit 1 exit 1
fi fi
BASH_DIR="$HARNESS/scripts/bash" BASH_DIR="$HARNESS/scripts/bash"
TESTS_DIR="$HARNESS/tests"
CASAN_APP_ROOT="${CASAN_APP_ROOT:-$(cd "$HARNESS/../.." && pwd)}" CASAN_APP_ROOT="${CASAN_APP_ROOT:-$(cd "$HARNESS/../.." && pwd)}"
VERSION_FILE="$_self/../VERSION" VERSION_FILE="$_self/../VERSION"
[[ -f "$VERSION_FILE" ]] || VERSION_FILE="$HARNESS/../../VERSION" [[ -f "$VERSION_FILE" ]] || VERSION_FILE="$HARNESS/../../VERSION"
@@ -43,12 +42,12 @@ Usage: casan <command> [args]
Commands: Commands:
init [--client ...] [--project id] Adopt CASAN and select Claude/Codex/VS Code integrations 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 doctor [--client ...] Verify configured hooks, pin, adapters, and VS Code route
level <show|set 1..4> Show / change the project's packaging level level <show|set 1..4> Show / change the project's packaging level
verify-harness Verify the resolved harness matches the project pin verify-harness Verify the resolved harness matches the project pin
run <in> <out> [action] [-- cmd...] Run a step through the harness (H4→H5→H6→exec→H4-out) run <in> <out> [action] [-- cmd...] Run a step through the harness (H4→H5→H6→exec→H4-out)
gate Run the full CI harness gate (all suites) gate Run production checks from the project manifest
test Run the core harness test suite (run-casan4)
verify Verify audit chain + tool audit + policy bundle verify Verify audit chain + tool audit + policy bundle
reuse Verify multi-project harness reuse (registry) reuse Verify multi-project harness reuse (registry)
project validate [--manifest path] Validate a project manifest and quality profile 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 verify Verify the adopted prompt-enforcement contract
prompt trace <trace-id> Verify that a prompt trace is H1-H7 certified prompt trace <trace-id> Verify that a prompt trace is H1-H7 certified
pipeline [--manifest path] Run the manifest-driven SRS→test pipeline 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 version Print version
help This help help This help
@@ -72,6 +71,9 @@ case "$cmd" in
init) init)
[[ -f "$DEVKIT_ROOT/casan-init.py" ]] || { echo "casan: init requires the casan-devkit package" >&2; exit 1; } [[ -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 "$@" ;; 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) verify-harness)
[[ -f "$DEVKIT_ROOT/casan-init.py" ]] || { echo "casan: verify-harness requires the casan-devkit package" >&2; exit 1; } [[ -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 "$@" ;; exec python3 "$DEVKIT_ROOT/casan-init.py" verify "$@" ;;
@@ -89,11 +91,12 @@ case "$cmd" in
esac ;; esac ;;
run) exec bash "$BASH_DIR/casan-harness.sh" "$@" ;; run) exec bash "$BASH_DIR/casan-harness.sh" "$@" ;;
gate) gate)
if [[ -n "${CASAN_PROJECT_MANIFEST:-}${CASAN_PROJECT_ID:-}" ]]; then if [[ -z "${CASAN_PROJECT_MANIFEST:-}${CASAN_PROJECT_ID:-}" \
exec bash "$BASH_DIR/project-gate.sh" "$@" && ! -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 fi
exec bash "$BASH_DIR/ci-harness-gate.sh" "$@" ;; exec bash "$BASH_DIR/project-gate.sh" "$@" ;;
test) exec bash "$TESTS_DIR/run-casan4-harness-tests.sh" "$@" ;;
verify) verify)
rc=0 rc=0
bash "$BASH_DIR/verify-audit-chain.sh" "$@" || rc=$? bash "$BASH_DIR/verify-audit-chain.sh" "$@" || rc=$?
@@ -125,7 +128,12 @@ case "$cmd" in
RUNNER="$CASAN_APP_ROOT/scripts/run-casan-pipeline.mjs" RUNNER="$CASAN_APP_ROOT/scripts/run-casan-pipeline.mjs"
[[ -f "$RUNNER" ]] || { echo "casan: pipeline runner is not installed" >&2; exit 1; } [[ -f "$RUNNER" ]] || { echo "casan: pipeline runner is not installed" >&2; exit 1; }
exec node "$RUNNER" "$@" ;; 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 ;; version|-v|--version) version ;;
help|-h|--help) usage ;; help|-h|--help) usage ;;
*) echo "casan: unknown command '$cmd'" >&2; usage >&2; exit 64 ;; *) echo "casan: unknown command '$cmd'" >&2; usage >&2; exit 64 ;;
+1 -1
View File
@@ -62,7 +62,7 @@ services:
- ./apps/okr:/app/apps/okr - ./apps/okr:/app/apps/okr
- ./apps/service-desk:/app/apps/service-desk - ./apps/service-desk:/app/apps/service-desk
- ./apps/projects:/app/apps/projects - ./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: expose:
- "3010" - "3010"
networks: networks:
+29 -1
View File
@@ -42,6 +42,11 @@ Installer sẽ:
- Tạo launcher `casan` (tự định vị install của chính nó) và đưa lên PATH - 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`). (`~/.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 > Windows cần **Git for Windows (Git Bash)** để *chạy* harness (xem
> [CASAN_AGENTIC_CLIENTS_WINDOWS.md](CASAN_AGENTIC_CLIENTS_WINDOWS.md)) — không > [CASAN_AGENTIC_CLIENTS_WINDOWS.md](CASAN_AGENTIC_CLIENTS_WINDOWS.md)) — không
> cần WSL2. Cả hai OS cần `python3`. > 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, 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`. 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 `--mode observe|enforce` mặc định `enforce`; dùng `observe` chỉ cho pilot
telemetry-only. `--integration-mode` nhận telemetry-only. `--integration-mode` nhận
`project_hook|managed_hook|casan_owned`; `--target <dir>` mặc định là thư mục `project_hook|managed_hook|casan_owned`; `--target <dir>` mặc định là thư mục
@@ -154,6 +169,18 @@ casan doctor --client codex
casan doctor --client vscode-copilot 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) ## 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 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 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). - 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 `verify-harness` **luôn tính lại hash từ file thật** (không tin hash cache), nên
+1 -1
View File
@@ -177,7 +177,7 @@ apps/<app-slug>/
└── test/ # test riêng └── 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/<app-slug>/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/<app-slug>/domain`, rồi chạy:
```bash ```bash
packages/casan-harness/scripts/bash/verify-harness-reuse.sh packages/casan-harness/scripts/bash/verify-harness-reuse.sh
+2
View File
@@ -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 `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 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. `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 ## Option A2 — New production project shell
+22 -1
View File
@@ -9,7 +9,7 @@ contents + maturity: [`packaging/levels.json`](../../packaging/levels.json).
| Lvl | Package names | Status | What it is | | 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 | | **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. | | **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. | | **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 Every bundle carries `BUNDLE-MANIFEST.txt` + `SHA256SUMS`; preview bundles also carry
`PREVIEW-INCOMPLETE.txt`. `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 ## Who adopts what
- **Governance-harness-only / BJT initial / CI gate** → `casan-core`. - **Governance-harness-only / BJT initial / CI gate** → `casan-core`.
- **New project adopting CASAN** → `casan-devkit` (install.sh scaffolds domain + CI). - **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 bin/casan # CLI
scripts/package-release.sh # release packager scripts/package-release.sh # release packager
packaging/levels.json # bundle contents + maturity (source of truth) 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 docs/packaging/ # this plan + adoption/CI/domain-pack/gitea/docker guides
``` ```
+16 -22
View File
@@ -11,37 +11,31 @@ cp packages/casan-devkit/templates/gitea-workflow/ci.yml .gitea/workflows/casan-
## 2. What it runs ## 2. What it runs
```yaml ```yaml
- bash packages/casan-harness/scripts/bash/ci-harness-gate.sh # all governance suites - casan verify-harness
- bash packages/casan-harness/scripts/bash/verify-audit-chain.sh - casan gate
- bash packages/casan-harness/scripts/bash/sign-policy-bundle.sh verify
``` ```
## 3. Environment knobs `CASAN_PROJECT_MANIFEST` must point to the project's manifest. Build and test
| Var | Default | Use | commands come from that reviewed manifest; the production package does not contain
|---|---|---| CASAN's own source-hub test suites.
| `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 |
## 4. Runner ## 3. Runner
Uses `runs-on: ci-runner` (a self-hosted Gitea Actions runner). To set one up on your host, 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 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 ## 4. Expected result
`CI_GATE_SUMMARY PASS=<n> FAIL=0 SKIP=<k>`. Any FAIL fails the job (exit 1). The gate is `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 **fail-closed**: missing signatures/policy in enforced mode (`CASAN_PROFILE=prod` or
`CASAN_VERIFY_STRICT=1`) also fail. `CASAN_VERIFY_STRICT=1`) also fail.
## 6. This repo's own CI ## 5. This repo's own CI
The source hub's active pipeline is `.gitea/workflows/ci.yml` (frontend tests → security The source hub's active pipeline still runs internal regression and adversarial suites.
gate → deploy). It already runs the gate from the promoted root layout Those suites validate CASAN itself and are intentionally excluded from customer/runtime
(`packages/casan-harness/...`). Use it as a worked example. artifacts.
## Tips ## 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. - 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`.
+1 -1
View File
@@ -42,7 +42,7 @@ apps/<project>/domain/
export CASAN_PROJECT_MANIFEST=apps/<project>/domain/project.manifest.json export CASAN_PROJECT_MANIFEST=apps/<project>/domain/project.manifest.json
bin/casan gate # runs domain-dependent suites against your pack 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. so `bin/casan reuse` can prove multi-project reuse.
## Reference example ## Reference example
+2 -2
View File
@@ -65,7 +65,7 @@ curl -fsSL -XPOST -H "Authorization: token $GITEA_TOKEN" \
## 5. Recommended cadence ## 5. Recommended cadence
- Tag `vX.Y.Z` on `main` → CI green → build bundles → publish `core` + `devkit` (always), - 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`. `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) ## Automated release (recommended — already wired)
`.gitea/workflows/release.yml` does all of the above automatically on a version tag. You `.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 ```bash
# bump the version everywhere first # bump the version everywhere first
echo 1.0.1 > VERSION # must match the tag 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 commit -am "release v1.0.1"
git tag -a v1.0.1 -m "CASAN v1.0.1" git tag -a v1.0.1 -m "CASAN v1.0.1"
git push origin main --follow-tags git push origin main --follow-tags
+8 -10
View File
@@ -55,22 +55,20 @@ Log "source : $Source"
Log "version : $version" Log "version : $version"
Log "install : $CasanHome" 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" $dest = Join-Path $CasanHome "versions\$version"
if (Test-Path $dest) { Remove-Item -Recurse -Force $dest } 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 New-Item -ItemType Directory -Force -Path (Join-Path $dest 'packages'), (Join-Path $dest 'bin') | Out-Null
function Copy-Tree($rel) { $runtimeCopier = Join-Path $Source 'scripts\copy-runtime.py'
$src = Join-Path $Source $rel if (-not (Test-Path $runtimeCopier)) { Die 'missing production runtime copier: scripts/copy-runtime.py' }
if (-not (Test-Path $src)) { return } & $py.Source $runtimeCopier --source-root $Source --destination-root $dest --component harness --component devkit --clean
$dst = Join-Path $dest $rel if ($LASTEXITCODE -ne 0) { Die 'failed to assemble the production runtime.' }
New-Item -ItemType Directory -Force -Path $dst | Out-Null
Copy-Item -Recurse -Force (Join-Path $src '*') $dst
}
Copy-Tree 'packages\casan-harness'
Copy-Tree 'packages\casan-devkit'
Copy-Item -Force (Join-Path $Source 'bin\casan') (Join-Path $dest 'bin\casan') 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 '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 -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 Get-ChildItem -Path $dest -Recurse -File -Filter '*.pyc' -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue
+14 -19
View File
@@ -75,34 +75,29 @@ log "version : $VERSION"
log "level : $LEVEL (core=harness+CLI, devkit=+adoption tooling)" log "level : $LEVEL (core=harness+CLI, devkit=+adoption tooling)"
log "install : $CASAN_HOME" 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" DEST="$CASAN_HOME/versions/$VERSION"
rm -rf "$DEST" rm -rf "$DEST"
mkdir -p "$DEST/packages" "$DEST/bin" mkdir -p "$DEST/packages" "$DEST/bin"
copy_tree() { # <relpath> RUNTIME_COPIER="$SRC/scripts/copy-runtime.py"
[ -e "$SRC/$1" ] || return 0 [ -f "$RUNTIME_COPIER" ] || die "missing production runtime copier: scripts/copy-runtime.py"
if command -v rsync >/dev/null 2>&1; then python3 "$RUNTIME_COPIER" \
rsync -a --exclude='__pycache__' --exclude='*.pyc' --exclude='.DS_Store' \ --source-root "$SRC" --destination-root "$DEST" --component harness --clean \
--exclude='*.log' "$SRC/$1/" "$DEST/$1/" || die "failed to assemble the production harness runtime."
else
mkdir -p "$DEST/$1" # L1 core: production harness + CLI.
cp -R "$SRC/$1/." "$DEST/$1/"
fi
}
# L1 core: harness + CLI + installer + level manifest.
copy_tree "packages/casan-harness"
cp "$SRC/bin/casan" "$DEST/bin/casan" cp "$SRC/bin/casan" "$DEST/bin/casan"
chmod +x "$DEST/bin/casan" chmod +x "$DEST/bin/casan"
cp "$SRC/VERSION" "$DEST/VERSION" 2>/dev/null || printf '%s\n' "$VERSION" > "$DEST/VERSION" 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"
mkdir -p "$DEST/packaging"; [ -f "$SRC/packaging/levels.json" ] && cp "$SRC/packaging/levels.json" "$DEST/packaging/levels.json" 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 if [ "$LEVEL" = "devkit" ]; then
copy_tree "packages/casan-devkit" python3 "$RUNTIME_COPIER" \
mkdir -p "$DEST/scripts"; [ -f "$SRC/scripts/package-release.sh" ] && cp "$SRC/scripts/package-release.sh" "$DEST/scripts/package-release.sh" --source-root "$SRC" --destination-root "$DEST" --component devkit --clean \
[ -d "$SRC/docs/packaging" ] && { mkdir -p "$DEST/docs"; cp -R "$SRC/docs/packaging" "$DEST/docs/packaging"; } || die "failed to assemble the production DevKit runtime."
fi fi
printf '%s\n' "$LEVEL" > "$DEST/.casan-level" printf '%s\n' "$LEVEL" > "$DEST/.casan-level"
@@ -112,7 +112,7 @@ const CONNECTIONS_CLI = join(HARNESS_BIN, 'model-connections.py');
const ORCHESTRATOR_CLI = join(HARNESS_BIN, 'goal-orchestrator.py'); const ORCHESTRATOR_CLI = join(HARNESS_BIN, 'goal-orchestrator.py');
const PATCH_EXECUTOR_CLI = join(HARNESS_BIN, 'goal-patch-executor.py'); const PATCH_EXECUTOR_CLI = join(HARNESS_BIN, 'goal-patch-executor.py');
const RBAC_CLI = join(HARNESS_BIN, 'rbac-check.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'); const PROJECT_SCAFFOLDER = join(APP_ROOT, 'packages', 'casan-devkit', 'project-scaffold.py');
function parseJson<T>(value: string): T | null { function parseJson<T>(value: string): T | null {
@@ -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 admin = { actor: 'goal-admin', role: 'org-admin', project: 'default', tenant: 'goal-test' };
const root = resolve(import.meta.dirname, '../../../..'); 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', () => { test('goal project selector exposes only active allowlisted registry entries', () => {
const result = new GoalsService().projects(admin); const result = new GoalsService().projects(admin);
+363 -5
View File
@@ -56,6 +56,141 @@ VSCODE_EXTENSION_IDS = {
} }
PROMPT_MARKER_START = "<!-- CASAN_PROMPT_ENFORCEMENT_START -->" PROMPT_MARKER_START = "<!-- CASAN_PROMPT_ENFORCEMENT_START -->"
PROMPT_MARKER_END = "<!-- CASAN_PROMPT_ENFORCEMENT_END -->" PROMPT_MARKER_END = "<!-- CASAN_PROMPT_ENFORCEMENT_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 @contextmanager
@@ -225,6 +360,8 @@ def _load_json_or(path, default):
def _backup_once(path, backups): def _backup_once(path, backups):
if backups is None:
return
bak = path + ".casan-bak" bak = path + ".casan-bak"
if os.path.exists(path) and not os.path.exists(bak): if os.path.exists(path) and not os.path.exists(bak):
with open(path, "r", encoding="utf-8", errors="replace") as fh: 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) hhash, hsource = compute_harness_hash(harness)
created = [] created = []
backups = [] 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): def created_add(p):
created.append(os.path.relpath(p, target)) 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 ── # ── .casan/config.json ──
cfg_dir = os.path.join(target, ".casan") cfg_dir = os.path.join(target, ".casan")
cfg = { cfg = {
@@ -696,6 +845,7 @@ def cmd_init(args):
"target_level_name": lvl_name, "target_level_name": lvl_name,
} }
p = os.path.join(cfg_dir, "config.json") 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) _write(p, json.dumps(cfg, ensure_ascii=False, indent=2) + "\n", backups); created_add(p)
# ── .casan/version.lock (pin) ── # ── .casan/version.lock (pin) ──
@@ -708,6 +858,7 @@ def cmd_init(args):
"recorded_at": now_iso(), "recorded_at": now_iso(),
} }
p = os.path.join(cfg_dir, "version.lock") 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) _write(p, json.dumps(lock, ensure_ascii=False, indent=2) + "\n", backups); created_add(p)
# ── .casan/agentic.env (Plan-20 flags) ── # ── .casan/agentic.env (Plan-20 flags) ──
@@ -720,6 +871,7 @@ def cmd_init(args):
"", "",
] ]
p = os.path.join(cfg_dir, "agentic.env") p = os.path.join(cfg_dir, "agentic.env")
mark_owned_if_absent(p)
_write(p, "\n".join(env_lines), backups); created_add(p) _write(p, "\n".join(env_lines), backups); created_add(p)
# Stable project-local bootstrap. It loads the config above, resolves the # 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): if not os.path.isfile(bootstrap_source):
sys.stderr.write("casan init: missing project hook bootstrap template\n") sys.stderr.write("casan init: missing project hook bootstrap template\n")
return 1 return 1
mark_owned_if_absent(bootstrap_target)
with open(bootstrap_source, "r", encoding="utf-8") as fh: with open(bootstrap_source, "r", encoding="utf-8") as fh:
_write(bootstrap_target, fh.read(), backups) _write(bootstrap_target, fh.read(), backups)
try: try:
@@ -746,6 +899,7 @@ def cmd_init(args):
os.makedirs(os.path.join(specify, "logs"), exist_ok=True) os.makedirs(os.path.join(specify, "logs"), exist_ok=True)
gi = os.path.join(specify, ".gitignore") gi = os.path.join(specify, ".gitignore")
if not os.path.exists(gi): 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) _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. # ── 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") claude_dst = os.path.join(target, ".claude", "settings.json")
if "claude" in clients: if "claude" in clients:
dst = claude_dst dst = claude_dst
mark_owned_if_absent(dst)
remove_json_hooks(dst, "claude_hook.py", backups) remove_json_hooks(dst, "claude_hook.py", backups)
r = merge_json_hooks(dst, os.path.join(ad, "claude-code", "settings.template.json"), r = merge_json_hooks(dst, os.path.join(ad, "claude-code", "settings.template.json"),
"casan-hook.py", backups) "casan-hook.py", backups)
@@ -770,6 +925,7 @@ def cmd_init(args):
codex_hooks_dst = os.path.join(target, ".codex", "hooks.json") codex_hooks_dst = os.path.join(target, ".codex", "hooks.json")
if "codex" in clients: if "codex" in clients:
dsth = codex_hooks_dst dsth = codex_hooks_dst
mark_owned_if_absent(dsth)
remove_json_hooks(dsth, "codex_hook.py", backups) remove_json_hooks(dsth, "codex_hook.py", backups)
r = merge_json_hooks(dsth, os.path.join(ad, "codex", "hooks.template.json"), r = merge_json_hooks(dsth, os.path.join(ad, "codex", "hooks.template.json"),
"casan-hook.py", backups) "casan-hook.py", backups)
@@ -787,6 +943,7 @@ def cmd_init(args):
os.path.join(target, ".codex", "config.toml"), backups) os.path.join(target, ".codex", "config.toml"), backups)
vscode_file = os.path.join(target, ".vscode", "extensions.json") vscode_file = os.path.join(target, ".vscode", "extensions.json")
mark_owned_if_absent(vscode_file)
vscode_merge = merge_vscode_recommendations(vscode_file, clients, backups) vscode_merge = merge_vscode_recommendations(vscode_file, clients, backups)
if vscode_merge not in ("unchanged", "absent"): if vscode_merge not in ("unchanged", "absent"):
created_add(vscode_file) created_add(vscode_file)
@@ -812,24 +969,31 @@ def cmd_init(args):
ci_src = os.path.join(dk, "templates", "gitea-workflow", "ci.yml") ci_src = os.path.join(dk, "templates", "gitea-workflow", "ci.yml")
ci_dst = os.path.join(target, ".gitea", "workflows", "casan-ci.yml") ci_dst = os.path.join(target, ".gitea", "workflows", "casan-ci.yml")
if _copy_if_absent(ci_src, ci_dst, None, target, created): 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") level_extras.append(".gitea/workflows/casan-ci.yml")
dom_src = os.path.join(dk, "templates", "domain-pack") dom_src = os.path.join(dk, "templates", "domain-pack")
dom_dst = os.path.join(target, "apps", project, "domain") dom_dst = os.path.join(target, "apps", project, "domain")
created_before_domain = len(created)
n = _copy_tree_missing(dom_src, dom_dst, target, created) n = _copy_tree_missing(dom_src, dom_dst, target, created)
if n: 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)) level_extras.append("apps/%s/domain (%d files)" % (project, n))
# ── manifest (so uninstall/verify know what init created) ── # ── manifest (so uninstall/verify know what init created) ──
manifest = { manifest = {
"created": created, "created": created,
"backups": [os.path.relpath(b, target) for b in backups], "backups": [os.path.relpath(b, target) for b in backups],
"owned_files": sorted(owned_files),
"project_id": project, "project_id": project,
"at": now_iso(), "at": now_iso(),
} }
p = os.path.join(cfg_dir, "init-manifest.json") p = os.path.join(cfg_dir, "init-manifest.json")
_write(p, json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", backups) _write(p, json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", backups)
print(json.dumps({ result = {
"status": "initialized", "status": "initialized",
"project_id": project, "project_id": project,
"target": target, "target": target,
@@ -846,7 +1010,8 @@ def cmd_init(args):
"level_extras": level_extras, "level_extras": level_extras,
"note": ("harness NOT copied into repo (hybrid model); selected client hooks " "note": ("harness NOT copied into repo (hybrid model); selected client hooks "
"MERGED and unselected CASAN hooks removed; run `casan doctor`"), "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"): if legacy_migration.get("manual_review"):
sys.stderr.write( sys.stderr.write(
"casan init: WARNING — legacy CASAN prose outside managed markers " "casan init: WARNING — legacy CASAN prose outside managed markers "
@@ -885,7 +1050,7 @@ def cmd_verify(args):
"harness_version_now": harness_version(harness), "harness_version_now": harness_version(harness),
"harness_root": harness, "harness_root": harness,
} }
print(json.dumps(result, ensure_ascii=False, indent=2)) _emit_json_or_human(args, result, _render_verify)
if not ok: if not ok:
sys.stderr.write("HARNESS_INTEGRITY_DRIFT — the resolved harness does not match the " sys.stderr.write("HARNESS_INTEGRITY_DRIFT — the resolved harness does not match the "
"project pin. The global harness changed or was tampered.\n") "project pin. The global harness changed or was tampered.\n")
@@ -919,7 +1084,7 @@ def cmd_level(args):
"4 enterprise": "future — not shipped", "4 enterprise": "future — not shipped",
}, },
} }
print(json.dumps(out, ensure_ascii=False, indent=2)) _emit_json_or_human(args, out, _render_level)
return 0 return 0
@@ -1091,10 +1256,180 @@ def cmd_doctor(args):
"with Plan-20 hooks and needs manual review: %s." % "with Plan-20 hooks and needs manual review: %s." %
", ".join(legacy_conflicts)) ", ".join(legacy_conflicts))
checks["status"] = "ready" if ready else "not_ready" 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 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): def main(argv=None):
parser = argparse.ArgumentParser(prog="casan-init", description="CASAN hybrid adoption") parser = argparse.ArgumentParser(prog="casan-init", description="CASAN hybrid adoption")
sub = parser.add_subparsers(dest="cmd") sub = parser.add_subparsers(dest="cmd")
@@ -1122,21 +1457,42 @@ def main(argv=None):
pi.add_argument("--force", action="store_true", pi.add_argument("--force", action="store_true",
help="bypass the source-hub safety guard (adopt CASAN into a CASAN checkout)") help="bypass the source-hub safety guard (adopt CASAN into a CASAN checkout)")
pi.add_argument("--harness", help="override harness root") 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 = 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("--target", help="project root (default: cwd)")
pv.add_argument("--harness", help="override harness root") 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 = sub.add_parser("level", help="show installed + project packaging level")
pl.add_argument("--show", action="store_true", help="(default) show levels") pl.add_argument("--show", action="store_true", help="(default) show levels")
pl.add_argument("--target", help="project root (default: cwd)") pl.add_argument("--target", help="project root (default: cwd)")
pl.add_argument("--harness", help="override harness root") 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 = sub.add_parser("doctor", help="verify selected client integrations end-to-end")
pd.add_argument("--target", help="project root (default: cwd)") pd.add_argument("--target", help="project root (default: cwd)")
pd.add_argument("--client", action="append", pd.add_argument("--client", action="append",
help="client(s) to check; default: clients enabled in config") help="client(s) to check; default: clients enabled in config")
pd.add_argument("--harness", help="override harness root") 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) args = parser.parse_args(argv)
if args.cmd == "init": if args.cmd == "init":
@@ -1153,6 +1509,8 @@ def main(argv=None):
return cmd_level(args) return cmd_level(args)
if args.cmd == "doctor": if args.cmd == "doctor":
return cmd_doctor(args) return cmd_doctor(args)
if args.cmd == "uninstall":
return cmd_uninstall(args)
parser.print_help() parser.print_help()
return 64 return 64
+12 -5
View File
@@ -40,10 +40,17 @@ fi
echo "==> installing CASAN core into $TARGET (project=$PROJECT domain=$DOMAIN)" echo "==> installing CASAN core into $TARGET (project=$PROJECT domain=$DOMAIN)"
mkdir -p "$TARGET/packages" "$TARGET/bin" "$TARGET/apps/$PROJECT/domain" mkdir -p "$TARGET/packages" "$TARGET/bin" "$TARGET/apps/$PROJECT/domain"
# 1) core harness + CLI # 1) production core harness + CLI. Migrate the old Level-5 registry before
RSYNC_EXCLUDES=(--exclude='__pycache__' --exclude='*.pyc' --exclude='.DS_Store' --exclude='*.log') # cleaning source-only/test content from an existing vendored installation.
[[ -f "$TARGET/packages/casan-harness/level5/project-registry.json" ]] && RSYNC_EXCLUDES+=(--exclude='level5/project-registry.json') OLD_REG="$TARGET/packages/casan-harness/level5/project-registry.json"
rsync -a "${RSYNC_EXCLUDES[@]}" "$SRC/packages/casan-harness/" "$TARGET/packages/casan-harness/" 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" cp "$SRC/bin/casan" "$TARGET/bin/casan"; chmod +x "$TARGET/bin/casan"
[[ -f "$SRC/VERSION" ]] && cp "$SRC/VERSION" "$TARGET/VERSION" [[ -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 PY
# 5) register in project-registry.json (append if absent) # 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' python3 - "$REG" "$PROJECT" "$DOMAIN" <<'PY'
import json, os, sys import json, os, sys
reg, pid, dom = sys.argv[1], sys.argv[2], sys.argv[3] reg, pid, dom = sys.argv[1], sys.argv[2], sys.argv[3]
+35 -3
View File
@@ -25,6 +25,35 @@ class ScaffoldError(RuntimeError):
pass 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: def atomic_write(path: Path, content: bytes, mode: int = 0o644) -> str:
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
if path.is_symlink(): 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]: def install_harness(target: Path) -> tuple[int, int]:
created = unchanged = 0 created = unchanged = 0
harness_source = SOURCE_ROOT / "packages" / "casan-harness" harness_source = SOURCE_ROOT / "packages" / "casan-harness"
exclusions = runtime_exclusions("harness")
for source in sorted(harness_source.rglob("*")): 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 continue
relative = source.relative_to(harness_source) relative = source.relative_to(harness_source)
if excluded(relative, exclusions):
continue
destination = target / "packages" / "casan-harness" / relative destination = target / "packages" / "casan-harness" / relative
# The target registry is adoption state, not immutable harness code. Preserve it # The target registry is adoption state, not immutable harness code. Preserve it
# after the first install so repeated scaffolds and upgrades remain idempotent. # 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 unchanged += 1
continue continue
mode = stat.S_IMODE(source.stat().st_mode) 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: 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")) 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") 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 # A shipped harness may carry source-hub examples. Never register dangling
@@ -1,7 +1,6 @@
# CASAN harness gate — Gitea Actions workflow (adoption template). # CASAN production gate — Gitea Actions workflow (adoption template).
# Copy to .gitea/workflows/casan-ci.yml in your project. Assumes the CASAN core harness # The runner installs the approved CASAN release globally and the project provides
# lives at packages/casan-harness/ (via casan-devkit install.sh) and domain data at # CASAN_PROJECT_MANIFEST. Internal CASAN product tests are intentionally not shipped.
# apps/<project>/domain/. Runs the full governance gate on every push/PR.
name: CASAN Gate name: CASAN Gate
on: on:
@@ -15,11 +14,7 @@ jobs:
runs-on: ci-runner runs-on: ci-runner
timeout-minutes: 45 timeout-minutes: 45
env: env:
CASAN_CI_RUN_FRONTEND: "0" # set 1 if your project has a frontend workspace CASAN_PROJECT_MANIFEST: apps/<project>/domain/project.manifest.json
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/<project>/domain # uncomment + set for your project
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -30,10 +25,8 @@ jobs:
command -v python3 >/dev/null || { apt-get update && apt-get install -y python3; } command -v python3 >/dev/null || { apt-get update && apt-get install -y python3; }
python3 --version python3 --version
- name: Run CASAN harness gate - name: Verify installed CASAN runtime
run: bash packages/casan-harness/scripts/bash/ci-harness-gate.sh run: casan verify-harness
- name: Verify audit chain + policy bundle - name: Run project governance gate
run: | run: casan gate
bash packages/casan-harness/scripts/bash/verify-audit-chain.sh
bash packages/casan-harness/scripts/bash/sign-policy-bundle.sh verify
@@ -36,6 +36,9 @@ assert 'CASAN_PROJECT_ID: "sample-project"' in workflow
assert "__PROJECT_ID__" not in workflow assert "__PROJECT_ID__" not in workflow
assert (root / "bin/casan-chat").exists() assert (root / "bin/casan-chat").exists()
assert (root / "bin/casan-chat.ps1").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"): for relative in ("docs/casan/CASAN_ADOPTION_WINDOWS.md", "docs/casan/CASAN_PROMPT_ENFORCEMENT.md"):
text = (root / relative).read_text(encoding="utf-8") text = (root / relative).read_text(encoding="utf-8")
assert "sample-project" in text assert "sample-project" in text
@@ -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 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" [[ -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" [[ -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" "$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' \ python3 - "$CASAN_HOME/current/packages/casan-devkit/casan-init.py" <<'PY' \
&& pass "client selector accepts menu numbers, aliases, repeats, and all" \ && 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"] assert m.select_clients(["all"],False)==["claude","codex","vscode-copilot"]
PY 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) =====" echo "===== ② casan init (config only, no harness copy) ====="
PROJ="$WORK/proj/my-app"; mkdir -p "$PROJ"; echo '{"name":"x"}' > "$PROJ/package.json" 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 ) \ ( 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" ( [ -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=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)" [ "$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)" [ "$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 =====" 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 ) \ ( 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" && 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 ""
echo "===== HYBRID INSTALL SUMMARY: PASS=$PASS FAIL=$FAIL =====" echo "===== HYBRID INSTALL SUMMARY: PASS=$PASS FAIL=$FAIL ====="
[[ "$FAIL" -eq 0 ]] || exit 1 [[ "$FAIL" -eq 0 ]] || exit 1
@@ -75,10 +75,13 @@ class ProjectScaffoldTests(unittest.TestCase):
args = options(directory, with_harness=True) args = options(directory, with_harness=True)
first = SCAFFOLD.scaffold(args) first = SCAFFOLD.scaffold(args)
second = 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.assertEqual([item["project_id"] for item in registry["projects"]], ["inventory-app"])
self.assertGreater(first["created"], 100) self.assertGreater(first["created"], 100)
self.assertEqual(second["created"], 0) 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): def test_invalid_identifiers_and_broad_target_fail_closed(self):
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
+3 -3
View File
@@ -8,9 +8,9 @@ Layout:
- `scripts/` — bash + powershell gate logic (H1→H7), path resolver `casan-paths.sh` - `scripts/` — bash + powershell gate logic (H1→H7), path resolver `casan-paths.sh`
- `security/` — filter/policy rules (prompt-filter, pii-rules, output-policy, ...) - `security/` — filter/policy rules (prompt-filter, pii-rules, output-policy, ...)
- `governance/`, `agentops/` — H5/H6 code - `governance/`, `agentops/` — H5/H6 code
- `level5/` — L5 config (drift/kpi/model-fallback/tool-registry YAMLs) - `config/` — runtime policy, registry, model fallback, and tool metadata
- `templates/`, `config/` — spec/plan templates, loop-policy - `templates/` — spec/plan templates
- `tests/` — reproducible harness test suites + integrity manifest - `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 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 the app under `CASAN_STATE_ROOT`. Domain data (golden-runs, corpus, input) lives in
@@ -1,5 +1,5 @@
version: 1.0 version: 1.0
description: CASAN Level 5 drift detection policy description: CASAN production drift detection policy
golden_dir: apps/okr/domain/golden-runs golden_dir: apps/okr/domain/golden-runs
evidence_dir: docs/output/casan/level5-evidence evidence_dir: docs/output/casan/level5-evidence
@@ -5,11 +5,11 @@
"H4-security", "H4-security",
"H5-governance", "H5-governance",
"H6-agentops", "H6-agentops",
"L5-drift-detection", "drift-detection",
"L5-model-fallback", "model-fallback",
"L5-tool-registry", "tool-registry",
"L5-rollback", "rollback",
"L5-business-feedback" "business-feedback"
], ],
"compatibility": { "compatibility": {
"speckit": ">=1.0.0", "speckit": ">=1.0.0",
@@ -18,7 +18,7 @@
}, },
"entrypoints": { "entrypoints": {
"wrapper": "packages/casan-harness/scripts/bash/casan-harness.sh", "wrapper": "packages/casan-harness/scripts/bash/casan-harness.sh",
"test": "packages/casan-harness/tests/run-casan4-harness-tests.sh", "project_gate": "packages/casan-harness/scripts/bash/project-gate.sh",
"dashboard": "docs/output/casan/agentops-dashboard.html" "agentic_bridge": "packages/casan-harness/scripts/python/agentic_bridge.py"
} }
} }
@@ -1,5 +1,5 @@
version: 1.0 version: 1.0
description: CASAN Level 5 business KPI feedback schema description: CASAN business KPI feedback schema
kpis: kpis:
- id: cycle_time_minutes - id: cycle_time_minutes
@@ -1,5 +1,5 @@
version: 1.0 version: 1.0
description: CASAN Level 5 model/tool fallback policy description: CASAN model/tool fallback policy
routes: routes:
- step_pattern: "review|test|security" - step_pattern: "review|test|security"
@@ -1,5 +1,5 @@
version: 1.0 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: tools:
- id: agent_step - id: agent_step
@@ -41,8 +41,8 @@ _casan_find_app_root() {
return 1 return 1
} }
# Harness (CODE) root: the tree that holds scripts/, security/, templates/, tests/, # Harness (CODE) root: the production tree that holds scripts/, security/,
# config/, level5/*.yaml. Derived from this file's location so it follows the move. # templates/, config/, adapters/, and schemas/. Derived from this file's location.
if [[ -z "${CASAN_HARNESS_ROOT:-}" ]]; then if [[ -z "${CASAN_HARNESS_ROOT:-}" ]]; then
CASAN_HARNESS_ROOT="$(cd "$_casan_paths_self/../.." && pwd)" CASAN_HARNESS_ROOT="$(cd "$_casan_paths_self/../.." && pwd)"
fi fi
@@ -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") 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") SECURITY = os.path.join(BIN, "security-check.sh")
STATE_ROOT = os.environ.get("CASAN_STATE_ROOT") or os.path.join(ROOT, ".specify") 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") APPROVAL_INBOX = os.path.join(BIN, "approval-inbox.py")
_MANIFEST_SPEC = importlib.util.spec_from_file_location( _MANIFEST_SPEC = importlib.util.spec_from_file_location(
"casan_goal_project_manifest", os.path.join(BIN, "project_manifest.py") "casan_goal_project_manifest", os.path.join(BIN, "project_manifest.py")
@@ -77,7 +77,7 @@ def _manifest_for_files(files: list[str]) -> dict:
if selected or project: if selected or project:
return PROJECT_MANIFEST.load(ROOT, selected, 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 = [] candidates = []
for entry in registry.get("projects", []): for entry in registry.get("projects", []):
manifest_path = entry.get("manifest") manifest_path = entry.get("manifest")
@@ -70,7 +70,7 @@ def load(root: str, manifest_path: str | None = None, project_id: str | None = N
if selected: if selected:
path = within(root, selected, "manifest") path = within(root, selected, "manifest")
elif requested: 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) entry = next((item for item in registry.get("projects", []) if item.get("project_id") == requested), None)
if not entry: if not entry:
_fail(f"project is not registered: {requested}") _fail(f"project is not registered: {requested}")
@@ -14,7 +14,7 @@ fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/casan-paths.sh" source "$SCRIPT_DIR/casan-paths.sh"
PROJECT_ROOT="$CASAN_APP_ROOT" 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" LOG_DIR="$CASAN_STATE_ROOT/logs/level5"
AUDIT_DIR="$CASAN_STATE_ROOT/logs/audit" AUDIT_DIR="$CASAN_STATE_ROOT/logs/audit"
mkdir -p "$LOG_DIR" "$AUDIT_DIR" mkdir -p "$LOG_DIR" "$AUDIT_DIR"
@@ -6,8 +6,8 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/casan-paths.sh" source "$SCRIPT_DIR/casan-paths.sh"
PROJECT_ROOT="$CASAN_APP_ROOT" PROJECT_ROOT="$CASAN_APP_ROOT"
REGISTRY="$CASAN_HARNESS_ROOT/level5/project-registry.json" REGISTRY="$CASAN_HARNESS_ROOT/config/project-registry.json"
PACKAGE="$CASAN_HARNESS_ROOT/level5/harness-package.json" PACKAGE="$CASAN_HARNESS_ROOT/config/harness-package.json"
python - "$REGISTRY" "$PACKAGE" <<'PY' python - "$REGISTRY" "$PACKAGE" <<'PY'
import json import json
@@ -1,5 +1,5 @@
#!/usr/bin/env pwsh #!/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: # Usage:
# tool-registry-gate.ps1 <tool-id> [idempotency-key] # tool-registry-gate.ps1 <tool-id> [idempotency-key]
# #
@@ -13,8 +13,9 @@ param(
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent $scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent
$projectRoot = (Resolve-Path (Join-Path $scriptDir "../../..")).Path $harnessRoot = if ($env:CASAN_HARNESS_ROOT) { $env:CASAN_HARNESS_ROOT } else { (Resolve-Path (Join-Path $scriptDir "../..")).Path }
$registry = Join-Path $projectRoot ".specify/level5/tool-registry.yaml" $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" $logDir = Join-Path $projectRoot ".specify/logs/level5"
$txLog = Join-Path $logDir "tool-registry.jsonl" $txLog = Join-Path $logDir "tool-registry.jsonl"
$toolCallLog = Join-Path $projectRoot ".specify/logs/audit/tool-calls.jsonl" $toolCallLog = Join-Path $projectRoot ".specify/logs/audit/tool-calls.jsonl"
@@ -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 Only GATE-relevant trees are hashed (the code that makes security/governance
decisions), never runtime state/logs: 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). stdlib-only, deterministic (sorted paths), text-mode agnostic (hashes raw bytes).
@@ -30,10 +30,9 @@ import sys
ALGO = "sha256" ALGO = "sha256"
HASHED_SUBTREES = ( 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_DIR_NAMES = {"__pycache__", ".git", "node_modules"}
SKIP_SUFFIXES = (".pyc", ".pyo", ".log", ".tmp", ".DS_Store") 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"} SKIP_BASENAMES = {"policy-manifest.json", "policy-manifest.sig", "project-registry.json"}
@@ -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})" [[ "${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 # 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 \ 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" "$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"' 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" assert_contains "$LEVEL5_DIR/16-policy-verify.stdout" "POLICY_SIGNATURE_VALID"
# L5: real provider usage telemetry import path # 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" assert_contains "$LEVEL5_DIR/17-provider-telemetry.stdout" "PROVIDER_TELEMETRY_IMPORTED"
# L5: shared harness package is registered by multiple projects # L5: shared harness package is registered by multiple projects
+12 -16
View File
@@ -7,29 +7,25 @@
"level": 1, "level": 1,
"package_names": ["casan-core", "casan-harness"], "package_names": ["casan-core", "casan-harness"],
"status": "implemented", "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"], "target_users": ["governance-harness-only projects", "BJT initial adoption", "CI gate usage"],
"includes": [ "includes": [
"packages/casan-harness/scripts", "packages/casan-harness",
"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",
"bin/casan", "bin/casan",
"install.sh", "install.sh",
"install.ps1", "install.ps1",
"packaging/levels.json", "packaging/levels.json",
"packaging/runtime-layout.json",
"scripts/copy-runtime.py",
"VERSION" "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", "artifact": "casan-core-v{VERSION}.tar.gz",
"docker_image": "casan-harness:{VERSION}" "docker_image": "casan-harness:{VERSION}"
}, },
@@ -37,7 +33,7 @@
"level": 2, "level": 2,
"package_names": ["casan-devkit", "casan-project-kit"], "package_names": ["casan-devkit", "casan-project-kit"],
"status": "implemented", "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"], "target_users": ["new projects adopting CASAN", "internal teams needing a repeatable setup"],
"extends": "core", "extends": "core",
"includes": [ "includes": [
+57
View File
@@ -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"
]
}
}
}
+1 -1
View File
@@ -63,7 +63,7 @@ function locateManifest(root, { manifestPath, projectId } = {}) {
const requestedProject = projectId || process.env.CASAN_PROJECT_ID; const requestedProject = projectId || process.env.CASAN_PROJECT_ID;
if (requestedProject) { 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 registry = readJson(registryPath, 'project registry');
const entry = registry.projects?.find((item) => item.project_id === requestedProject || item.manifest === requestedProject); const entry = registry.projects?.find((item) => item.project_id === requestedProject || item.manifest === requestedProject);
if (!entry) fail(`project is not registered: ${requestedProject}`); if (!entry) fail(`project is not registered: ${requestedProject}`);
+205
View File
@@ -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())
+13 -1
View File
@@ -16,7 +16,7 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT" cd "$ROOT"
MANIFEST="packaging/levels.json" MANIFEST="packaging/levels.json"
VERSION="$(cat VERSION 2>/dev/null || echo 0.0.0)" VERSION="$(cat VERSION 2>/dev/null || echo 0.0.0)"
DIST="$ROOT/dist" DIST="${CASAN_DIST_DIR:-$ROOT/dist}"
BUNDLE="${1:-}" BUNDLE="${1:-}"
if [[ -z "$BUNDLE" ]]; then if [[ -z "$BUNDLE" ]]; then
@@ -87,6 +87,18 @@ copy_path() { # <relpath>
fi fi
local dest="$PKGDIR/$1" local dest="$PKGDIR/$1"
mkdir -p "$(dirname "$dest")" 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 # copy, dropping caches/artifacts
if [[ -d "$src" ]]; then if [[ -d "$src" ]]; then
rsync -a --exclude='__pycache__' --exclude='*.pyc' --exclude='.DS_Store' "$src/" "$dest/" 2>/dev/null \ rsync -a --exclude='__pycache__' --exclude='*.pyc' --exclude='.DS_Store' "$src/" "$dest/" 2>/dev/null \
+13
View File
@@ -37,6 +37,19 @@ for path in "${required[@]}"; do
} }
done 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) (cd "$ROOT" && shasum -a 256 -c SHA256SUMS >/dev/null)
VERSION="$(tr -d '[:space:]' < "$ROOT/VERSION")" VERSION="$(tr -d '[:space:]' < "$ROOT/VERSION")"
CLI_VERSION="$(cd "$ROOT" && ./bin/casan version)" CLI_VERSION="$(cd "$ROOT" && ./bin/casan version)"