Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06c404084d | ||
|
|
581000bb56 | ||
|
|
5745519126 | ||
|
|
8b477f3800 | ||
|
|
7f01cf980a | ||
|
|
67d482ae7f | ||
|
|
1d6384d23c | ||
|
|
a7b1c604d5 | ||
|
|
cf568c66f4 | ||
|
|
6983fb108b | ||
|
|
9c9efb1043 | ||
|
|
eef5a29ead | ||
|
|
d64e955b1c |
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "casan",
|
||||||
|
"owner": {
|
||||||
|
"name": "CASAN Team"
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"description": "Native operator facade for the CASAN governed AI coding assurance runtime"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "casan",
|
||||||
|
"source": "./",
|
||||||
|
"description": "Adopt, diagnose, verify, and operate CASAN from Claude Code without bypassing project-local hook trust or runtime integrity controls.",
|
||||||
|
"version": "1.0.7",
|
||||||
|
"author": {
|
||||||
|
"name": "CASAN Team"
|
||||||
|
},
|
||||||
|
"license": "Proprietary",
|
||||||
|
"keywords": [
|
||||||
|
"agentic-coding",
|
||||||
|
"assurance",
|
||||||
|
"governance",
|
||||||
|
"security",
|
||||||
|
"audit"
|
||||||
|
],
|
||||||
|
"category": "workflow",
|
||||||
|
"tags": [
|
||||||
|
"agentic-coding",
|
||||||
|
"assurance",
|
||||||
|
"governance",
|
||||||
|
"security",
|
||||||
|
"audit"
|
||||||
|
],
|
||||||
|
"strict": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "casan",
|
||||||
|
"version": "1.0.7",
|
||||||
|
"description": "Native Claude Code operator facade for the CASAN governed AI coding assurance runtime.",
|
||||||
|
"author": {
|
||||||
|
"name": "CASAN Team"
|
||||||
|
},
|
||||||
|
"license": "Proprietary",
|
||||||
|
"keywords": [
|
||||||
|
"claude-code",
|
||||||
|
"agentic-coding",
|
||||||
|
"assurance",
|
||||||
|
"governance",
|
||||||
|
"security",
|
||||||
|
"audit"
|
||||||
|
],
|
||||||
|
"skills": [
|
||||||
|
"./skills/"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "casan",
|
||||||
|
"version": "1.0.7",
|
||||||
|
"description": "Native Codex operator facade for the CASAN governed AI coding assurance runtime.",
|
||||||
|
"author": {
|
||||||
|
"name": "CASAN Team"
|
||||||
|
},
|
||||||
|
"license": "Proprietary",
|
||||||
|
"keywords": [
|
||||||
|
"agentic-coding",
|
||||||
|
"assurance",
|
||||||
|
"governance",
|
||||||
|
"security",
|
||||||
|
"audit"
|
||||||
|
],
|
||||||
|
"skills": "./skills/",
|
||||||
|
"interface": {
|
||||||
|
"displayName": "CASAN",
|
||||||
|
"shortDescription": "Governed AI coding assurance",
|
||||||
|
"longDescription": "Adopt, diagnose, verify, and operate the CASAN assurance runtime from Codex while preserving project-local hook trust and the versioned shared runtime.",
|
||||||
|
"developerName": "CASAN Team",
|
||||||
|
"category": "Developer Tools",
|
||||||
|
"capabilities": [
|
||||||
|
"Project adoption",
|
||||||
|
"Assurance verification",
|
||||||
|
"Governed execution"
|
||||||
|
],
|
||||||
|
"defaultPrompt": "Use $casan to adopt and verify CASAN in this project."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,6 +93,18 @@ jobs:
|
|||||||
- name: Run CASAN4 harness tests (35 tests)
|
- name: Run CASAN4 harness tests (35 tests)
|
||||||
run: bash packages/casan-harness/tests/run-casan4-harness-tests.sh
|
run: bash packages/casan-harness/tests/run-casan4-harness-tests.sh
|
||||||
|
|
||||||
|
- name: Run Core Local Assurance Viewer contracts
|
||||||
|
run: python3 packages/casan-harness/tests/local-viewer-tests.py
|
||||||
|
|
||||||
|
- name: Prove visual reports from the clean Core release artifact
|
||||||
|
run: bash packages/casan-harness/tests/core-local-viewer-artifact-tests.sh
|
||||||
|
|
||||||
|
- name: Prove managed and vendored Core adoption
|
||||||
|
run: bash packages/casan-devkit/tests/hybrid-install-tests.sh
|
||||||
|
|
||||||
|
- name: Validate native Codex and Claude plugin facades
|
||||||
|
run: python3 packages/casan-devkit/tests/native-plugin-tests.py
|
||||||
|
|
||||||
- name: Run adversarial harness tests (44 tests)
|
- name: Run adversarial harness tests (44 tests)
|
||||||
run: bash packages/casan-harness/tests/adversarial-harness-tests.sh
|
run: bash packages/casan-harness/tests/adversarial-harness-tests.sh
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ jobs:
|
|||||||
node-version: "20"
|
node-version: "20"
|
||||||
cache: npm
|
cache: npm
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
|
- name: Validate native Codex and Claude plugin facades
|
||||||
|
run: python3 packages/casan-devkit/tests/native-plugin-tests.py
|
||||||
- name: Run reusable governance suites
|
- name: Run reusable governance suites
|
||||||
env:
|
env:
|
||||||
CASAN_CI_RUN_PROJECT: "0"
|
CASAN_CI_RUN_PROJECT: "0"
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ o6.txt
|
|||||||
o7.txt
|
o7.txt
|
||||||
t6.txt
|
t6.txt
|
||||||
t7.txt
|
t7.txt
|
||||||
|
tmp/design-qa/
|
||||||
|
|
||||||
# TS build cache
|
# TS build cache
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ COPY packages/casan-control-panel/frontend/package.json ./packages/casan-control
|
|||||||
COPY --from=builder /app/node_modules ./node_modules
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
COPY --from=builder /app/packages/casan-control-panel/backend/dist ./packages/casan-control-panel/backend/dist
|
COPY --from=builder /app/packages/casan-control-panel/backend/dist ./packages/casan-control-panel/backend/dist
|
||||||
COPY packages/casan-harness/scripts ./packages/casan-harness/scripts
|
COPY packages/casan-harness/scripts ./packages/casan-harness/scripts
|
||||||
|
COPY packages/casan-harness/kernel ./packages/casan-harness/kernel
|
||||||
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/config/project-registry.json ./packages/casan-harness/config/project-registry.json
|
COPY packages/casan-harness/config/project-registry.json ./packages/casan-harness/config/project-registry.json
|
||||||
@@ -46,7 +47,6 @@ WORKDIR /app/packages/casan-control-panel/backend
|
|||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV CASAN_PROFILE=prod
|
ENV CASAN_PROFILE=prod
|
||||||
ENV CASAN_CP_STRICT=1
|
ENV CASAN_CP_STRICT=1
|
||||||
ENV CASAN_CP_TRUST_AUTH_PROXY=1
|
|
||||||
ENV CP_BIND=0.0.0.0
|
ENV CP_BIND=0.0.0.0
|
||||||
ENV CP_PORT=3010
|
ENV CP_PORT=3010
|
||||||
|
|
||||||
|
|||||||
@@ -18,11 +18,17 @@ Mô hình production mặc định:
|
|||||||
suite, test scripts, internal CI runners, `level5`, dashboard lab, source docs
|
suite, test scripts, internal CI runners, `level5`, dashboard lab, source docs
|
||||||
hay release tooling.
|
hay release tooling.
|
||||||
|
|
||||||
|
Ngay sau init, CASAN ghi hai projection do CASAN sở hữu:
|
||||||
|
`.casan/discovery.json` và `.casan/readiness.json`. Readiness tách ba chiều:
|
||||||
|
**Core**, **Domain Pipeline** và **Provider Telemetry**. Core có thể sẵn sàng
|
||||||
|
cho prompt/report dù hai chiều tùy chọn còn `not_configured` hoặc
|
||||||
|
`optional_unavailable`.
|
||||||
|
|
||||||
## Trạng thái sản phẩm
|
## Trạng thái sản phẩm
|
||||||
|
|
||||||
| Thành phần | Trạng thái | Phạm vi |
|
| Thành phần | Trạng thái | Phạm vi |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Core | Implemented | H1–H7 harness, hooks, policy gates, audit, evidence và CLI |
|
| Core | Implemented | H1–H7 harness, hooks, policy gates, audit, evidence, CLI và Local Assurance Viewer |
|
||||||
| DevKit | Implemented | Core + adoption tooling, domain-pack và CI template |
|
| DevKit | Implemented | Core + adoption tooling, domain-pack và CI template |
|
||||||
| Control Plane | Preview | Live H1–H7, H6, run history và evidence export; deploy riêng |
|
| Control Plane | Preview | Live H1–H7, H6, run history và evidence export; deploy riêng |
|
||||||
| Enterprise | Chưa phát hành | OIDC/KMS/WORM/HA/DR/SLA; CLI chủ động từ chối |
|
| Enterprise | Chưa phát hành | OIDC/KMS/WORM/HA/DR/SLA; CLI chủ động từ chối |
|
||||||
@@ -48,23 +54,30 @@ capability được đóng gói; runtime mô tả vị trí; maturity mô tả m
|
|||||||
|
|
||||||
## Golden path: prompt → live assurance
|
## Golden path: prompt → live assurance
|
||||||
|
|
||||||
Core không export HTML trên hot path. Sau mỗi prompt, hook tự ghi trace/H6 và
|
Core không export HTML và không giữ web server trên hot path. Sau mỗi prompt,
|
||||||
trả assurance receipt. Nếu project đã enroll Control Plane, receipt có deep link
|
hook chỉ ghi trace/H6 và trả assurance receipt. Khi cần xem, `casan view` khởi
|
||||||
đến đúng run; nếu offline, dùng `casan report latest`.
|
động/reuse **Local Assurance Viewer** read-only trên loopback và mở đúng run.
|
||||||
|
Viewer, H1–H7, H6, history và export đều nằm trong Core, hoạt động offline,
|
||||||
|
không cần Node/npm hoặc Platform.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Platform preview: một lệnh, tự trỏ Control Plane vào project hiện tại
|
|
||||||
casan dashboard start
|
|
||||||
|
|
||||||
# Sau một prompt
|
# Sau một prompt
|
||||||
casan report latest
|
casan report latest
|
||||||
casan view # mở run mới nhất
|
casan readiness --refresh # Core / Domain / Provider, không chạy pipeline
|
||||||
casan report export --format html # snapshot chỉ tạo khi được yêu cầu
|
casan view # mở run mới nhất trong Core viewer
|
||||||
|
casan report export --format html # run dossier, chỉ tạo khi được yêu cầu
|
||||||
|
casan report export --h6 --format html # H6 dossier on-demand
|
||||||
|
|
||||||
|
# Lifecycle viewer cục bộ
|
||||||
|
casan dashboard status
|
||||||
|
casan dashboard stop
|
||||||
```
|
```
|
||||||
|
|
||||||
`casan dashboard start` là convenience launcher cho local demo/evaluation.
|
Không cần chạy `export` sau mỗi prompt. Evidence là source of truth; HTML/JSON
|
||||||
Production triển khai Control Plane như service dùng chung và enroll project
|
chỉ là projection on-demand. `casan dashboard start` trong Core mở viewer
|
||||||
bằng `casan init --dashboard-url https://casan.example`.
|
single-project. Khi bundle Platform hiện diện, cùng lệnh đó quản lý Control
|
||||||
|
Plane. Production triển khai Platform như service dùng chung chỉ khi cần
|
||||||
|
multi-project, RBAC tập trung, approvals và fleet operations.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
@@ -74,6 +87,33 @@ bằng `casan init --dashboard-url https://casan.example`.
|
|||||||
- Windows: PowerShell 5.1+, Python 3 và Git for Windows/Git Bash.
|
- Windows: PowerShell 5.1+, Python 3 và Git for Windows/Git Bash.
|
||||||
- Client tương ứng nếu cần: Claude Code, Codex hoặc VS Code.
|
- Client tương ứng nếu cần: Claude Code, Codex hoặc VS Code.
|
||||||
|
|
||||||
|
### Native plugin façade — tùy chọn
|
||||||
|
|
||||||
|
Repository root đồng thời là marketplace source cho Codex và Claude Code. Plugin
|
||||||
|
chỉ cung cấp skill `$casan` để agent biết cách adopt, diagnose và verify CASAN;
|
||||||
|
nó **không** tự bật hook, không tự cài runtime và không thay thế bước trust của
|
||||||
|
client.
|
||||||
|
|
||||||
|
Từ một checkout đã được tổ chức phê duyệt:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Codex
|
||||||
|
codex plugin marketplace add /absolute/path/to/CASAN
|
||||||
|
codex plugin add casan@casan
|
||||||
|
```
|
||||||
|
|
||||||
|
Trong Claude Code:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/plugin marketplace add /absolute/path/to/CASAN
|
||||||
|
/plugin install casan@casan
|
||||||
|
```
|
||||||
|
|
||||||
|
Sau khi cài plugin, mở session mới và gọi `$casan`. Runtime production vẫn được
|
||||||
|
cài một lần bằng `install.sh`/`install.ps1`, sau đó mỗi repository phải chạy
|
||||||
|
`casan init`. Không cài chồng native plugin và một bản skill copy thủ công vào
|
||||||
|
cùng client.
|
||||||
|
|
||||||
### 1. Cài DevKit một lần trên máy
|
### 1. Cài DevKit một lần trên máy
|
||||||
|
|
||||||
Từ checkout hoặc release bundle đã được duyệt:
|
Từ checkout hoặc release bundle đã được duyệt:
|
||||||
@@ -148,6 +188,7 @@ Project đã init không bị hỏi lại runtime: CASAN giữ nguyên mode hi
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
casan doctor
|
casan doctor
|
||||||
|
casan readiness --refresh
|
||||||
casan verify-harness
|
casan verify-harness
|
||||||
casan edition show
|
casan edition show
|
||||||
```
|
```
|
||||||
@@ -155,6 +196,11 @@ casan edition show
|
|||||||
Với Codex, mở `/hooks`, review và trust đúng project hook sau lần init hoặc khi
|
Với Codex, mở `/hooks`, review và trust đúng project hook sau lần init hoặc khi
|
||||||
bootstrap hash thay đổi.
|
bootstrap hash thay đổi.
|
||||||
|
|
||||||
|
`casan readiness` là product status dành cho người vận hành và dashboard;
|
||||||
|
`casan doctor` là diagnostic sâu cho integrity, hook, smoke test và trust.
|
||||||
|
Không dùng trạng thái thiếu Domain Pack hoặc thiếu token/cost provider để hạ
|
||||||
|
Core thành failed.
|
||||||
|
|
||||||
Codex hooks gọi bootstrap tương đối từ project root và không phụ thuộc vào
|
Codex hooks gọi bootstrap tương đối từ project root và không phụ thuộc vào
|
||||||
`git rev-parse`, nên ownership hoặc cấu hình Git không thể làm hỏng lifecycle
|
`git rev-parse`, nên ownership hoặc cấu hình Git không thể làm hỏng lifecycle
|
||||||
hook. Git vẫn được khuyến nghị mạnh cho source provenance, review diff và
|
hook. Git vẫn được khuyến nghị mạnh cho source provenance, review diff và
|
||||||
@@ -377,6 +423,9 @@ installation hoặc project runtime.
|
|||||||
│ ├── version.lock
|
│ ├── version.lock
|
||||||
│ ├── casan-hook.py
|
│ ├── casan-hook.py
|
||||||
│ ├── agentic.env
|
│ ├── agentic.env
|
||||||
|
│ ├── discovery.json
|
||||||
|
│ ├── readiness.json
|
||||||
|
│ ├── domain.json # chỉ khi chọn manifest bằng casan domain configure
|
||||||
│ ├── init-manifest.json
|
│ ├── init-manifest.json
|
||||||
│ └── runtime/casan-core/ # chỉ khi --runtime vendored
|
│ └── runtime/casan-core/ # chỉ khi --runtime vendored
|
||||||
├── .specify/
|
├── .specify/
|
||||||
@@ -397,6 +446,9 @@ installation hoặc project runtime.
|
|||||||
| `.casan/version.lock` | Pin version và SHA-256 của Core runtime đã resolve |
|
| `.casan/version.lock` | Pin version và SHA-256 của Core runtime đã resolve |
|
||||||
| `.casan/casan-hook.py` | Stdlib bootstrap, verify pin rồi dispatch adapter |
|
| `.casan/casan-hook.py` | Stdlib bootstrap, verify pin rồi dispatch adapter |
|
||||||
| `.casan/agentic.env` | Compatibility/reference flags; runtime đọc `config.json` |
|
| `.casan/agentic.env` | Compatibility/reference flags; runtime đọc `config.json` |
|
||||||
|
| `.casan/discovery.json` | Inventory bounded các marker/source/requirements/Domain Pack candidate; không sửa source |
|
||||||
|
| `.casan/readiness.json` | Contract Core / Domain Pipeline / Provider Telemetry dùng chung cho CLI và viewer |
|
||||||
|
| `.casan/domain.json` | Reference CASAN-owned tới manifest do project sở hữu; chỉ tạo khi `casan domain configure` |
|
||||||
| `.casan/init-manifest.json` | Danh sách file CASAN quản lý, checksum và backup |
|
| `.casan/init-manifest.json` | Danh sách file CASAN quản lý, checksum và backup |
|
||||||
| `.casan/runtime/casan-core/` | Core production-only; chỉ có ở Vendored |
|
| `.casan/runtime/casan-core/` | Core production-only; chỉ có ở Vendored |
|
||||||
| `.specify/logs`, `.specify/state` | Trace, audit và state runtime; không commit |
|
| `.specify/logs`, `.specify/state` | Trace, audit và state runtime; không commit |
|
||||||
@@ -586,6 +638,8 @@ Dùng explicit route `@casan <prompt>` và kiểm tra
|
|||||||
|
|
||||||
| Path | Trách nhiệm |
|
| Path | Trách nhiệm |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| `.codex-plugin/`, `.claude-plugin/` | Native marketplace manifests; không tự bật enforcement |
|
||||||
|
| `skills/casan/` | Operator skill façade dùng chung cho Codex và Claude Code |
|
||||||
| `bin/casan` | CLI entrypoint |
|
| `bin/casan` | CLI entrypoint |
|
||||||
| `install.sh`, `install.ps1` | Global installers |
|
| `install.sh`, `install.ps1` | Global installers |
|
||||||
| `packages/casan-harness/` | Runtime controls, adapters, policies, evidence và source tests |
|
| `packages/casan-harness/` | Runtime controls, adapters, policies, evidence và source tests |
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ if [[ -z "${HARNESS:-}" || ! -d "$HARNESS" ]]; then
|
|||||||
fi
|
fi
|
||||||
BASH_DIR="$HARNESS/scripts/bash"
|
BASH_DIR="$HARNESS/scripts/bash"
|
||||||
CASAN_APP_ROOT="${CASAN_APP_ROOT:-${PROJECT_ROOT:-$(cd "$HARNESS/../.." && pwd)}}"
|
CASAN_APP_ROOT="${CASAN_APP_ROOT:-${PROJECT_ROOT:-$(cd "$HARNESS/../.." && pwd)}}"
|
||||||
|
export CASAN_APP_ROOT
|
||||||
VERSION_FILE="$_self/../VERSION"
|
VERSION_FILE="$_self/../VERSION"
|
||||||
[[ -f "$VERSION_FILE" ]] || VERSION_FILE="$HARNESS/../../VERSION"
|
[[ -f "$VERSION_FILE" ]] || VERSION_FILE="$HARNESS/../../VERSION"
|
||||||
|
|
||||||
@@ -75,6 +76,8 @@ Commands:
|
|||||||
init [--runtime managed|vendored] Adopt/reconfigure CASAN (interactive wizard by default)
|
init [--runtime managed|vendored] Adopt/reconfigure CASAN (interactive wizard by default)
|
||||||
uninstall [--purge] Remove CASAN from this project (preserves user config)
|
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
|
||||||
|
readiness [--refresh] [--json] Assess Core, Domain Pipeline, and Provider Telemetry
|
||||||
|
domain <status|discover|configure> Manage optional project-owned Domain Pack selection
|
||||||
edition <show|set> Show / change the product edition
|
edition <show|set> Show / change the product edition
|
||||||
level <show|set 1..4> Deprecated alias for edition
|
level <show|set 1..4> Deprecated alias for edition
|
||||||
verify-harness Verify the resolved harness matches the project pin
|
verify-harness Verify the resolved harness matches the project pin
|
||||||
@@ -87,10 +90,10 @@ 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
|
||||||
report latest [--json] Show the latest prompt assurance receipt
|
report latest [--json] Show the latest prompt assurance receipt
|
||||||
report export [trace] [--format] Export a trace snapshot on demand
|
report export [trace] [--format] Export a run/H6 dossier on demand
|
||||||
view [trace-id] Open a trace in the enrolled Control Plane
|
view [trace-id] Open the Core Local Assurance Viewer
|
||||||
pipeline [--manifest path] Run the manifest-driven SRS→test pipeline
|
pipeline [--manifest path] Run an optional project-owned SRS→test pipeline
|
||||||
dashboard <start|status|stop|open> Run the local Control Plane (Platform only)
|
dashboard <start|status|stop|open> Run local assurance (Core) or Control Plane
|
||||||
version Print version
|
version Print version
|
||||||
help This help
|
help This help
|
||||||
|
|
||||||
@@ -115,6 +118,10 @@ case "$cmd" in
|
|||||||
doctor)
|
doctor)
|
||||||
[[ -f "$DEVKIT_ROOT/casan-init.py" ]] || { echo "casan: doctor requires the casan-devkit package" >&2; exit 1; }
|
[[ -f "$DEVKIT_ROOT/casan-init.py" ]] || { echo "casan: doctor requires the casan-devkit package" >&2; exit 1; }
|
||||||
exec python3 "$DEVKIT_ROOT/casan-init.py" doctor "$@" ;;
|
exec python3 "$DEVKIT_ROOT/casan-init.py" doctor "$@" ;;
|
||||||
|
readiness)
|
||||||
|
exec python3 "$HARNESS/scripts/python/report_cli.py" --root "$CASAN_APP_ROOT" readiness "$@" ;;
|
||||||
|
domain)
|
||||||
|
exec python3 "$HARNESS/scripts/python/report_cli.py" --root "$CASAN_APP_ROOT" domain "$@" ;;
|
||||||
level)
|
level)
|
||||||
[[ -f "$DEVKIT_ROOT/casan-init.py" ]] || { echo "casan: level requires the casan-devkit package" >&2; exit 1; }
|
[[ -f "$DEVKIT_ROOT/casan-init.py" ]] || { echo "casan: level requires the casan-devkit package" >&2; exit 1; }
|
||||||
sub="${1:-show}"; shift || true
|
sub="${1:-show}"; shift || true
|
||||||
@@ -179,18 +186,28 @@ case "$cmd" in
|
|||||||
exec python3 "$HARNESS/scripts/python/report_cli.py" --root "$CASAN_APP_ROOT" view "$@" ;;
|
exec python3 "$HARNESS/scripts/python/report_cli.py" --root "$CASAN_APP_ROOT" view "$@" ;;
|
||||||
pipeline)
|
pipeline)
|
||||||
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; }
|
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: casan pipeline [project runner arguments]
|
||||||
|
|
||||||
|
Runs a project-owned manifest-driven SRS→test pipeline when
|
||||||
|
scripts/run-casan-pipeline.mjs exists. The pipeline is an optional Domain Pack
|
||||||
|
capability; CASAN Core prompt assurance and visual reports do not depend on it.
|
||||||
|
EOF
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ ! -f "$RUNNER" ]]; then
|
||||||
|
echo "CASAN_PIPELINE_NOT_CONFIGURED — no project-owned Domain Pipeline runner was found." >&2
|
||||||
|
echo "Core remains available: use 'casan view' and 'casan readiness'." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
exec node "$RUNNER" "$@" ;;
|
exec node "$RUNNER" "$@" ;;
|
||||||
dashboard)
|
dashboard)
|
||||||
CONTROL_PLANE_SCRIPT="$HARNESS/../casan-control-panel/scripts/control-plane-local.sh"
|
CONTROL_PLANE_SCRIPT="$HARNESS/../casan-control-panel/scripts/control-plane-local.sh"
|
||||||
if [[ -f "$CONTROL_PLANE_SCRIPT" ]]; then
|
if [[ -f "$CONTROL_PLANE_SCRIPT" ]]; then
|
||||||
exec bash "$CONTROL_PLANE_SCRIPT" "$@"
|
exec bash "$CONTROL_PLANE_SCRIPT" "$@"
|
||||||
fi
|
fi
|
||||||
[[ -f "$BASH_DIR/dashboard-serve.sh" ]] || {
|
exec python3 "$HARNESS/scripts/python/report_cli.py" --root "$CASAN_APP_ROOT" viewer "$@" ;;
|
||||||
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 ;;
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# CASAN report redesign — Design QA
|
||||||
|
|
||||||
|
## Comparison target
|
||||||
|
|
||||||
|
- Source visual truth:
|
||||||
|
- `/Users/thanhnguyen/Downloads/hình ảnh (1).png` — 1844 × 862 px.
|
||||||
|
- `/Users/thanhnguyen/Downloads/hình ảnh (2).png` — 1796 × 795 px.
|
||||||
|
- Rendered implementation:
|
||||||
|
- `tmp/design-qa/h6-default-final.png` — 866 × 2165 px.
|
||||||
|
- Product state: Core Local Assurance Viewer, H6, stale/insufficient telemetry,
|
||||||
|
six successful runs, technical disclosures collapsed.
|
||||||
|
- Browser viewport: 866 × 1043 CSS px, DPR 2. The browser capture is stored at
|
||||||
|
CSS-pixel width; the reference screens are wider desktop compositions, so
|
||||||
|
comparison is by hierarchy, density and visualization pattern rather than
|
||||||
|
pixel-identical scaling.
|
||||||
|
- Responsive verification: 390 × 844 CSS px, document `scrollWidth` 390 px.
|
||||||
|
|
||||||
|
## Full-view comparison evidence
|
||||||
|
|
||||||
|
The two reference reports and the final implementation were opened together in
|
||||||
|
one comparison input. CASAN intentionally preserves the references' strongest
|
||||||
|
patterns—headline metrics, measured distributions, explicit provenance and
|
||||||
|
forensic detail—while moving from an all-dark engineering sheet to the existing
|
||||||
|
CASAN light decision workspace.
|
||||||
|
|
||||||
|
## Required fidelity surfaces
|
||||||
|
|
||||||
|
- Fonts and typography: the references use dense monospace throughout. CASAN
|
||||||
|
intentionally uses the existing system sans hierarchy for decision content
|
||||||
|
and reserves monospace for IDs, timestamps and evidence values. Headings,
|
||||||
|
labels and body copy wrap without clipping.
|
||||||
|
- Spacing and layout rhythm: four executive metrics, decision findings, outcome
|
||||||
|
distribution and evidence quality form a consistent vertical sequence.
|
||||||
|
Technical content is removed from the default reading path.
|
||||||
|
- Colors and tokens: navy, cyan, green, amber and red follow the existing CASAN
|
||||||
|
semantic palette. Every colored state also has a visible label or value.
|
||||||
|
- Image and asset fidelity: neither reference contains raster product imagery.
|
||||||
|
No placeholder image, generated asset or replacement illustration is needed.
|
||||||
|
- Copy and content: all values and warnings come from canonical report data.
|
||||||
|
Missing token/cost values remain unavailable and no maturity claim is inferred.
|
||||||
|
|
||||||
|
## Focused-region comparison
|
||||||
|
|
||||||
|
The final full-page capture is readable at original resolution, including the
|
||||||
|
executive metrics, decision findings, outcome bar, coverage bars and collapsed
|
||||||
|
technical disclosure. A separate crop was not needed.
|
||||||
|
|
||||||
|
## Interaction and accessibility checks
|
||||||
|
|
||||||
|
- Filter and technical sections are native `details`/`summary` controls.
|
||||||
|
- Technical telemetry becomes visible when expanded and hidden when collapsed.
|
||||||
|
- Responsive navigation opens from one labeled button and closes with Escape.
|
||||||
|
- At 390 px the document has no horizontal overflow.
|
||||||
|
- Reduced-motion CSS remains present.
|
||||||
|
- Browser console warning/error check returned no entries.
|
||||||
|
|
||||||
|
## Comparison history
|
||||||
|
|
||||||
|
### Iteration 1
|
||||||
|
|
||||||
|
- Finding: **P2 — tablet navigation consumed too much report width** at the
|
||||||
|
default 866 px browser viewport.
|
||||||
|
- Evidence: `tmp/design-qa/h6-default.png`.
|
||||||
|
- Fix: raised the local viewer navigation-collapse breakpoint from 820 px to
|
||||||
|
960 px.
|
||||||
|
|
||||||
|
### Iteration 2
|
||||||
|
|
||||||
|
- Post-fix evidence: `tmp/design-qa/h6-default-final.png`.
|
||||||
|
- Result: the report uses the full tablet canvas, the menu remains keyboard
|
||||||
|
operable, all content is readable, and no actionable P0/P1/P2 mismatch remains.
|
||||||
|
|
||||||
|
## Follow-up polish
|
||||||
|
|
||||||
|
- A multi-run trend chart can be added only after the report contract exposes a
|
||||||
|
truthful ordered series. It must not be inferred from incomplete snapshots.
|
||||||
|
|
||||||
|
final result: passed
|
||||||
@@ -11,7 +11,12 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
CASAN_PROFILE: prod
|
CASAN_PROFILE: prod
|
||||||
CASAN_CP_STRICT: "1"
|
CASAN_CP_STRICT: "1"
|
||||||
CASAN_CP_TRUST_AUTH_PROXY: "1"
|
CASAN_CP_AUTH_MODE: jwt
|
||||||
|
CASAN_CP_JWT_ISSUER: http://localhost:18082
|
||||||
|
CASAN_CP_JWT_AUDIENCE: casan-control-panel
|
||||||
|
CASAN_CP_JWT_PUBLIC_KEY_FILE: /run/casan-idp/idp-public.pem
|
||||||
|
CASAN_CP_JWT_ROLE_CLAIM: groups
|
||||||
|
CASAN_CP_JWT_CLOCK_SKEW_SECONDS: "30"
|
||||||
CP_BIND: 0.0.0.0
|
CP_BIND: 0.0.0.0
|
||||||
CP_PORT: "3010"
|
CP_PORT: "3010"
|
||||||
CASAN_APP_ROOT: /app
|
CASAN_APP_ROOT: /app
|
||||||
@@ -63,6 +68,7 @@ services:
|
|||||||
- ./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/config/project-registry.json:/app/packages/casan-harness/config/project-registry.json
|
- ./packages/casan-harness/config/project-registry.json:/app/packages/casan-harness/config/project-registry.json
|
||||||
|
- ./tmp/control-panel-local/idp-public.pem:/run/casan-idp/idp-public.pem:ro
|
||||||
expose:
|
expose:
|
||||||
- "3010"
|
- "3010"
|
||||||
networks:
|
networks:
|
||||||
@@ -111,8 +117,8 @@ services:
|
|||||||
- --oidc-groups-claim=groups
|
- --oidc-groups-claim=groups
|
||||||
- --reverse-proxy=true
|
- --reverse-proxy=true
|
||||||
- --set-xauthrequest=true
|
- --set-xauthrequest=true
|
||||||
- --pass-access-token=false
|
- --pass-access-token=true
|
||||||
- --pass-authorization-header=false
|
- --pass-authorization-header=true
|
||||||
- --skip-provider-button=true
|
- --skip-provider-button=true
|
||||||
- --ssl-insecure-skip-verify=true
|
- --ssl-insecure-skip-verify=true
|
||||||
expose:
|
expose:
|
||||||
@@ -129,7 +135,10 @@ services:
|
|||||||
CASAN_IDP_SUB: oidc-ops
|
CASAN_IDP_SUB: oidc-ops
|
||||||
CASAN_IDP_EMAIL: oidc-ops@example.com
|
CASAN_IDP_EMAIL: oidc-ops@example.com
|
||||||
CASAN_IDP_GROUPS: casan-org-admin,casan-approver,casan-project:AINative_OKR_CASAN4
|
CASAN_IDP_GROUPS: casan-org-admin,casan-approver,casan-project:AINative_OKR_CASAN4
|
||||||
|
CASAN_IDP_PRIVATE_KEY_FILE: /run/casan-idp/idp-private.pem
|
||||||
CASAN_APPROVAL_SIGNER_TOKEN: ${CASAN_APPROVAL_SIGNER_TOKEN:-local-approval-signer-secret}
|
CASAN_APPROVAL_SIGNER_TOKEN: ${CASAN_APPROVAL_SIGNER_TOKEN:-local-approval-signer-secret}
|
||||||
|
volumes:
|
||||||
|
- ./tmp/control-panel-local/idp-private.pem:/run/casan-idp/idp-private.pem:ro
|
||||||
ports:
|
ports:
|
||||||
- "18082:8080"
|
- "18082:8080"
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
# `production-preflight.sh` validates all files, endpoints, Object Lock and
|
# `production-preflight.sh` validates all files, endpoints, Object Lock and
|
||||||
# images before `docker compose up` is allowed.
|
# images before `docker compose up` is allowed.
|
||||||
#
|
#
|
||||||
# oauth2-proxy must emit X-Auth-Request-User and X-Auth-Request-Groups.
|
# oauth2-proxy must emit a signed OIDC access token. Nginx removes caller-supplied
|
||||||
# Nginx overwrites X-CASAN-* headers before proxying to the API; the API maps
|
# identity headers and forwards only that bearer token; the API verifies issuer,
|
||||||
# groups such as casan-approver -> approver via rbac-check.py map-claim.
|
# audience, expiry and signature in-process before deriving CASAN identity.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
control-panel-api:
|
control-panel-api:
|
||||||
@@ -19,7 +19,6 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
CASAN_PROFILE: prod
|
CASAN_PROFILE: prod
|
||||||
CASAN_CP_STRICT: "1"
|
CASAN_CP_STRICT: "1"
|
||||||
CASAN_CP_TRUST_AUTH_PROXY: "1"
|
|
||||||
CP_BIND: 0.0.0.0
|
CP_BIND: 0.0.0.0
|
||||||
CP_PORT: "3010"
|
CP_PORT: "3010"
|
||||||
CASAN_APP_ROOT: /app
|
CASAN_APP_ROOT: /app
|
||||||
@@ -29,6 +28,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ${CASAN_CP_STATE_DIR:?Set CASAN_CP_STATE_DIR}:/app/.specify
|
- ${CASAN_CP_STATE_DIR:?Set CASAN_CP_STATE_DIR}:/app/.specify
|
||||||
- ${CASAN_CP_OUTPUT_DIR:?Set CASAN_CP_OUTPUT_DIR}:/app/docs/output:ro
|
- ${CASAN_CP_OUTPUT_DIR:?Set CASAN_CP_OUTPUT_DIR}:/app/docs/output:ro
|
||||||
|
- ${CASAN_CP_IDP_PUBLIC_KEY:?Set CASAN_CP_IDP_PUBLIC_KEY}:/run/casan-idp/idp-public.pem:ro
|
||||||
expose:
|
expose:
|
||||||
- "3010"
|
- "3010"
|
||||||
networks:
|
networks:
|
||||||
@@ -57,8 +57,8 @@ services:
|
|||||||
- --http-address=0.0.0.0:4180
|
- --http-address=0.0.0.0:4180
|
||||||
- --reverse-proxy=true
|
- --reverse-proxy=true
|
||||||
- --set-xauthrequest=true
|
- --set-xauthrequest=true
|
||||||
- --pass-access-token=false
|
- --pass-access-token=true
|
||||||
- --pass-authorization-header=false
|
- --pass-authorization-header=true
|
||||||
- --skip-provider-button=true
|
- --skip-provider-button=true
|
||||||
- --cookie-secure=true
|
- --cookie-secure=true
|
||||||
- --cookie-httponly=true
|
- --cookie-httponly=true
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# CASAN Assurance Kernel
|
||||||
|
|
||||||
|
Status: implemented contract layer, version `1.0.0` (2026-08-02).
|
||||||
|
|
||||||
|
## Purpose and maturity
|
||||||
|
|
||||||
|
CASAN now has a framework-independent wire contract and deterministic policy
|
||||||
|
primitives outside the native Bash topology. The native harness remains
|
||||||
|
operational and is the first compatibility adapter; the Agentic Bridge is the
|
||||||
|
second adapter. This is an incremental extraction, not a rewrite of H1–H7.
|
||||||
|
|
||||||
|
CASAN may describe this layer as an **Assurance Kernel contract and policy
|
||||||
|
foundation with two conforming adapters**. It must not claim that every legacy
|
||||||
|
loop, graph, report, or third-party runtime has migrated to the kernel.
|
||||||
|
|
||||||
|
## Boundary
|
||||||
|
|
||||||
|
```text
|
||||||
|
Execution runtime
|
||||||
|
-> runtime adapter
|
||||||
|
-> canonical Assurance Kernel envelope
|
||||||
|
-> deterministic policy / verification / evidence services
|
||||||
|
-> runtime-specific enforcement mechanisms
|
||||||
|
```
|
||||||
|
|
||||||
|
The kernel does not execute models. It does not infer security decisions from
|
||||||
|
model output. Runtime-specific data is allowed only under a namespace such as
|
||||||
|
`casan.runtime.casan-native-harness` or `casan.runtime.agentic-bridge`.
|
||||||
|
|
||||||
|
## Canonical contract
|
||||||
|
|
||||||
|
The canonical source is
|
||||||
|
`packages/casan-harness/schemas/assurance-kernel.schema.json`. It is JSON Schema
|
||||||
|
Draft 2020-12 and uses `schema_version: 1.0.0`.
|
||||||
|
|
||||||
|
The envelope defines:
|
||||||
|
|
||||||
|
- `ExecutionRun`: identity, parent, scope, environment, runtime, mode, goal,
|
||||||
|
timestamps, status, risk summary, evidence manifest and correlation.
|
||||||
|
- `ExecutionStep`: causal position, actor/action/resource, context references,
|
||||||
|
policy decisions, tool invocation, verification, evidence and outcome.
|
||||||
|
- `Actor`: human, agent, child agent, model, tool, service account, runtime,
|
||||||
|
approver or policy engine with issuer, trust and authentication evidence.
|
||||||
|
- `Delegation`: authority, resource scope, expiry, maximum depth, parent,
|
||||||
|
revocation and approval.
|
||||||
|
- `ContextItem`: origin, trust, content hash, transforms, compression lineage,
|
||||||
|
instruction/data classification and injection scan.
|
||||||
|
- `Action` and `ResourceAccess`: canonical class, operation, authority,
|
||||||
|
side-effect level, resource and environment.
|
||||||
|
- `PolicyDecision`: versioned policy, structured decision, reason codes, risk
|
||||||
|
facts, enforcement point, engine identity, time and evidence reference.
|
||||||
|
- `RuntimeConstraint` and `RuntimeCapabilities`: honest limits and supported
|
||||||
|
cancellation/intervention mechanisms.
|
||||||
|
- `VerificationRequirement` and `VerificationResult`: expected versus actual
|
||||||
|
result, evidence, independence and failure severity.
|
||||||
|
- `Claim`, `EvidenceItem` and `TraceLink`: bidirectional claim support,
|
||||||
|
integrity metadata, producer, artifact, validation, retention and typed links.
|
||||||
|
- `Approval` and `Intervention`: explicit decisions and runtime support status.
|
||||||
|
- `Outcome`: four independent results for execution, assurance,
|
||||||
|
certification and business effect.
|
||||||
|
|
||||||
|
## Deterministic kernel services
|
||||||
|
|
||||||
|
The stdlib-only Python implementation is under
|
||||||
|
`packages/casan-harness/kernel/`.
|
||||||
|
|
||||||
|
`policy.py` provides:
|
||||||
|
|
||||||
|
- Machine-readable action classification from
|
||||||
|
`config/action-classes.json`.
|
||||||
|
- Effective risk as the maximum of content, action, resource, identity and
|
||||||
|
environment risk.
|
||||||
|
- H2 registry configuration validation and fail-closed dependency decisions.
|
||||||
|
- Single-step failure-policy selection with `halt`, `quarantine`,
|
||||||
|
`require_approval` and `record_only`.
|
||||||
|
- Production trust capability evaluation.
|
||||||
|
|
||||||
|
`contracts.py` provides builders and an enforcement-boundary verifier. The
|
||||||
|
verifier rejects unsupported schema versions, invalid actor/step references,
|
||||||
|
observed-only certification, broken claim/evidence links, evidence digest
|
||||||
|
tampering, delegation cycles and excess delegation depth.
|
||||||
|
|
||||||
|
`taxonomy.py` separates `RuntimeControl.Hn`, `ReadinessCheck.Hn`,
|
||||||
|
`ReportDimension.Hn` and `CertificationClaim.Hn`. A legacy `Hn` remains a
|
||||||
|
display alias only and never authorizes cross-category interpretation.
|
||||||
|
|
||||||
|
`supervision.py` negotiates interventions against declared runtime
|
||||||
|
capabilities. Unsupported pause, rollback, cancellation, authority reduction
|
||||||
|
or quarantine returns `unsupported`; it never reports a fictitious success.
|
||||||
|
|
||||||
|
## Action policy
|
||||||
|
|
||||||
|
The action registry includes read, write, delete, database mutation,
|
||||||
|
migration, deployment, release, credential access, identity/permission change,
|
||||||
|
external network side effect, infrastructure modification and unknown actions.
|
||||||
|
|
||||||
|
High-impact actions have a high minimum risk, require an identified actor,
|
||||||
|
explicit approval and evidence. Benign text cannot reduce that floor. Unknown
|
||||||
|
shell operations also fail toward high risk. H2 registry enforcement and H5
|
||||||
|
approval remain separate defense-in-depth decisions.
|
||||||
|
|
||||||
|
## Outcome semantics
|
||||||
|
|
||||||
|
The following states are deliberately distinct:
|
||||||
|
|
||||||
|
```text
|
||||||
|
execution_result = success | failed | cancelled | quarantined | ...
|
||||||
|
assurance_result = passed | failed | degraded | not_evaluated | ...
|
||||||
|
certification_result = certified | non_certified | ineligible | ...
|
||||||
|
business_result = achieved | not_achieved | partial | not_evaluated | ...
|
||||||
|
```
|
||||||
|
|
||||||
|
A telemetry append can succeed while `execution_result=failed`. A failed
|
||||||
|
side-effecting command defaults to `halt` in enforce/production mode and cannot
|
||||||
|
produce a successful completion or normal certification.
|
||||||
|
|
||||||
|
## Evidence integrity and trust
|
||||||
|
|
||||||
|
Canonical evidence metadata is linked to claims and carries a SHA-256 digest.
|
||||||
|
This detects local mutation and broken references. It is not, by itself, an
|
||||||
|
external trust root or WORM guarantee. Production certification separately
|
||||||
|
requires external signing and an external immutable anchor.
|
||||||
|
|
||||||
|
## Compatibility rules
|
||||||
|
|
||||||
|
- New envelopes use semantic version `1.0.0`; incompatible schema changes
|
||||||
|
require a new major version.
|
||||||
|
- Additive runtime fields belong under a namespaced `extensions` object.
|
||||||
|
- The native and Agentic Bridge formats remain readable and are dual-emitted
|
||||||
|
with canonical envelopes during migration.
|
||||||
|
- Legacy evidence is not silently reinterpreted as kernel-validated evidence.
|
||||||
|
- Observe-mode and unsafe-development-bypass runs are never certified.
|
||||||
|
- Audit chain format v1 remains verifiable while new records use v2 fields.
|
||||||
|
|
||||||
|
## Claims and residual limits
|
||||||
|
|
||||||
|
CASAN can claim deterministic shared action policy, canonical execution and
|
||||||
|
evidence contracts, and cross-runtime conformance for the native harness and
|
||||||
|
Agentic Bridge. It cannot yet claim universal runtime adoption, complete
|
||||||
|
multi-runtime supervision, external infrastructure availability, or
|
||||||
|
data-backed H1–H7 dossiers beyond the reports actually backed by evidence.
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# CASAN Control Plane
|
||||||
|
|
||||||
|
Status: production-capable packaged authentication and trust configuration;
|
||||||
|
external infrastructure remains operator-provisioned.
|
||||||
|
|
||||||
|
## Deployment modes
|
||||||
|
|
||||||
|
### Local development
|
||||||
|
|
||||||
|
- Default bind: `127.0.0.1`.
|
||||||
|
- `CASAN_CP_AUTH_MODE=local` is allowed only on loopback and outside production.
|
||||||
|
- Local actor headers are explicitly development identity and are not a
|
||||||
|
production authentication claim.
|
||||||
|
|
||||||
|
### Networked or production
|
||||||
|
|
||||||
|
- `CASAN_CP_AUTH_MODE=jwt` is mandatory.
|
||||||
|
- Production requires RS256 verification, a mounted public key, exact issuer
|
||||||
|
and audience, and a clock skew from 0 through 300 seconds.
|
||||||
|
- Missing or invalid configuration refuses startup before NestJS listens.
|
||||||
|
- HS256 is retained only for deterministic non-production tests.
|
||||||
|
|
||||||
|
## Packaged identity boundary
|
||||||
|
|
||||||
|
```text
|
||||||
|
Browser
|
||||||
|
-> enterprise OIDC
|
||||||
|
-> oauth2-proxy session
|
||||||
|
-> Nginx auth_request
|
||||||
|
removes X-CASAN-* and forwarded identity headers
|
||||||
|
forwards signed Bearer access token
|
||||||
|
-> NestJS AuthProvider
|
||||||
|
verifies RS256 signature, iss, aud, sub, exp, nbf and iat
|
||||||
|
-> VerifiedClaims
|
||||||
|
-> CASAN RBAC role mapping and scoped request identity
|
||||||
|
```
|
||||||
|
|
||||||
|
Nginx is a TLS/session boundary, not the identity authority for the API. The
|
||||||
|
API ignores caller-supplied identity assertions and derives actor, role,
|
||||||
|
tenant and project only after token verification.
|
||||||
|
|
||||||
|
## Authentication implementation
|
||||||
|
|
||||||
|
`backend/src/common/auth-provider.ts` defines:
|
||||||
|
|
||||||
|
- `AuthProvider` and `JwtAuthProvider`.
|
||||||
|
- Typed `VerifiedClaims` and `AuthenticationDecision`.
|
||||||
|
- RS256/HS256 signature verification using Node's standard crypto APIs.
|
||||||
|
- Issuer, audience, expiry, not-before, issued-at and bounded-skew checks.
|
||||||
|
- Safe tenant/project syntax validation.
|
||||||
|
- Structured authentication audit evidence without raw tokens.
|
||||||
|
- Middleware that overwrites request identity only from verified claims.
|
||||||
|
|
||||||
|
`auth-context.ts` refuses direct header use in JWT mode unless the middleware
|
||||||
|
has marked the identity verified. Existing RBAC services then map verified
|
||||||
|
groups and continue to produce governance/authorization evidence.
|
||||||
|
|
||||||
|
## Production configuration
|
||||||
|
|
||||||
|
Required runtime variables:
|
||||||
|
|
||||||
|
```text
|
||||||
|
CASAN_PROFILE=prod
|
||||||
|
CASAN_CP_AUTH_MODE=jwt
|
||||||
|
CASAN_CP_JWT_ISSUER=https://...
|
||||||
|
CASAN_CP_JWT_AUDIENCE=...
|
||||||
|
CASAN_CP_JWT_PUBLIC_KEY_FILE=/run/casan-idp/idp-public.pem
|
||||||
|
CASAN_CP_JWT_ROLE_CLAIM=groups
|
||||||
|
CASAN_CP_JWT_TENANT_CLAIM=casan_tenant
|
||||||
|
CASAN_CP_JWT_PROJECT_CLAIM=casan_project
|
||||||
|
CASAN_CP_JWT_CLOCK_SKEW_SECONDS=60
|
||||||
|
```
|
||||||
|
|
||||||
|
The host path is configured as `CASAN_CP_IDP_PUBLIC_KEY` and mounted read-only.
|
||||||
|
oauth2-proxy must set xauthrequest output and pass the access token. Images for
|
||||||
|
API, UI and oauth2-proxy must be digest-pinned.
|
||||||
|
|
||||||
|
`production-preflight.sh` validates:
|
||||||
|
|
||||||
|
- TLS hostname, expiry and matching private key.
|
||||||
|
- IdP public key readability and RSA public-key format.
|
||||||
|
- HTTPS OIDC issuer, secure cookies and token forwarding.
|
||||||
|
- Matching oauth2-proxy client ID and API audience.
|
||||||
|
- Matching issuer and bounded JWT clock skew.
|
||||||
|
- External Vault/KMS signing and S3 Object Lock provider selection.
|
||||||
|
- Live short-lived non-root Vault token lookup.
|
||||||
|
- A real Object Lock COMPLIANCE anchor write.
|
||||||
|
- Digest-pinned images and valid Compose/Nginx configuration.
|
||||||
|
|
||||||
|
## Authentication evidence
|
||||||
|
|
||||||
|
Each production authentication decision appends a JSONL record under
|
||||||
|
`CASAN_STATE_ROOT/logs/auth/decisions.jsonl` with category, policy, decision,
|
||||||
|
reason, actor, issuer, scope and request method/path. Tokens and secrets are
|
||||||
|
never written. A failure to authenticate returns HTTP 401 with a stable reason
|
||||||
|
code; it is not converted to viewer access.
|
||||||
|
|
||||||
|
## Local production-like smoke
|
||||||
|
|
||||||
|
`docker-compose.control-panel.local.yml` uses the same bearer-token path. The
|
||||||
|
smoke script generates an ephemeral RSA keypair, mounts the private key into
|
||||||
|
the mock IdP and the public key into the API, and enables token forwarding.
|
||||||
|
This validates the boundary without treating the mock IdP as production.
|
||||||
|
|
||||||
|
## Residual limitations
|
||||||
|
|
||||||
|
- Public-key rotation currently requires replacing the mounted key and
|
||||||
|
restarting the API; automated JWKS discovery and rollover are not present.
|
||||||
|
- Enterprise IdP, managed TLS, Vault and S3 are not provisioned by the repo.
|
||||||
|
- Local tests validate crypto and provider contracts, but do not prove a real
|
||||||
|
tenant's claim mapping, token lifecycle, network policy or key rotation.
|
||||||
|
- The Control Plane is production-capable for this packaged deployment model;
|
||||||
|
it is not yet a universal multi-runtime scheduler or supervisor.
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# CASAN Execution Adapters
|
||||||
|
|
||||||
|
## Adapter contract
|
||||||
|
|
||||||
|
An execution adapter translates runtime events into the canonical Assurance
|
||||||
|
Kernel envelope without replacing H2, H4, H5 or other existing controls.
|
||||||
|
|
||||||
|
```text
|
||||||
|
runtime event
|
||||||
|
-> adapter mapping
|
||||||
|
-> ExecutionRun / ExecutionStep / Actor / Action
|
||||||
|
-> PolicyDecision / VerificationResult / EvidenceItem / Outcome
|
||||||
|
-> common verifier
|
||||||
|
```
|
||||||
|
|
||||||
|
Adapter code lives in `packages/casan-harness/kernel/adapters.py`. The canonical
|
||||||
|
schema and policy code must not import a native runtime.
|
||||||
|
|
||||||
|
## Native harness adapter
|
||||||
|
|
||||||
|
`NativeHarnessAdapter` maps the Bash harness execution identity, action,
|
||||||
|
command, actor, environment, policy decisions, evidence and final outcome. The
|
||||||
|
native entrypoint in `scripts/bash/casan-harness.sh` continues to emit its
|
||||||
|
existing metrics and completion protocol while atomically writing a canonical
|
||||||
|
bundle to `CASAN_STATE_ROOT/logs/kernel/`.
|
||||||
|
|
||||||
|
If a phase exits non-zero, the harness best-effort emits a canonical failed
|
||||||
|
bundle before propagating the original exit code. This emission never masks
|
||||||
|
the command failure. A successful run reports execution, assurance and
|
||||||
|
certification separately.
|
||||||
|
|
||||||
|
The adapter is compatibility code: existing H gates remain the enforcement
|
||||||
|
implementation. The kernel does not duplicate them.
|
||||||
|
|
||||||
|
## Agentic Bridge adapter
|
||||||
|
|
||||||
|
`AgenticBridgeAdapter` maps lifecycle admission, pre-tool, post-tool, telemetry
|
||||||
|
and finalize events. The bridge now:
|
||||||
|
|
||||||
|
- Uses the shared action classifier and risk floors.
|
||||||
|
- Defaults H2 registry enforcement on in enforce mode.
|
||||||
|
- Converts missing, unreadable, timed-out, malformed or failed H2 dependencies
|
||||||
|
into structured policy decisions.
|
||||||
|
- Distinguishes failed tool outcome from successful telemetry recording.
|
||||||
|
- Applies the single-step failure policy.
|
||||||
|
- Restricts certification using enforcement mode, assurance strength and trust
|
||||||
|
capability.
|
||||||
|
- Dual-emits the canonical bundle alongside its v20 lifecycle records.
|
||||||
|
|
||||||
|
The Agentic Bridge remains a single-model integration: it performs admission,
|
||||||
|
policy, evidence and finalization but does not invoke a model itself.
|
||||||
|
|
||||||
|
## Cross-runtime conformance
|
||||||
|
|
||||||
|
`tests/assurance-kernel-tests.py` runs the same invariants through both
|
||||||
|
adapters. It proves:
|
||||||
|
|
||||||
|
1. Dangerous action classification is identical.
|
||||||
|
2. Missing actor identity denies the same high-impact action.
|
||||||
|
3. Missing H2 enforcement denies in enforce mode.
|
||||||
|
4. Approval and evidence requirements are identical.
|
||||||
|
5. Claim/evidence relationships use the same contract.
|
||||||
|
6. Observed-only execution cannot be certified.
|
||||||
|
7. Execution, assurance and certification outcomes remain distinct.
|
||||||
|
8. Correlation and parent causation are preserved.
|
||||||
|
9. The same verifier detects evidence tampering.
|
||||||
|
10. Runtime details are namespaced extensions rather than kernel assumptions.
|
||||||
|
|
||||||
|
The conformance suite currently covers two paths. A future adapter must pass
|
||||||
|
the same suite before being described as kernel-conforming.
|
||||||
|
|
||||||
|
## Capability negotiation
|
||||||
|
|
||||||
|
Adapters declare `canCancel`, `canPause`, `canResume`, `canRollback`,
|
||||||
|
`canReduceAuthority` and `canQuarantine`. These are conservative. The native
|
||||||
|
harness currently declares no general asynchronous intervention capability;
|
||||||
|
the Agentic Bridge declares quarantine support only where its lifecycle can
|
||||||
|
represent it. Unsupported interventions return an explicit result.
|
||||||
|
|
||||||
|
## Adding an adapter
|
||||||
|
|
||||||
|
1. Preserve the runtime's existing security controls.
|
||||||
|
2. Map stable run, step, actor, correlation and causation identities.
|
||||||
|
3. Use the shared action policy; do not create a weaker parallel classifier.
|
||||||
|
4. Emit policy decisions and evidence references, not boolean-only results.
|
||||||
|
5. Put runtime-only fields in `casan.runtime.<adapter>` extensions.
|
||||||
|
6. Validate with `validate_bundle` and the JSON Schema.
|
||||||
|
7. Add the full cross-runtime invariant matrix.
|
||||||
|
8. Do not mark legacy or observe-only records certified.
|
||||||
|
|
||||||
|
## Residual migration scope
|
||||||
|
|
||||||
|
Custom loop runners, graphs and every historical event producer have not been
|
||||||
|
rewritten. They may continue using legacy formats, but they cannot inherit
|
||||||
|
kernel-conformance claims until they receive an adapter and pass conformance.
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# CASAN Trust Boundaries
|
||||||
|
|
||||||
|
## Trust model
|
||||||
|
|
||||||
|
CASAN separates enforcement, evidence, identity, execution isolation and
|
||||||
|
external trust. Passing one boundary never implies that another passed.
|
||||||
|
|
||||||
|
| Boundary | Development/local | Production/strict |
|
||||||
|
|---|---|---|
|
||||||
|
| Control Plane identity | Explicit loopback local identity | In-process RS256 JWT verification required |
|
||||||
|
| H2 tool authority | Enforce defaults on; explicit bypass is high-severity/non-certifiable | Enforce on; bypass configuration rejected |
|
||||||
|
| H5 high-impact action | Approval and actor required by action floor | Strict signed approval plus external trust requirements |
|
||||||
|
| Tool isolation | Static fallback only when explicitly enabled in dev/test | Container isolation required; unavailable backend denies |
|
||||||
|
| Audit signing | Local OpenSSL permitted and labelled local | Vault/KMS external signing required |
|
||||||
|
| Immutable anchor | Local hash chain permitted and labelled local | External Object Lock/WORM provider required |
|
||||||
|
| Certification | Local/observed limitations disclosed | Unsafe fallback or emergency override cannot normally certify |
|
||||||
|
|
||||||
|
## H2 enforcement dependency
|
||||||
|
|
||||||
|
H2 returns a structured `PolicyDecision`, never a boolean-only decision. Missing
|
||||||
|
file, unreadable file, timeout, malformed protocol, missing runtime and internal
|
||||||
|
execution errors have stable reason codes. In enforce mode all dependency
|
||||||
|
errors deny. In observe mode the operation may be observed, but assurance is
|
||||||
|
degraded and certification is forbidden.
|
||||||
|
|
||||||
|
Registry enforcement defaults on for side-effecting native and Agentic Bridge
|
||||||
|
paths. `CASAN_H2_REGISTRY=0` or `CASAN_AGENTIC_H2_REGISTRY=0` is an explicit
|
||||||
|
unsafe development/test bypass. Production treats the bypass as invalid.
|
||||||
|
|
||||||
|
## H5 governance and action risk
|
||||||
|
|
||||||
|
`config/action-classes.json` is the common policy source. Effective risk is the
|
||||||
|
maximum of content, action, resource, identity and environment factors.
|
||||||
|
Deployment, release, migration, database mutation, delete,
|
||||||
|
identity/permission modification and infrastructure modification cannot be
|
||||||
|
downgraded by benign wording. Credential access and unknown commands are also
|
||||||
|
high-risk. High-impact operations require actor identity, approval and
|
||||||
|
evidence.
|
||||||
|
|
||||||
|
Production governance requires strict approval and external signing/anchor
|
||||||
|
capability. Governance audit format v2 binds action class, risk factors and
|
||||||
|
evidence requirements into the hash. The verifier continues to recognize
|
||||||
|
legacy v1 records without upgrading their claims.
|
||||||
|
|
||||||
|
## Execution isolation
|
||||||
|
|
||||||
|
`sandbox-run.sh` detects its selected capability and writes structured sandbox
|
||||||
|
evidence. In enforce/production mode a side-effecting path requires the
|
||||||
|
container backend. If Docker or the required backend is unavailable, execution
|
||||||
|
is denied with `sandbox_isolation_backend_unavailable`; there is no silent
|
||||||
|
timeout/static fallback.
|
||||||
|
|
||||||
|
The implemented container contract uses:
|
||||||
|
|
||||||
|
- Read-only root filesystem.
|
||||||
|
- Explicit workspace bind mount as the writable scope.
|
||||||
|
- Network disabled by default.
|
||||||
|
- Non-root container user.
|
||||||
|
- Dropped Linux capabilities and `no-new-privileges`.
|
||||||
|
- PID, memory, CPU and timeout limits.
|
||||||
|
- Restricted working directory and filtered environment.
|
||||||
|
- Output-size and exit-code capture at the harness boundary.
|
||||||
|
- Rootless Docker requirement in production.
|
||||||
|
|
||||||
|
CASAN does not claim AppArmor, SELinux, a custom seccomp profile, per-domain
|
||||||
|
network allowlisting or complete host isolation where those mechanisms are not
|
||||||
|
configured. Development static fallback is policy filtering, not isolation.
|
||||||
|
|
||||||
|
## Control Plane identity boundary
|
||||||
|
|
||||||
|
Arbitrary `X-CASAN-*`, `X-Forwarded-User` and similar headers are untrusted.
|
||||||
|
Nginx removes them, oauth2-proxy provides a signed access token, and the API
|
||||||
|
verifies the token before creating `VerifiedClaims`. A non-loopback local mode
|
||||||
|
or an invalid production verifier refuses startup.
|
||||||
|
|
||||||
|
## Signing and immutable evidence boundary
|
||||||
|
|
||||||
|
Local SHA-256 chains and evidence digests provide tamper detection under the
|
||||||
|
local host's trust. They are not externally trusted immutability. Normal
|
||||||
|
production certification requires both:
|
||||||
|
|
||||||
|
1. `CASAN_SIGNING_PROVIDER=vault_kms` (or another explicitly supported external
|
||||||
|
trust provider) with an HTTPS endpoint and short-lived credential.
|
||||||
|
2. `CASAN_IMMUTABLE_ANCHOR_PROVIDER=s3_object_lock` (or an explicitly supported
|
||||||
|
external WORM provider) with bucket and KMS configuration.
|
||||||
|
|
||||||
|
The packaged production path verifies Vault Transit readiness and performs an
|
||||||
|
S3 Object Lock COMPLIANCE anchor write. It does not provision either service.
|
||||||
|
|
||||||
|
`CASAN_TRUST_EMERGENCY_OVERRIDE=1` is break-glass behavior. It emits critical
|
||||||
|
evidence, keeps readiness failed and makes the run non-certifiable. It is not a
|
||||||
|
normal production fallback.
|
||||||
|
|
||||||
|
## Secret and evidence handling
|
||||||
|
|
||||||
|
- Raw JWTs and authentication secrets are not logged.
|
||||||
|
- Tool and prompt evidence remains subject to existing redaction/scanning.
|
||||||
|
- Authentication evidence records identity metadata and stable reason codes.
|
||||||
|
- Canonical evidence digests detect local metadata tampering, while external
|
||||||
|
signing/anchoring supplies production trust.
|
||||||
|
- A successful evidence append never changes a failed execution into success.
|
||||||
|
|
||||||
|
## Infrastructure not proven by repository tests
|
||||||
|
|
||||||
|
Repository-local tests use cryptographic fixtures and narrow service stubs.
|
||||||
|
Docker isolation tests are skip-aware when Docker is absent. Real enterprise
|
||||||
|
OIDC traffic, Vault policy enforcement, KMS/HSM custody, S3 retention policy,
|
||||||
|
rootless Docker host hardening and managed network controls require deployment
|
||||||
|
evidence before production claims are made.
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
# Assurance Kernel and Control Plane Upgrade — Implementation Log
|
||||||
|
|
||||||
|
Date: 2026-08-02
|
||||||
|
|
||||||
|
## Executive implementation result
|
||||||
|
|
||||||
|
All seven P0 items and all five requested P1 items were implemented with
|
||||||
|
targeted regression coverage. The native harness remains operational. The
|
||||||
|
Assurance Kernel now has a versioned canonical contract, deterministic shared
|
||||||
|
policy, a native adapter, an Agentic Bridge adapter and a 10-invariant
|
||||||
|
cross-runtime conformance suite. The Control Plane packages in-process RS256
|
||||||
|
identity verification and refuses unsafe network startup.
|
||||||
|
|
||||||
|
P2 was intentionally limited: taxonomy and runtime capability negotiation were
|
||||||
|
implemented, but new H4/H5/H2 report dossiers were not represented as
|
||||||
|
data-backed because the shared evidence-query migration was not safe to finish
|
||||||
|
within this change.
|
||||||
|
|
||||||
|
## Verified pre-existing behavior
|
||||||
|
|
||||||
|
- H4 already blocked injection/secrets before model execution.
|
||||||
|
- The agent bridge already denied sensitive pre-tool adapter failures in many
|
||||||
|
paths and disclosed observed-only degradation.
|
||||||
|
- The native harness already propagated command exit status; the misleading
|
||||||
|
completion gap was primarily in Agentic Bridge finalize semantics, where a
|
||||||
|
later `Stop(completed)` could obscure a failed tool.
|
||||||
|
- Container isolation already implemented read-only root, no network,
|
||||||
|
non-root execution, capability dropping and resource bounds.
|
||||||
|
- Production preflight already checked live Vault/S3 prerequisites, and
|
||||||
|
`sign-audit-head.sh` already refused local fallback for `CASAN_PROFILE=prod`.
|
||||||
|
- H6 already had a data-backed report and operational test suite; other report
|
||||||
|
dimensions remained `contract_ready`.
|
||||||
|
- H5 strict signed approval, separation of duties and OIDC approval fixtures
|
||||||
|
already existed.
|
||||||
|
|
||||||
|
## Verified gaps
|
||||||
|
|
||||||
|
- Agentic H2 returned allow when the gate script was absent.
|
||||||
|
- Agentic registry enforcement defaulted off.
|
||||||
|
- H5 risk followed text risk, allowing benign deployment text to avoid the
|
||||||
|
inherent action floor.
|
||||||
|
- Agentic telemetry and finalization did not model failed execution separately
|
||||||
|
enough to prevent misleading successful completion.
|
||||||
|
- `sandbox-run.sh` could silently fall back to weaker timeout/static behavior.
|
||||||
|
- Control Plane identity trusted forwarded identity headers when a flag was set.
|
||||||
|
- Production governance/certification did not uniformly require both external
|
||||||
|
signing and immutable anchoring capabilities.
|
||||||
|
- No stable canonical execution contract or two-runtime conformance suite
|
||||||
|
existed.
|
||||||
|
- H1–H7 categories were structurally ambiguous.
|
||||||
|
|
||||||
|
## Code changed in this task
|
||||||
|
|
||||||
|
### P0.1 — H2 missing-gate behavior
|
||||||
|
|
||||||
|
Selected implementation: `evaluate_h2_gate` returns a structured
|
||||||
|
`PolicyDecision` with stable reason codes. Missing file, permission denial,
|
||||||
|
timeout, malformed response and internal exception deny in enforce mode.
|
||||||
|
Observe mode yields `observe_only`, degraded and non-certifiable evidence.
|
||||||
|
|
||||||
|
Proof: kernel unit tests and Agentic Bridge integration tests cover every error
|
||||||
|
class and both modes.
|
||||||
|
|
||||||
|
Residual: external shell gates remain a runtime dependency; the change makes
|
||||||
|
dependency failure honest and safe rather than eliminating it.
|
||||||
|
|
||||||
|
### P0.2 — registry defaults
|
||||||
|
|
||||||
|
Selected implementation: both side-effecting native and Agentic Bridge paths
|
||||||
|
use shared configuration validation and default registry enforcement on.
|
||||||
|
Development/test bypass is explicit, high-severity and non-certifiable;
|
||||||
|
production bypass is invalid.
|
||||||
|
|
||||||
|
Proof: unit, bridge integration and legacy Agentic Bridge suites.
|
||||||
|
|
||||||
|
### P0.3 — action-class risk floors
|
||||||
|
|
||||||
|
Selected implementation: `config/action-classes.json` is the deterministic
|
||||||
|
source. Effective risk is the maximum of five factors. H5 governance and both
|
||||||
|
adapters consume it; high-impact actions require actor, approval and evidence.
|
||||||
|
|
||||||
|
Proof: benign deployment regression in unit, bridge and shell integration
|
||||||
|
tests. Existing H5 strict approval remains 12/12.
|
||||||
|
|
||||||
|
### P0.4 — single-step failure semantics
|
||||||
|
|
||||||
|
Selected implementation: kernel failure-policy selection plus bridge
|
||||||
|
post-tool/finalize changes. Execution failure, telemetry recording, assurance
|
||||||
|
continuation and certification are separate. Production side-effect
|
||||||
|
`record_only` is rejected. Native phase failure emits a failed canonical bundle
|
||||||
|
and preserves the original non-zero exit.
|
||||||
|
|
||||||
|
Proof: all four policy values, invalid/production configuration, Agentic failed
|
||||||
|
write and native exit-7 regressions.
|
||||||
|
|
||||||
|
### P0.5 — isolation fallback
|
||||||
|
|
||||||
|
Selected implementation: enforce/production chooses container isolation and
|
||||||
|
denies when unavailable. Static fallback requires an explicit dev/test flag.
|
||||||
|
Capability evidence discloses the actual backend. Production requires a
|
||||||
|
digest-pinned image and rootless Docker.
|
||||||
|
|
||||||
|
Proof: unavailable-backend and explicit-development-fallback tests. Existing
|
||||||
|
container escape tests are present but were skipped locally because Docker was
|
||||||
|
unavailable.
|
||||||
|
|
||||||
|
### P0.6 — Control Plane identity
|
||||||
|
|
||||||
|
Selected implementation: typed `AuthProvider`, RS256 JWT verification,
|
||||||
|
`VerifiedClaims`, safe scope checks, audit evidence, header overwrite and
|
||||||
|
startup validation. Nginx passes only the access token after removing identity
|
||||||
|
headers. Production preflight verifies the packaged boundary.
|
||||||
|
|
||||||
|
Proof: missing/invalid/expired/wrong-audience/wrong-issuer tests, valid RS256,
|
||||||
|
header spoofing and production startup refusal. Backend has 57 passing tests.
|
||||||
|
|
||||||
|
Residual: mounted static public key requires coordinated restart for rotation;
|
||||||
|
JWKS discovery/automatic rollover is not yet implemented.
|
||||||
|
|
||||||
|
### P0.7 — trust-root enforcement
|
||||||
|
|
||||||
|
Selected implementation: shared capability evaluation, governance enforcement,
|
||||||
|
Vault signing, Object Lock anchoring and certification restrictions. Production
|
||||||
|
local fallback is refused. Emergency override is critical, readiness-failing
|
||||||
|
and non-certifiable.
|
||||||
|
|
||||||
|
Proof: unit, bridge, shell and production configuration tests.
|
||||||
|
|
||||||
|
Residual: real Vault/KMS and S3 are customer infrastructure and were not
|
||||||
|
provisioned or contacted in this local run.
|
||||||
|
|
||||||
|
### P1 — contract and adapters
|
||||||
|
|
||||||
|
Selected implementation: JSON Schema `1.0.0`, Python builders/verifier,
|
||||||
|
namespaced extensions, dual emission, native compatibility adapter and Agentic
|
||||||
|
Bridge adapter. Evidence integrity and bidirectional claim links use one
|
||||||
|
verifier. Delegation depth/cycles are validated.
|
||||||
|
|
||||||
|
Proof: 30 kernel/unit/conformance tests, including the exact 10 cross-runtime
|
||||||
|
invariants and evidence tampering.
|
||||||
|
|
||||||
|
### P1 — H taxonomy
|
||||||
|
|
||||||
|
Selected implementation: separate typed namespaces for runtime controls,
|
||||||
|
readiness checks, report dimensions and certification claims. Structured
|
||||||
|
readiness, report and certification evidence now includes a category/type.
|
||||||
|
Legacy `Hn` remains a display mapping only.
|
||||||
|
|
||||||
|
Proof: cross-category unit test and additive Control Plane report tests.
|
||||||
|
|
||||||
|
### Limited P2 — runtime supervision
|
||||||
|
|
||||||
|
Selected implementation: conservative runtime capabilities and intervention
|
||||||
|
negotiation. Unsupported actions are explicit, not silently successful.
|
||||||
|
|
||||||
|
Residual: no general scheduler, durable supervisor state, cross-process cancel,
|
||||||
|
pause/resume implementation, budget service or rollback engine was added.
|
||||||
|
|
||||||
|
## Tests added
|
||||||
|
|
||||||
|
- `assurance-kernel-tests.py`: 30 unit and conformance tests.
|
||||||
|
- `assurance-upgrade-integration-tests.py`: 7 Agentic Bridge integration tests.
|
||||||
|
- `phase-assurance-upgrade-tests.sh`: 11 shell integration groups.
|
||||||
|
- `auth-provider.test.ts`: JWT and production identity tests.
|
||||||
|
- Existing auth-context/report tests extended for spoofing and taxonomy.
|
||||||
|
- Production preflight tests extended to a full valid contract plus audience,
|
||||||
|
local trust and missing-key failures.
|
||||||
|
|
||||||
|
## Verification results
|
||||||
|
|
||||||
|
| Suite | Result |
|
||||||
|
|---|---:|
|
||||||
|
| Kernel unit + two-adapter conformance | 30/30 pass |
|
||||||
|
| Draft 2020-12 JSON Schema validation | pass for both adapters |
|
||||||
|
| Agentic upgrade integration | 7/7 pass |
|
||||||
|
| Assurance upgrade shell integration | 11/11 pass |
|
||||||
|
| Existing Agentic Bridge acceptance/threat suite | 42/42 pass |
|
||||||
|
| Control Plane backend | 57/57 pass |
|
||||||
|
| Control Plane backend TypeScript build | pass |
|
||||||
|
| Control Plane frontend typecheck/Vite build | pass |
|
||||||
|
| Existing H5 approval identity | 12/12 pass |
|
||||||
|
| Sandbox isolation suite | 8/8 accounted; live Docker cases skipped |
|
||||||
|
| Production handoff | 8/8 pass |
|
||||||
|
| Existing H6 AgentOps | 20/20 pass |
|
||||||
|
| Readiness compatibility | 5/5 pass |
|
||||||
|
| Production and local Compose config | pass |
|
||||||
|
|
||||||
|
Legacy suites that generated tracked audit/dashboard output were run with
|
||||||
|
temporary state where supported; known generated artifacts were restored after
|
||||||
|
verification. Existing user-owned `docs/evidence/` was not modified.
|
||||||
|
|
||||||
|
## Infrastructure-dependent work not possible locally
|
||||||
|
|
||||||
|
- Live rootless Docker isolation and network/filesystem escape tests.
|
||||||
|
- Enterprise IdP login and real key-rotation behavior.
|
||||||
|
- Live Vault Transit signing against a managed policy/token.
|
||||||
|
- Live S3 Object Lock retention and organization SCP/IAM enforcement.
|
||||||
|
- Managed TLS/DNS and external endpoint smoke.
|
||||||
|
|
||||||
|
The provider interfaces, fail-closed validation and deterministic local
|
||||||
|
contract tests are implemented for each missing dependency.
|
||||||
|
|
||||||
|
## Remaining gaps
|
||||||
|
|
||||||
|
- H4/H5/H2 and remaining H1/H3/H7 dossiers still need a shared canonical
|
||||||
|
evidence-query implementation before `contract_ready` can be removed.
|
||||||
|
- Static RS256 key mount lacks JWKS rollover.
|
||||||
|
- Kernel adoption is proven for two adapters, not every loop/graph/runtime.
|
||||||
|
- Runtime supervision is capability negotiation, not a full durable supervisor.
|
||||||
|
- External trust and production host hardening require deployment evidence.
|
||||||
|
- Cross-runtime policy-bundle distribution and remote adapter admission are not
|
||||||
|
yet centralized by the Control Plane.
|
||||||
|
|
||||||
|
## Current maturity and honest claims
|
||||||
|
|
||||||
|
After this change, CASAN is a strong assurance-enabled harness with an
|
||||||
|
implemented framework-independent Assurance Kernel foundation and a packaged,
|
||||||
|
production-capable Control Plane boundary. Framework independence is proven for
|
||||||
|
two adapters under the canonical contract. Full platform-wide kernel adoption,
|
||||||
|
universal supervision, complete dossiers and provisioned external trust remain
|
||||||
|
future work.
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# CASAN Assurance Kernel Migration
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This migration preserves existing native and Agentic Bridge protocols while
|
||||||
|
adding canonical kernel envelopes and changing unsafe defaults. It is designed
|
||||||
|
for incremental adoption; no big-bang H1–H7 rewrite is required.
|
||||||
|
|
||||||
|
## Behavior changes
|
||||||
|
|
||||||
|
1. H2 registry enforcement defaults on in enforce mode.
|
||||||
|
2. A missing or failed H2 dependency denies in enforce mode.
|
||||||
|
3. High-impact action classes use deterministic risk floors and require actor,
|
||||||
|
approval and evidence.
|
||||||
|
4. Failed side-effecting single-step execution defaults to halt in enforce or
|
||||||
|
production mode.
|
||||||
|
5. Enforce/production sandbox execution refuses timeout/static-only fallback.
|
||||||
|
6. Networked/production Control Plane startup requires verified JWT identity;
|
||||||
|
`CASAN_CP_TRUST_AUTH_PROXY` no longer authorizes header trust.
|
||||||
|
7. Production certification requires an external signing provider and external
|
||||||
|
immutable anchor.
|
||||||
|
8. Canonical schema `1.0.0` is dual-emitted with legacy runtime records.
|
||||||
|
|
||||||
|
## Development migration
|
||||||
|
|
||||||
|
Existing loopback development works with:
|
||||||
|
|
||||||
|
```text
|
||||||
|
CASAN_PROFILE=development
|
||||||
|
CASAN_CP_AUTH_MODE=local
|
||||||
|
CP_BIND=127.0.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
For short-lived compatibility testing only, H2 can be bypassed explicitly:
|
||||||
|
|
||||||
|
```text
|
||||||
|
CASAN_H2_REGISTRY=0
|
||||||
|
CASAN_AGENTIC_H2_REGISTRY=0
|
||||||
|
```
|
||||||
|
|
||||||
|
The bypass is high-severity, observe-only/non-certifiable and rejected in
|
||||||
|
production. Prefer registering the tool instead of retaining this setting.
|
||||||
|
|
||||||
|
Failure handling can be selected with
|
||||||
|
`CASAN_SINGLE_STEP_FAILURE_POLICY=halt|quarantine|require_approval|record_only`.
|
||||||
|
Production side effects reject `record_only` and fail closed to `halt`.
|
||||||
|
|
||||||
|
## Production Control Plane migration
|
||||||
|
|
||||||
|
1. Export the enterprise IdP's RS256 public key.
|
||||||
|
2. Configure issuer, audience, claim names and bounded clock skew in
|
||||||
|
`runtime.env` using `infra/production/runtime.env.example`.
|
||||||
|
3. Configure oauth2-proxy to pass access tokens and authorization headers.
|
||||||
|
4. Set `CASAN_CP_IDP_PUBLIC_KEY` in `casan-prod.env` for the read-only mount.
|
||||||
|
5. Add a digest-pinned `CASAN_CP_OAUTH2_PROXY_IMAGE`.
|
||||||
|
6. Remove `CASAN_CP_TRUST_AUTH_PROXY` from every environment.
|
||||||
|
7. Configure Vault/KMS and Object Lock provider variables.
|
||||||
|
8. Run `production-preflight.sh` before starting Compose.
|
||||||
|
9. Run managed endpoint smoke with an authenticated enterprise session.
|
||||||
|
|
||||||
|
The current API loads a static public key at startup. Coordinate IdP rotation by
|
||||||
|
installing the next public key and restarting the API after token overlap has
|
||||||
|
been handled operationally. Automated JWKS rollover is not yet implemented.
|
||||||
|
|
||||||
|
## Adapter migration
|
||||||
|
|
||||||
|
The native harness and Agentic Bridge already dual-emit to
|
||||||
|
`CASAN_STATE_ROOT/logs/kernel/`. Existing consumers can keep reading legacy
|
||||||
|
records. New consumers should:
|
||||||
|
|
||||||
|
1. Require `schema_version=1.0.0`.
|
||||||
|
2. Run the common verifier.
|
||||||
|
3. Respect the four separate outcome fields.
|
||||||
|
4. Reject observed-only certification.
|
||||||
|
5. Treat runtime extensions as optional namespaced data.
|
||||||
|
6. Avoid certifying legacy runs that have no canonical evidence.
|
||||||
|
|
||||||
|
For a new runtime, implement an adapter and pass the complete 10-invariant
|
||||||
|
conformance matrix before advertising kernel conformance.
|
||||||
|
|
||||||
|
## Audit compatibility
|
||||||
|
|
||||||
|
New H5 audit records use v2 hashing with action class and risk factors. The
|
||||||
|
audit verifier and head signer support v1 and v2 records. This is compatibility,
|
||||||
|
not claim upgrading: v1 records do not gain v2 risk evidence retroactively.
|
||||||
|
|
||||||
|
## Verification commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 packages/casan-harness/tests/assurance-kernel-tests.py
|
||||||
|
python3 packages/casan-harness/tests/assurance-upgrade-integration-tests.py
|
||||||
|
bash packages/casan-harness/tests/phase-assurance-upgrade-tests.sh
|
||||||
|
bash packages/casan-harness/tests/phase-agentic-bridge-tests.sh
|
||||||
|
npm --workspace packages/casan-control-panel/backend test
|
||||||
|
npm --workspace packages/casan-control-panel/backend run build
|
||||||
|
npm --workspace packages/casan-control-panel/frontend run build
|
||||||
|
bash packages/casan-harness/tests/phase-production-preflight-tests.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Redirect `CASAN_STATE_ROOT` to a temporary directory for legacy suites that
|
||||||
|
write runtime evidence.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Code rollback can restore the previous adapters because legacy emission remains
|
||||||
|
present. Before rollback:
|
||||||
|
|
||||||
|
1. Stop new runs and preserve canonical/audit evidence.
|
||||||
|
2. Record the last v2 audit head and external anchor.
|
||||||
|
3. Roll back API, UI, Nginx and oauth2-proxy as one deployment unit.
|
||||||
|
4. Do not restore trusted-header identity on a networked deployment.
|
||||||
|
5. Do not disable production registry, isolation or trust-root requirements.
|
||||||
|
6. If operational continuity requires emergency local trust, use the explicit
|
||||||
|
break-glass override, retain the critical evidence and do not certify runs.
|
||||||
|
|
||||||
|
A rollback that reintroduces production header trust, missing-gate allow, or
|
||||||
|
silent sandbox/trust fallback is not a safe compatibility rollback.
|
||||||
|
|
||||||
|
## Expected warnings
|
||||||
|
|
||||||
|
- Development registry bypass: high severity and non-certifiable.
|
||||||
|
- Missing H2 in observe mode: degraded assurance.
|
||||||
|
- Local signer/local ledger: permitted locally, untrusted for production.
|
||||||
|
- Unsupported runtime intervention: explicit `unsupported`.
|
||||||
|
- Legacy evidence without kernel envelope: compatibility-readable but not
|
||||||
|
kernel-validated.
|
||||||
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 70 KiB |
@@ -0,0 +1,52 @@
|
|||||||
|
# Core-only commercial product audit
|
||||||
|
|
||||||
|
Audit date: 2026-07-29
|
||||||
|
Reference project: `Basic Design (Screen&Report)_v2.7`
|
||||||
|
|
||||||
|
## Problem reproduced
|
||||||
|
|
||||||
|
The previous empty-state overview displayed READY and operational-looking
|
||||||
|
zeroes before any governed run existed. Core, Domain Pipeline, and Provider
|
||||||
|
Telemetry were not separated, so a customer could not tell which capability
|
||||||
|
was installed, optional, or waiting for evidence.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
|
||||||
|
- `00-init-empty-state.png`
|
||||||
|
- `01-assurance-overview.png`
|
||||||
|
- `02-run-dossier-h1-h7.png`
|
||||||
|
- `03-h6-trace-filtered.png`
|
||||||
|
|
||||||
|
## Corrected product contract
|
||||||
|
|
||||||
|
- `casan init --edition core` writes `.casan/discovery.json` and
|
||||||
|
`.casan/readiness.json`.
|
||||||
|
- Core can be `ready` while Domain Pipeline is `not_configured`/`discovered`
|
||||||
|
and Provider Telemetry is `optional_unavailable`.
|
||||||
|
- The overview hides operational KPIs until a governed run exists.
|
||||||
|
- The empty H6 view hides failure, latency, token, cost, coverage, chart and
|
||||||
|
export controls rather than displaying fabricated zeroes.
|
||||||
|
- HTML/JSON export remains on-demand.
|
||||||
|
- `casan pipeline --help` never executes a pipeline; a missing project runner
|
||||||
|
returns `CASAN_PIPELINE_NOT_CONFIGURED` without marking Core failed.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
|
||||||
|
- `04-commercial-readiness-empty-state.png`
|
||||||
|
- `05-commercial-h6-empty-state.png`
|
||||||
|
- `06-basic-design-commercial-overview.png`
|
||||||
|
- `07-basic-design-commercial-h6.png`
|
||||||
|
|
||||||
|
## Acceptance evidence
|
||||||
|
|
||||||
|
- clean Core-only vendored init: Core `ready`, Domain Pipeline
|
||||||
|
`not_configured`, Provider Telemetry `optional_unavailable`;
|
||||||
|
- no application source or `apps/` directory created by Core init;
|
||||||
|
- Basic Design discovery: Core `ready_with_attention` because Claude/Codex are
|
||||||
|
healthy while the selected VS Code route lacks its extension; Domain
|
||||||
|
Pipeline `discovered`; Provider Telemetry `available`;
|
||||||
|
- Basic Design H1–H7 domain smoke: seven controls passed;
|
||||||
|
- Inspect latest run opens the interactive H1–H7 dossier;
|
||||||
|
- Open H6 opens the trace-filtered AgentOps dossier;
|
||||||
|
- Core and DevKit release artifacts build and pass extracted-artifact smoke;
|
||||||
|
- hybrid install suite: 104 pass, 0 fail.
|
||||||
@@ -13,6 +13,7 @@ sh install.sh --level devkit
|
|||||||
cd /path/to/my-project
|
cd /path/to/my-project
|
||||||
casan init --project ticketing --client claude,codex
|
casan init --project ticketing --client claude,codex
|
||||||
casan doctor
|
casan doctor
|
||||||
|
casan readiness --refresh
|
||||||
casan verify-harness
|
casan verify-harness
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -20,6 +21,27 @@ The project defaults to Level 1/Core with runtime mode `managed`. Core remains
|
|||||||
under `$CASAN_HOME`; the repository receives `.casan` config/lock/bootstrap and
|
under `$CASAN_HOME`; the repository receives `.casan` config/lock/bootstrap and
|
||||||
the selected client hooks. The CLI output states the resolved runtime path.
|
the selected client hooks. The CLI output states the resolved runtime path.
|
||||||
|
|
||||||
|
After any prompt, visual assurance is available from Core itself:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
casan report latest
|
||||||
|
casan view
|
||||||
|
```
|
||||||
|
|
||||||
|
The viewer is single-project, offline-capable and read-only. It starts lazily on
|
||||||
|
`127.0.0.1`, so projects do not need Platform, Node/npm or an always-running
|
||||||
|
service merely to inspect H1–H7 and H6.
|
||||||
|
|
||||||
|
The dashboard and CLI expose three independent states:
|
||||||
|
|
||||||
|
- Core: installation acceptance and client activation;
|
||||||
|
- Domain Pipeline: optional project-specific SRS→test configuration;
|
||||||
|
- Provider Telemetry: optional model/provider token and cost coverage.
|
||||||
|
|
||||||
|
`not_configured` Domain Pipeline and `optional_unavailable` Provider Telemetry
|
||||||
|
do not block Core. `casan report export` is on-demand and must not run after
|
||||||
|
every prompt.
|
||||||
|
|
||||||
Use this for developer workstations and managed CI runners. CI must install the
|
Use this for developer workstations and managed CI runners. CI must install the
|
||||||
same release recorded by `.casan/version.lock` before running gates.
|
same release recorded by `.casan/version.lock` before running gates.
|
||||||
|
|
||||||
@@ -88,6 +110,8 @@ bin/casan pipeline --manifest apps/<project>/domain/project.manifest.json
|
|||||||
- **Project lock/config** → `.casan/config.json` and `.casan/version.lock`.
|
- **Project lock/config** → `.casan/config.json` and `.casan/version.lock`.
|
||||||
- **Your domain data** → `apps/<project>/domain/` (via `CASAN_DOMAIN_ROOT`).
|
- **Your domain data** → `apps/<project>/domain/` (via `CASAN_DOMAIN_ROOT`).
|
||||||
- **Runtime state** → `.specify/` (logs, audit, governance — created on first run).
|
- **Runtime state** → `.specify/` (logs, audit, governance — created on first run).
|
||||||
|
- **Local viewer state** → `.specify/state/local-viewer.json` (`0600` on POSIX,
|
||||||
|
project ACL on Windows; ephemeral loopback port/session token; removed on stop).
|
||||||
The bootstrap resolves the mode/path from the project lock and verifies the live
|
The bootstrap resolves the mode/path from the project lock and verifies the live
|
||||||
Core hash before dispatch.
|
Core hash before dispatch.
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ contents + maturity: [`packaging/levels.json`](../../packaging/levels.json).
|
|||||||
|
|
||||||
| Edition | Package names | Status | What it is |
|
| Edition | Package names | Status | What it is |
|
||||||
|---|---|:--:|---|
|
|---|---|:--:|---|
|
||||||
| **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 |
|
| **1 — Core Harness** | `casan-core`, `casan-harness` | ✅ implemented | Minimal H1–H7 production runtime, native Codex/Claude operator façade, plus zero-dependency Local Assurance Viewer: latest/history, interactive H1–H7, H6 and on-demand export |
|
||||||
| **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. |
|
||||||
@@ -42,6 +42,12 @@ 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`.
|
||||||
|
|
||||||
|
Core and every cumulative bundle also carry `.codex-plugin/`, `.claude-plugin/`
|
||||||
|
and `skills/casan/`. These files expose an optional native `$casan` operator
|
||||||
|
surface. They do not install the shared runtime or enable project hooks; the
|
||||||
|
production trust path remains global/vendored runtime installation followed by
|
||||||
|
`casan init` and client hook review.
|
||||||
|
|
||||||
## Production runtime boundary
|
## Production runtime boundary
|
||||||
|
|
||||||
`packaging/runtime-layout.json` is the canonical allowlist used by macOS/Linux,
|
`packaging/runtime-layout.json` is the canonical allowlist used by macOS/Linux,
|
||||||
@@ -53,7 +59,7 @@ Production Core/DevKit artifacts never include:
|
|||||||
- internal CI/adversarial runners (`ci-harness-gate.sh`, `security-gate.sh`,
|
- internal CI/adversarial runners (`ci-harness-gate.sh`, `security-gate.sh`,
|
||||||
`test-integrity.py`, cloud smoke);
|
`test-integrity.py`, cloud smoke);
|
||||||
- the legacy `packages/casan-harness/level5/` directory;
|
- the legacy `packages/casan-harness/level5/` directory;
|
||||||
- Platform-only dashboard/local-infrastructure helpers;
|
- Platform-only centralized Control Plane/local-infrastructure helpers;
|
||||||
- source documentation, installer copies, Dockerfiles, caches, or release tooling
|
- source documentation, installer copies, Dockerfiles, caches, or release tooling
|
||||||
inside the installed version directory.
|
inside the installed version directory.
|
||||||
|
|
||||||
@@ -63,10 +69,11 @@ its tests so CASAN itself remains verifiable; only the install/release boundary
|
|||||||
minimal.
|
minimal.
|
||||||
|
|
||||||
## Who adopts what
|
## Who adopts what
|
||||||
- **Governance-harness-only / BJT initial / CI gate** → `casan-core`.
|
- **Governance-harness-only / BJT initial / CI gate / single-project visual assurance** → `casan-core`.
|
||||||
- **New project adopting CASAN** → `casan-devkit` (install.sh scaffolds domain + CI).
|
- **New project adopting CASAN** → `casan-devkit` (install.sh scaffolds domain + CI).
|
||||||
- **Want dashboards/visibility** → `casan-platform` (preview; live H1–H7,
|
- **Want centralized multi-project operations, RBAC and approvals** →
|
||||||
H6 coverage and on-demand run/H6 exports today).
|
`casan-platform` (preview). Core already provides single-project H1–H7, H6,
|
||||||
|
history and on-demand run/H6 exports.
|
||||||
- **Enterprise governed console** → future; building blocks (RBAC/tenant/KMS/WORM/approval)
|
- **Enterprise governed console** → future; building blocks (RBAC/tenant/KMS/WORM/approval)
|
||||||
already live in core.
|
already live in core.
|
||||||
|
|
||||||
@@ -76,6 +83,9 @@ packages/casan-harness/ # L1 core (implemented)
|
|||||||
packages/casan-devkit/ # L2 adoption kit (implemented)
|
packages/casan-devkit/ # L2 adoption kit (implemented)
|
||||||
packages/casan-platform/ # L3 structure-only (preview; README)
|
packages/casan-platform/ # L3 structure-only (preview; README)
|
||||||
packages/casan-enterprise/ # L4 structure-only (future; README)
|
packages/casan-enterprise/ # L4 structure-only (future; README)
|
||||||
|
skills/casan/ # native operator skill shared by Codex + Claude
|
||||||
|
.codex-plugin/ # Codex plugin manifest
|
||||||
|
.claude-plugin/ # Claude plugin + marketplace manifests
|
||||||
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)
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# CASAN Commercial Readiness Contract
|
||||||
|
|
||||||
|
## Promise
|
||||||
|
|
||||||
|
After `casan init`, a repository can use CASAN Core prompt assurance and the
|
||||||
|
Local Assurance Viewer without modifying application source, installing a
|
||||||
|
Domain Pack, running a domain pipeline, or exporting HTML after every prompt.
|
||||||
|
|
||||||
|
CASAN never turns missing evidence into zero and never calls an installation
|
||||||
|
READY when a required client activation step remains.
|
||||||
|
|
||||||
|
## State model
|
||||||
|
|
||||||
|
| Dimension | Ready state | Non-blocking state | Owner |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Core | `ready`, `ready_with_attention` | — | CASAN runtime + selected client |
|
||||||
|
| Domain Pipeline | `configured` | `discovered`, `not_configured` | Project team |
|
||||||
|
| Provider Telemetry | `available` | `optional_unavailable` | Client/provider adapter |
|
||||||
|
|
||||||
|
Core installation acceptance uses H1–H7 as an installation contract:
|
||||||
|
|
||||||
|
1. H1 project configuration exists and is valid;
|
||||||
|
2. H2 selected client integration is installed and trust is explicit;
|
||||||
|
3. H3 local report capability exists in the resolved runtime;
|
||||||
|
4. H4 enforcement mode is disclosed (`observe` is a warning);
|
||||||
|
5. H5 evidence directories are writable;
|
||||||
|
6. H6 provider token/cost telemetry is measured when available and explicitly
|
||||||
|
optional when unavailable;
|
||||||
|
7. H7 bootstrap and version/hash pin exist.
|
||||||
|
|
||||||
|
When at least one selected client route is operational but another selected
|
||||||
|
route fails the deep doctor check, Core is `ready_with_attention`: reports and
|
||||||
|
governed prompts remain usable through the healthy route, while the broken
|
||||||
|
route is shown as an explicit activation action.
|
||||||
|
|
||||||
|
These checks do not claim that a real prompt run is H1–H7 certified. Run
|
||||||
|
certification is created only from canonical lifecycle evidence after a prompt.
|
||||||
|
|
||||||
|
## CASAN-owned files
|
||||||
|
|
||||||
|
- `.casan/discovery.json`: bounded read-only discovery projection;
|
||||||
|
- `.casan/readiness.json`: shared CLI/viewer state;
|
||||||
|
- `.casan/domain.json`: optional reference to a project-owned manifest.
|
||||||
|
|
||||||
|
No command above creates requirements, traceability mappings, tests, source
|
||||||
|
files or a fake runnable pipeline.
|
||||||
|
|
||||||
|
## Operator path
|
||||||
|
|
||||||
|
```bash
|
||||||
|
casan init
|
||||||
|
casan doctor
|
||||||
|
casan readiness --refresh
|
||||||
|
casan view
|
||||||
|
|
||||||
|
# Optional, only for project-specific SRS→test acceptance
|
||||||
|
casan domain discover
|
||||||
|
casan domain configure path/to/project.manifest.json
|
||||||
|
casan pipeline --help
|
||||||
|
```
|
||||||
|
|
||||||
|
`casan pipeline` runs only when the project provides
|
||||||
|
`scripts/run-casan-pipeline.mjs`. Otherwise it returns
|
||||||
|
`CASAN_PIPELINE_NOT_CONFIGURED` while leaving Core fully usable.
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# CASAN Core Local Assurance Viewer
|
||||||
|
|
||||||
|
## Product decision
|
||||||
|
|
||||||
|
A project that installs only CASAN Core can inspect production-quality visual
|
||||||
|
reports. Platform is not a prerequisite for basic visibility.
|
||||||
|
|
||||||
|
Core owns the single-project review loop:
|
||||||
|
|
||||||
|
- commercial readiness split into Core, Domain Pipeline, and Provider Telemetry;
|
||||||
|
- latest assurance receipt and run history;
|
||||||
|
- interactive H1→H7 evidence spine;
|
||||||
|
- H6 runtime, reliability, token/cost coverage, freshness and findings;
|
||||||
|
- loading, empty, error, legacy and partial-telemetry states;
|
||||||
|
- self-contained HTML and machine-auditable JSON export on demand.
|
||||||
|
|
||||||
|
Platform remains the centralized operations layer: multi-project fleet views,
|
||||||
|
organization RBAC, shared approval queues, remote ingestion, governed settings
|
||||||
|
and managed retention.
|
||||||
|
|
||||||
|
## Lifecycle
|
||||||
|
|
||||||
|
```text
|
||||||
|
Prompt finishes
|
||||||
|
└─ hook writes canonical trace/events/H6 + latest-run receipt
|
||||||
|
└─ no server, no HTML export, no second model call
|
||||||
|
|
||||||
|
Developer runs `casan view`
|
||||||
|
└─ Core starts or reuses a loopback viewer
|
||||||
|
├─ reads bounded evidence projections
|
||||||
|
├─ opens latest/specified trace
|
||||||
|
└─ generates HTML/JSON only when Export is selected
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not run `casan report export` after every prompt. Export is an independent
|
||||||
|
review artifact, not the evidence source of truth.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
casan report latest
|
||||||
|
casan readiness --refresh
|
||||||
|
casan view [trace-id] [--no-open]
|
||||||
|
|
||||||
|
casan report export [trace-id] --format html|json [-o path]
|
||||||
|
casan report export --h6 --format html|json [-o path]
|
||||||
|
|
||||||
|
casan dashboard start [--port 0]
|
||||||
|
casan dashboard open
|
||||||
|
casan dashboard status
|
||||||
|
casan dashboard stop
|
||||||
|
```
|
||||||
|
|
||||||
|
When Platform code is installed, `casan dashboard` manages the centralized
|
||||||
|
Control Plane. `casan view` remains the predictable Core single-project viewer.
|
||||||
|
|
||||||
|
An empty project must not show operational zeroes or a fabricated READY
|
||||||
|
verdict. Before the first governed prompt, the overview shows installation
|
||||||
|
readiness and actionable onboarding only. Operational KPIs appear after
|
||||||
|
canonical run evidence exists.
|
||||||
|
|
||||||
|
## Runtime and packaging contract
|
||||||
|
|
||||||
|
- Python standard library only; no Node/npm or network dependency.
|
||||||
|
- Static HTML/CSS/JS ships inside `casan-core`.
|
||||||
|
- System fonts only; no CDN, analytics or external asset requests.
|
||||||
|
- Managed and vendored Core use the same runtime allowlist.
|
||||||
|
- Runtime tests and source-only tooling do not cross the release boundary.
|
||||||
|
- Evidence remains under the project's `.specify/` tree.
|
||||||
|
|
||||||
|
## Security model
|
||||||
|
|
||||||
|
- binds only to `127.0.0.1` on an ephemeral port by default;
|
||||||
|
- rejects non-loopback clients and non-loopback `Host` headers;
|
||||||
|
- requires a high-entropy session token for every API/export request;
|
||||||
|
- stores viewer state as `0600` on POSIX and under the project ACL on Windows;
|
||||||
|
- exposes GET-only evidence APIs; mutation verbs return `405`;
|
||||||
|
- sends CSP, `no-store`, `nosniff`, frame denial and no-referrer headers;
|
||||||
|
- validates trace/project filters and never resolves a request path as a file;
|
||||||
|
- bounds source reads, record counts and response size;
|
||||||
|
- rotates the non-evidence request log at 1 MiB and never logs the session token;
|
||||||
|
- redacts prompt bodies, credentials, tool input/output and authorization data;
|
||||||
|
- keeps missing token/cost values `null`, never fabricated as zero.
|
||||||
|
|
||||||
|
The UI shell itself is non-sensitive and may load without a token. All project
|
||||||
|
metadata, evidence and exports require the session token.
|
||||||
|
|
||||||
|
## Presentation and disclosure policy
|
||||||
|
|
||||||
|
Reports use two information layers:
|
||||||
|
|
||||||
|
1. the executive layer shows verdict, governed volume, reliability, latency,
|
||||||
|
evidence quality, decision findings and measured distributions;
|
||||||
|
2. the technical layer preserves filters, provider counters, canonical paths,
|
||||||
|
event history, sanitized manifests and raw breakdown tables behind native
|
||||||
|
disclosure controls.
|
||||||
|
|
||||||
|
The technical layer is collapsed by default in interactive viewers. It remains
|
||||||
|
available for investigation and is included in print-ready export appendices.
|
||||||
|
This is progressive disclosure, not evidence deletion.
|
||||||
|
|
||||||
|
Every visual must be derived from the report contract. Missing token, cost,
|
||||||
|
freshness or provenance values remain unavailable; the UI must not invent a
|
||||||
|
zero, trend, governance verdict or maturity claim. Charts include visible
|
||||||
|
labels and values so meaning does not depend on color alone.
|
||||||
|
|
||||||
|
## Production acceptance gate
|
||||||
|
|
||||||
|
The Core viewer is releasable only when all of these pass:
|
||||||
|
|
||||||
|
1. unit contracts for empty, legacy, partial, certified and unsafe-input states;
|
||||||
|
2. clean-project test from the built `casan-core` tarball;
|
||||||
|
3. no `packages/casan-control-panel`, `package.json` or Node dependency in Core;
|
||||||
|
4. unauthorized API, mutation and Host-header checks fail closed;
|
||||||
|
5. run/H6 HTML and JSON export work offline;
|
||||||
|
6. browser validation covers the report hierarchy, interaction and responsive
|
||||||
|
navigation;
|
||||||
|
7. existing harness, DevKit installation and packaging suites remain green.
|
||||||
|
8. a clean `casan init --edition core` project reports Core ready without
|
||||||
|
requiring a Domain Pack, provider token/cost telemetry, or app-source edits.
|
||||||
|
|
||||||
|
## Maturity statement
|
||||||
|
|
||||||
|
This viewer improves evidence usability; it does not grant CASAN Maturity L4.
|
||||||
|
Maturity is assessed from real operational evidence, ownership, controls and
|
||||||
|
repeatability. The interface must show the recorded maturity status and must
|
||||||
|
not infer a level from the installed edition.
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
# CASAN Domain Pack Guide
|
# CASAN Domain Pack Guide
|
||||||
|
|
||||||
A **Domain Pack** is the per-project data the harness needs to govern YOUR domain. It lives
|
A **Domain Pack** is optional per-project data the harness needs for a
|
||||||
at `apps/<project>/domain/` and is selected via `CASAN_DOMAIN_ROOT`. The harness code
|
project-specific SRS→test pipeline. Core prompt assurance and visual reporting
|
||||||
|
do not require it. A pack commonly lives at `apps/<project>/domain/`. The harness code
|
||||||
(`packages/casan-harness/`) never contains domain data — this is what makes it reusable.
|
(`packages/casan-harness/`) never contains domain data — this is what makes it reusable.
|
||||||
|
|
||||||
Scaffold template: `packages/casan-devkit/templates/domain-pack/`.
|
Scaffold template: `packages/casan-devkit/templates/domain-pack/`.
|
||||||
@@ -39,9 +40,18 @@ apps/<project>/domain/
|
|||||||
|
|
||||||
## Wire it up
|
## Wire it up
|
||||||
```bash
|
```bash
|
||||||
|
casan domain discover
|
||||||
|
casan domain configure apps/<project>/domain/project.manifest.json
|
||||||
|
casan domain status
|
||||||
|
|
||||||
|
# Existing automation remains supported
|
||||||
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
|
||||||
```
|
```
|
||||||
|
`casan domain configure` writes only `.casan/domain.json`; it references an
|
||||||
|
existing in-project manifest and never copies, rewrites or invents application
|
||||||
|
requirements, source mappings or tests.
|
||||||
|
|
||||||
`domain_root` is also recorded per-project in `packages/casan-harness/config/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.
|
||||||
|
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ CASAN Maturity L1–L5 describes evidence-backed operational adoption.
|
|||||||
| Gitea CI gate template | — | Included | Included | — |
|
| Gitea CI gate template | — | Included | Included | — |
|
||||||
| Control Panel: runs, governance, security, cost, approvals | — | — | Included | Not a shipped edition |
|
| Control Panel: runs, governance, security, cost, approvals | — | — | Included | Not a shipped edition |
|
||||||
| Prompt assurance receipt + latest-run discovery | Included | Included | Included | Building blocks only |
|
| Prompt assurance receipt + latest-run discovery | Included | Included | Included | Building blocks only |
|
||||||
| Clickable trace deep link when enrolled | Included | Included | Included | Building blocks only |
|
| Core Local Assurance Viewer (single project, offline, read-only) | Included | Included | Included | Building blocks only |
|
||||||
| One-command local Control Plane launcher | — | — | Included | Not a shipped edition |
|
| H1–H7 assurance rail + per-run HTML/JSON export | Included | Included | Included | Building blocks only |
|
||||||
| H1–H7 live assurance rail + per-run HTML/JSON export | — | — | Included | Not a shipped edition |
|
| H6 AgentOps dossier + history + filters + HTML/JSON export | Included | Included | Included | Building blocks only |
|
||||||
|
| Clickable local trace deep link; central link when enrolled | Included | Included | Included | Building blocks only |
|
||||||
|
| One-command centralized Control Plane launcher | — | — | Included | Not a shipped edition |
|
||||||
| Async HMAC telemetry delivery with durable local spool | Included | Included | Included | Building blocks only |
|
| Async HMAC telemetry delivery with durable local spool | Included | Included | Included | Building blocks only |
|
||||||
| Evidence Pack Viewer | — | — | Included | Not a shipped edition |
|
| Evidence Pack Viewer | — | — | Included | Not a shipped edition |
|
||||||
| Governed chat/operator/codegen MVP | — | — | Included, preview | Not a shipped edition |
|
| Governed chat/operator/codegen MVP | — | — | Included, preview | Not a shipped edition |
|
||||||
@@ -31,11 +33,13 @@ CASAN Maturity L1–L5 describes evidence-backed operational adoption.
|
|||||||
## Edition rules
|
## Edition rules
|
||||||
|
|
||||||
- **Core** is the first paid-PoC product: use it with the customer’s existing
|
- **Core** is the first paid-PoC product: use it with the customer’s existing
|
||||||
AI coding tools and CI. It is self-hosted and evidence-oriented.
|
AI coding tools and CI. It includes a production-quality single-project
|
||||||
|
Local Assurance Viewer without Node/npm or a Platform dependency.
|
||||||
- **DevKit** adds repeatable adoption material. It does not add a managed
|
- **DevKit** adds repeatable adoption material. It does not add a managed
|
||||||
service or a security certification.
|
service or a security certification.
|
||||||
- **Platform Preview** includes the Control Panel and the Evidence Pack Viewer,
|
- **Platform Preview** includes the Control Panel and Evidence Pack Viewer,
|
||||||
but is not production enterprise software. The bundle has a
|
plus centralized multi-project operations, RBAC and approvals. It is not
|
||||||
|
production enterprise software. The bundle has a
|
||||||
`PREVIEW-INCOMPLETE.txt` marker by design.
|
`PREVIEW-INCOMPLETE.txt` marker by design.
|
||||||
- **Enterprise** remains `future` and `package-release.sh enterprise` refuses
|
- **Enterprise** remains `future` and `package-release.sh enterprise` refuses
|
||||||
to generate an artifact. Do not advertise it as a purchasable edition.
|
to generate an artifact. Do not advertise it as a purchasable edition.
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
| **04 Self-improve** | � core done+test | `packages/casan-harness/scripts/bash/self-improve.py`: `propose` đọc metrics/drift → proposal dry-run (không ghi); `apply` bắt buộc approval, áp qua governed store (audit); sensitive/loosen luôn cần duyệt. `phase-selfimprove-tests.sh` 7/0 (WSL), nối CI. Còn: luật đề xuất phong phú hơn (corpus/model escalation), chạy định kỳ CI (05). |
|
| **04 Self-improve** | � core done+test | `packages/casan-harness/scripts/bash/self-improve.py`: `propose` đọc metrics/drift → proposal dry-run (không ghi); `apply` bắt buộc approval, áp qua governed store (audit); sensitive/loosen luôn cần duyệt. `phase-selfimprove-tests.sh` 7/0 (WSL), nối CI. Còn: luật đề xuất phong phú hơn (corpus/model escalation), chạy định kỳ CI (05). |
|
||||||
| **05 CI/CD** | 🟡 CI gate MVP done | `packages/casan-harness/scripts/bash/ci-harness-gate.sh` chạy các suite harness/hardening/sourcegen/traceability/frontend theo thứ tự an toàn, có timeout/filter; `.gitea/workflows/harness-ci.yml` gọi gate trên push/PR. Filtered local verify PASS=2/0. Còn: full gate xanh trên runner thật, xử lý A6 nếu còn chậm/treo, bật Docker infra lab nếu runner hỗ trợ, package/release artifact `fpt-casan-sdd-harness`. |
|
| **05 CI/CD** | 🟡 CI gate MVP done | `packages/casan-harness/scripts/bash/ci-harness-gate.sh` chạy các suite harness/hardening/sourcegen/traceability/frontend theo thứ tự an toàn, có timeout/filter; `.gitea/workflows/harness-ci.yml` gọi gate trên push/PR. Filtered local verify PASS=2/0. Còn: full gate xanh trên runner thật, xử lý A6 nếu còn chậm/treo, bật Docker infra lab nếu runner hỗ trợ, package/release artifact `fpt-casan-sdd-harness`. |
|
||||||
| **06 Onboard dự án 2** | 📋 chưa bắt đầu (🔓 **đã mở khoá** — 01 done) | Chứng minh reuse: cắm 1 repo khác + golden/corpus/input, đăng ký qua `verify-harness-reuse.sh`, không sửa gate. Phụ thuộc 01 ✅. App mới chỉ cần `apps/<project>/domain/` + set `CASAN_DOMAIN_ROOT` (đã có `domain_root` per-project trong `project-registry.json`). |
|
| **06 Onboard dự án 2** | 📋 chưa bắt đầu (🔓 **đã mở khoá** — 01 done) | Chứng minh reuse: cắm 1 repo khác + golden/corpus/input, đăng ký qua `verify-harness-reuse.sh`, không sửa gate. Phụ thuộc 01 ✅. App mới chỉ cần `apps/<project>/domain/` + set `CASAN_DOMAIN_ROOT` (đã có `domain_root` per-project trong `project-registry.json`). |
|
||||||
| **08 Context compression** | � MVP done+test | **CASAN-native token-killer** (Track 3) đã có: `context-compress.py` (dedup/extractive/structural, must-keep, tee, gate fail-able), `phase08-compression-tests.sh` 7/0 (WSL), nối CI. Còn: Track 1 nén INPUT + Track 2 nén VIEW liên-bước + Track 4 abstractive (gated) + nối H4/H5 trong pipeline thật. |
|
| **08 Context compression** | 🟡 Track 3 hardened+test | **CASAN-native token-killer** đã có: severity `[ERR]/[WRN]`, multiplicity-preserving must-keep gate, raw fallback/halt fail-safe, hash-bound JSON evidence, estimate/provider labeling; `phase08-compression-tests.sh` 19/0, SEC-09 7/0, loop consumer 16/0. Log thật NEHOPS giữ 4/4 warning; 1.620→76 chỉ là whitespace estimate, chưa phải ROI claim. Còn: Track 1 INPUT + Track 2 VIEW + Track 4 abstractive + H4/H5 pipeline binding + A/B provider telemetry. |
|
||||||
| **12 Domain Pack SDK** | 📋 chưa bắt đầu (🔓 01 done, còn chờ 06) | Onboard bằng khai báo (golden/corpus/policy theo domain). Phụ thuộc 01 ✅, 06. |
|
| **12 Domain Pack SDK** | 📋 chưa bắt đầu (🔓 01 done, còn chờ 06) | Onboard bằng khai báo (golden/corpus/policy theo domain). Phụ thuộc 01 ✅, 06. |
|
||||||
| **01 Restructure** | ✅ **done+test (2026-07-08, merged main)** | Đã tách: harness code → `packages/casan-harness/`, domain OKR → `apps/okr/domain/`, runtime state ở `.specify/`; **app promote lên git root** (hết wrapper `AINative_OKR_CASAN5/`); facade `.specify` symlink **gỡ sạch** (hard cutoff); path resolve qua `casan-paths.sh` (marker walk-up). Full gate **PASS=64 FAIL=0 SKIP=3** từ cấu trúc mới. CI (`.gitea/workflows/ci.yml`) + docs đã đồng bộ. Mở khoá 06/12. Chi tiết: `CASAN_PLAN_01_RESTRUCTURE.md`. |
|
| **01 Restructure** | ✅ **done+test (2026-07-08, merged main)** | Đã tách: harness code → `packages/casan-harness/`, domain OKR → `apps/okr/domain/`, runtime state ở `.specify/`; **app promote lên git root** (hết wrapper `AINative_OKR_CASAN5/`); facade `.specify` symlink **gỡ sạch** (hard cutoff); path resolve qua `casan-paths.sh` (marker walk-up). Full gate **PASS=64 FAIL=0 SKIP=3** từ cấu trúc mới. CI (`.gitea/workflows/ci.yml`) + docs đã đồng bộ. Mở khoá 06/12. Chi tiết: `CASAN_PLAN_01_RESTRUCTURE.md`. |
|
||||||
| **13 Control Plane** | 🟡 core + **Track 1/2/3 + FinOps/SLO + Command Center + local-prod TLS/OIDC smoke** done+test | **Sửa kiến trúc: là tài sản harness, KHÔNG nằm trong OKR.** Governance core đã dời vào harness: `packages/casan-harness/scripts/bash/control-plane-settings.py` (settings versioned + audit hash-chain + deny-by-default + approval + rollback), `phase-control-plane-tests.sh` 9/0 + `phase-control-plane-hitl-tests.sh` 9/0 (nối CI). Đã gỡ khỏi `apps/okr` (OKR sạch: 46/0/3skip + 16/16). **Track 1 DONE**: Ops Console read-only telemetry. **Track 2 DONE**: settings API/UI tại `packages/casan-control-panel/` bọc `control-plane-settings.py` + `rbac-check.py`. **Track 3 DONE**: kill-switch API/UI; approval inbox/delegation/oversight API/UI bọc harness `approval-inbox.py` + `delegation-policy.yaml`, SoD, apply setting proposal qua governed store; IdP group claim→RBAC role mapping tested. **Track 4 local-prod DONE**: FinOps/SLO page + Docker/nginx/oauth2-proxy deploy scaffold; `local-prod-smoke.sh` passes `CP_LOCAL_SMOKE_PASS https_oidc=true actor=oidc-ops role=org-admin` and invokes authenticated `managed-prod-smoke.sh` on the local-prod endpoint (`CP_MANAGED_SMOKE_PASS actor=oidc-ops role=org-admin widgets=9`); `prod-readiness-check.sh` validates managed-prod TLS/OIDC/nginx prerequisites. **Command Center §8.6 DONE**: `GET /api/v1/command` + `/command` UI, 9 evidence-backed widgets (gồm `chat_loop`), provenance envelope, executive briefing VI/EN, live ticker, evidence drawer. `npm run console:test` **26/0**, `npm run console:build` xanh. Targeted related suites: RBAC 12/0, C7 15/0. Còn: live managed prod deploy với cert/enterprise IdP/host thật (07 T2); RAI view thuộc Plan-15 follow-up. |
|
| **13 Control Plane** | 🟡 core + **Track 1/2/3 + FinOps/SLO + Command Center + local-prod TLS/OIDC smoke** done+test | **Sửa kiến trúc: là tài sản harness, KHÔNG nằm trong OKR.** Governance core đã dời vào harness: `packages/casan-harness/scripts/bash/control-plane-settings.py` (settings versioned + audit hash-chain + deny-by-default + approval + rollback), `phase-control-plane-tests.sh` 9/0 + `phase-control-plane-hitl-tests.sh` 9/0 (nối CI). Đã gỡ khỏi `apps/okr` (OKR sạch: 46/0/3skip + 16/16). **Track 1 DONE**: Ops Console read-only telemetry. **Track 2 DONE**: settings API/UI tại `packages/casan-control-panel/` bọc `control-plane-settings.py` + `rbac-check.py`. **Track 3 DONE**: kill-switch API/UI; approval inbox/delegation/oversight API/UI bọc harness `approval-inbox.py` + `delegation-policy.yaml`, SoD, apply setting proposal qua governed store; IdP group claim→RBAC role mapping tested. **Track 4 local-prod DONE**: FinOps/SLO page + Docker/nginx/oauth2-proxy deploy scaffold; `local-prod-smoke.sh` passes `CP_LOCAL_SMOKE_PASS https_oidc=true actor=oidc-ops role=org-admin` and invokes authenticated `managed-prod-smoke.sh` on the local-prod endpoint (`CP_MANAGED_SMOKE_PASS actor=oidc-ops role=org-admin widgets=9`); `prod-readiness-check.sh` validates managed-prod TLS/OIDC/nginx prerequisites. **Command Center §8.6 DONE**: `GET /api/v1/command` + `/command` UI, 9 evidence-backed widgets (gồm `chat_loop`), provenance envelope, executive briefing VI/EN, live ticker, evidence drawer. `npm run console:test` **26/0**, `npm run console:build` xanh. Targeted related suites: RBAC 12/0, C7 15/0. Còn: live managed prod deploy với cert/enterprise IdP/host thật (07 T2); RAI view thuộc Plan-15 follow-up. |
|
||||||
|
|||||||
@@ -2,13 +2,19 @@
|
|||||||
|
|
||||||
> Năng lực **nén prompt/context để giảm token · latency · cost** — nhưng **không phá governance**.
|
> Năng lực **nén prompt/context để giảm token · latency · cost** — nhưng **không phá governance**.
|
||||||
>
|
>
|
||||||
> Status 2026-07-06: **🟡 Track 3 MVP implemented + tested (CASAN-native token-killer).**
|
> Status 2026-07-29: **🟡 Track 3 hardened + regression-tested (CASAN-native token-killer).**
|
||||||
> `packages/casan-harness/scripts/bash/context-compress.py` — compressor deterministic của CASAN
|
> `packages/casan-harness/scripts/bash/context-compress.py` — compressor deterministic của CASAN
|
||||||
> (KHÔNG dùng lại RTK): modes `dedup`/`extractive`/`structural`, must-keep luôn giữ,
|
> (KHÔNG dùng lại RTK): modes `dedup`/`extractive`/`structural`; nhận diện severity
|
||||||
> tee raw-passthrough khi lệnh fail, báo `token_saved`/`ratio`, và gate must-keep
|
> `[ERR]/[WRN]`; giữ multiplicity của warning/must-keep; invalid regex/input rỗng sau nén
|
||||||
> fail-able. Test `packages/casan-harness/tests/phase08-compression-tests.sh` **7/0 (WSL)**; đã nối
|
> đều fail-closed; preservation fail mặc định trả RAW + exit 1; có lựa chọn `halt`; JSON
|
||||||
> vào `ci-harness-gate.sh` (`phase08-compression`). Còn: Track 1 nén INPUT + Track 2
|
> evidence bind SHA-256 raw/candidate/output và ghi rõ `whitespace_estimate` khác provider
|
||||||
> nén VIEW liên-bước + Track 4 abstractive (gated) + nối H4/H5 scan/hash trong pipeline thật.
|
> telemetry. Test `packages/casan-harness/tests/phase08-compression-tests.sh` **19/0 (macOS)**;
|
||||||
|
> SEC-09 **7/0**; loop consumer **16/0**; đã nối `ci-harness-gate.sh`
|
||||||
|
> (`phase08-compression`). Log thật NEHOPS `analyzer-20260507.log` đo **1.620 → 76
|
||||||
|
> whitespace-estimate units**, giữ đủ **4/4 `[WRN]`**, preservation pass; con số này vẫn
|
||||||
|
> **không phải provider token saving claim** cho đến khi qua H3 end-to-end/A-B. Còn:
|
||||||
|
> Track 1 nén INPUT + Track 2 nén VIEW liên-bước + Track 4 abstractive (gated) + nối
|
||||||
|
> H4 scan raw/compressed và H5 audit binding trong pipeline thật.
|
||||||
>
|
>
|
||||||
> Nhãn: [có] tồn tại thật · [đo] đã kiểm chứng · [mới] cần làm · [chưa tự động] có đo/người quyết.
|
> Nhãn: [có] tồn tại thật · [đo] đã kiểm chứng · [mới] cần làm · [chưa tự động] có đo/người quyết.
|
||||||
> Phụ thuộc: **01** (đường dẫn sau restructure) · **07** (thứ tự scan/audit an toàn, V19/V5) · **03** (chế độ abstractive dùng model).
|
> Phụ thuộc: **01** (đường dẫn sau restructure) · **07** (thứ tự scan/audit an toàn, V19/V5) · **03** (chế độ abstractive dùng model).
|
||||||
|
|||||||
@@ -10,20 +10,26 @@ This document defines the boundary between the local workstation experience and
|
|||||||
flowchart LR
|
flowchart LR
|
||||||
B["Browser"] -->|TLS + OIDC cookie| N["Nginx"]
|
B["Browser"] -->|TLS + OIDC cookie| N["Nginx"]
|
||||||
N -->|auth_request| O["oauth2-proxy"]
|
N -->|auth_request| O["oauth2-proxy"]
|
||||||
N -->|overwritten identity headers| A["Control Panel API"]
|
N -->|signed bearer token; identity headers removed| A["Control Panel API"]
|
||||||
|
A -->|verify RS256 + issuer + audience + time claims| I["VerifiedClaims"]
|
||||||
A -->|tenant-scoped encrypted store| S["CASAN state"]
|
A -->|tenant-scoped encrypted store| S["CASAN state"]
|
||||||
A -->|random bridge token, local only| H["Mac host bridge"]
|
A -->|random bridge token, local only| H["Mac host bridge"]
|
||||||
H -->|official CLI auth| P["Codex / Claude"]
|
H -->|official CLI auth| P["Codex / Claude"]
|
||||||
```
|
```
|
||||||
|
|
||||||
The API must not be published directly. Nginx is the only ingress and overwrites `X-CASAN-Actor`, `X-CASAN-Groups`, and `X-CASAN-Role`. The API refuses a non-loopback strict bind unless `CASAN_CP_TRUST_AUTH_PROXY=1` is explicit.
|
The API must not be published directly. Nginx is the only ingress and removes
|
||||||
|
`X-CASAN-*` and forwarded identity headers. The API refuses every production or
|
||||||
|
non-loopback bind unless `CASAN_CP_AUTH_MODE=jwt` has a valid cryptographic
|
||||||
|
verifier. Proxy headers alone are never authenticated identity.
|
||||||
|
|
||||||
## Implemented controls
|
## Implemented controls
|
||||||
|
|
||||||
- TLS at Nginx; production accepts only TLS 1.2/1.3.
|
- TLS at Nginx; production accepts only TLS 1.2/1.3.
|
||||||
- OIDC authentication through oauth2-proxy.
|
- OIDC authentication through oauth2-proxy.
|
||||||
- Secure, HttpOnly, SameSite=Lax session cookies with bounded expiry/refresh.
|
- Secure, HttpOnly, SameSite=Lax session cookies with bounded expiry/refresh.
|
||||||
- Browser-supplied identity headers are overwritten at Nginx.
|
- Browser-supplied identity headers are removed at Nginx.
|
||||||
|
- The API verifies RS256 signature, issuer, audience, subject, expiry,
|
||||||
|
not-before/issued-at bounds and clock skew before deriving request identity.
|
||||||
- CSP, frame denial, MIME sniffing protection, referrer and browser permission restrictions.
|
- CSP, frame denial, MIME sniffing protection, referrer and browser permission restrictions.
|
||||||
- API request body capped at 1 MiB.
|
- API request body capped at 1 MiB.
|
||||||
- SSE buffering disabled for trace streams; no intermediate proxy cache.
|
- SSE buffering disabled for trace streams; no intermediate proxy cache.
|
||||||
@@ -53,21 +59,26 @@ Before production deployment:
|
|||||||
2. Do not deploy `provider-auth-bridge.py`.
|
2. Do not deploy `provider-auth-bridge.py`.
|
||||||
3. Use managed OpenAI/Anthropic credentials from Vault/KMS or workload identity.
|
3. Use managed OpenAI/Anthropic credentials from Vault/KMS or workload identity.
|
||||||
4. Use an enterprise IdP and explicit group-to-role mapping.
|
4. Use an enterprise IdP and explicit group-to-role mapping.
|
||||||
5. Use a CA-issued certificate and a fixed production hostname.
|
5. Mount the IdP RS256 public key and configure exact issuer/audience; coordinate
|
||||||
6. Set a digest-pinned `CASAN_CP_API_IMAGE`, `CASAN_CP_UI_IMAGE`, and `CASAN_CP_OAUTH2_PROXY_IMAGE`.
|
key rotation with an API restart until JWKS rollover is implemented.
|
||||||
7. Keep the API on a private container/network segment with Nginx as its only caller.
|
6. Use a CA-issued certificate and a fixed production hostname.
|
||||||
8. Restrict egress from the API to allowlisted model providers, Vault/KMS, object storage and required observability endpoints.
|
7. Set a digest-pinned `CASAN_CP_API_IMAGE`, `CASAN_CP_UI_IMAGE`, and `CASAN_CP_OAUTH2_PROXY_IMAGE`.
|
||||||
9. Store state on encrypted storage; send audit heads to KMS/WORM/Object Lock.
|
8. Keep the API on a private container/network segment with Nginx as its only caller.
|
||||||
10. Export rate-limit, auth failure, bridge-disabled and audit-chain metrics to alerting.
|
9. Restrict egress from the API to allowlisted model providers, Vault/KMS, object storage and required observability endpoints.
|
||||||
11. Back up and restore-test tenant state before enabling write actions.
|
10. Store state on encrypted storage; send audit heads to KMS/WORM/Object Lock.
|
||||||
12. Run the production preflight and security suites against the exact deployment images.
|
11. Export rate-limit, auth failure, bridge-disabled and audit-chain metrics to alerting.
|
||||||
|
12. Back up and restore-test tenant state before enabling write actions.
|
||||||
|
13. Run the production preflight and security suites against the exact deployment images.
|
||||||
|
|
||||||
## Known residual risks
|
## Known residual risks
|
||||||
|
|
||||||
- Local bridge authorization is bearer-token based; a process with access to the token file can call it.
|
- Local bridge authorization is bearer-token based; a process with access to the token file can call it.
|
||||||
- In-memory rate windows reset when the API or bridge restarts. Production should also rate-limit at ingress.
|
- In-memory rate windows reset when the API or bridge restarts. Production should also rate-limit at ingress.
|
||||||
- Goal output is stored in tenant-scoped local state but is not currently envelope-encrypted as a whole.
|
- Goal output is stored in tenant-scoped local state but is not currently envelope-encrypted as a whole.
|
||||||
- A compromised container on the private Control Panel network may attempt to forge proxy headers. Production network policy must keep unrelated workloads off that network.
|
- Static public-key rotation requires a coordinated file replacement and API
|
||||||
|
restart; automated JWKS rollover is not implemented.
|
||||||
|
- A compromised ingress container can interfere with availability or bearer
|
||||||
|
forwarding, but cannot mint a valid IdP signature without the IdP key.
|
||||||
- Developer account subscriptions have provider-specific quotas and are not an availability SLA.
|
- Developer account subscriptions have provider-specific quotas and are not an availability SLA.
|
||||||
- The local mock IdP and self-signed TLS do not prove enterprise SSO readiness.
|
- The local mock IdP and self-signed TLS do not prove enterprise SSO readiness.
|
||||||
|
|
||||||
@@ -83,4 +94,3 @@ packages/casan-harness/scripts/bash/local-full.sh start
|
|||||||
This terminates the bridge, deletes the old token and creates a new one. Then inspect `tmp/control-panel-local/auth-bridge/model-audit.jsonl` for unexpected provider/status/hash activity. The log contains no raw prompts.
|
This terminates the bridge, deletes the old token and creates a new one. Then inspect `tmp/control-panel-local/auth-bridge/model-audit.jsonl` for unexpected provider/status/hash activity. The log contains no raw prompts.
|
||||||
|
|
||||||
If a goal audit chain is suspected of tampering, stop new goal submissions, preserve `.specify/logs/audit/goal-orchestrator.jsonl` and its head, and compare each `prev_hash`/`record_hash` before restoring service.
|
If a goal audit chain is suspected of tampering, stop new goal submissions, preserve `.specify/logs/audit/goal-orchestrator.jsonl` and its head, and compare each `prev_hash`/`record_hash` before restoring service.
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|||||||
from urllib.parse import parse_qs, urlencode, urlparse
|
from urllib.parse import parse_qs, urlencode, urlparse
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
|
||||||
|
|
||||||
@@ -33,7 +34,12 @@ USERS = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
KID = "casan-local-prod-idp"
|
KID = "casan-local-prod-idp"
|
||||||
KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
KEY_FILE = os.environ.get("CASAN_IDP_PRIVATE_KEY_FILE", "")
|
||||||
|
if KEY_FILE:
|
||||||
|
with open(KEY_FILE, "rb") as key_handle:
|
||||||
|
KEY = serialization.load_pem_private_key(key_handle.read(), password=None)
|
||||||
|
else:
|
||||||
|
KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
CODES = {}
|
CODES = {}
|
||||||
APPROVAL_SIGNER_TOKEN = os.environ.get("CASAN_APPROVAL_SIGNER_TOKEN", "")
|
APPROVAL_SIGNER_TOKEN = os.environ.get("CASAN_APPROVAL_SIGNER_TOKEN", "")
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ not be invented or committed.
|
|||||||
it at `/opt/casan-control-panel/tls/tls.crt` and `tls.key` (mode `0600`).
|
it at `/opt/casan-control-panel/tls/tls.crt` and `tls.key` (mode `0600`).
|
||||||
2. Register `https://<fqdn>/oauth2/callback` with the enterprise IdP. Map the
|
2. Register `https://<fqdn>/oauth2/callback` with the enterprise IdP. Map the
|
||||||
`groups` claim to CASAN groups such as `casan-org-admin` and
|
`groups` claim to CASAN groups such as `casan-org-admin` and
|
||||||
`casan-approver`. Copy `oauth2-proxy.env.example` outside the repo and fill
|
`casan-approver`. Export the IdP's RS256 verification public key to
|
||||||
it using the secret manager.
|
`/opt/casan-control-panel/idp-public.pem`; rotation requires replacing this
|
||||||
|
file and restarting the API. Copy `oauth2-proxy.env.example` outside the
|
||||||
|
repo and fill it using the secret manager.
|
||||||
3. Create a Vault Transit key `casan-audit-key` with `exportable=false`; give a
|
3. Create a Vault Transit key `casan-audit-key` with `exportable=false`; give a
|
||||||
workload identity only `transit/sign/casan-audit-key` and read-key metadata.
|
workload identity only `transit/sign/casan-audit-key` and read-key metadata.
|
||||||
Render a short-lived token into `vault.env` outside Git. Never use Vault dev
|
Render a short-lived token into `vault.env` outside Git. Never use Vault dev
|
||||||
@@ -35,6 +37,7 @@ sudo install -d -m 0700 /opt/casan-control-panel/tls /srv/casan/state /srv/casan
|
|||||||
sudo install -m 0600 /dev/null /opt/casan-control-panel/oauth2-proxy.env
|
sudo install -m 0600 /dev/null /opt/casan-control-panel/oauth2-proxy.env
|
||||||
sudo install -m 0600 /dev/null /opt/casan-control-panel/runtime.env
|
sudo install -m 0600 /dev/null /opt/casan-control-panel/runtime.env
|
||||||
sudo install -m 0600 /dev/null /opt/casan-control-panel/vault.env
|
sudo install -m 0600 /dev/null /opt/casan-control-panel/vault.env
|
||||||
|
sudo install -m 0644 /secure/export/idp-public.pem /opt/casan-control-panel/idp-public.pem
|
||||||
|
|
||||||
bash packages/casan-harness/scripts/bash/production-preflight.sh infra/production/casan-prod.env
|
bash packages/casan-harness/scripts/bash/production-preflight.sh infra/production/casan-prod.env
|
||||||
set -a; source infra/production/casan-prod.env; set +a
|
set -a; source infra/production/casan-prod.env; set +a
|
||||||
@@ -43,9 +46,15 @@ docker compose -f docker-compose.control-panel.yml up -d
|
|||||||
|
|
||||||
The preflight performs a real Object Lock anchor write. It will reject local
|
The preflight performs a real Object Lock anchor write. It will reject local
|
||||||
URLs, image tags, expired/near-expiry TLS, dev/root Vault tokens, incomplete
|
URLs, image tags, expired/near-expiry TLS, dev/root Vault tokens, incomplete
|
||||||
OIDC, missing paths, and buckets without Object Lock. After login, run
|
OIDC, issuer/audience mismatches, missing IdP keys, local trust providers,
|
||||||
|
missing paths, and buckets without Object Lock. After login, run
|
||||||
`managed-prod-smoke.sh` with an authenticated cookie jar.
|
`managed-prod-smoke.sh` with an authenticated cookie jar.
|
||||||
|
|
||||||
|
The API does not trust `X-CASAN-*` or forwarded-user headers from Nginx.
|
||||||
|
oauth2-proxy returns the signed access token, Nginx removes caller-supplied
|
||||||
|
identity headers, and the API verifies RS256 signature, issuer, audience,
|
||||||
|
expiry and clock bounds before deriving actor, role, tenant and project.
|
||||||
|
|
||||||
## Operational anchors
|
## Operational anchors
|
||||||
|
|
||||||
Run `audit-ship-s3.sh` after every audit seal from the same workload identity.
|
Run `audit-ship-s3.sh` after every audit seal from the same workload identity.
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ CASAN_CP_RUNTIME_ENV=/opt/casan-control-panel/runtime.env
|
|||||||
CASAN_CP_VAULT_ENV=/opt/casan-control-panel/vault.env
|
CASAN_CP_VAULT_ENV=/opt/casan-control-panel/vault.env
|
||||||
CASAN_CP_STATE_DIR=/srv/casan/state
|
CASAN_CP_STATE_DIR=/srv/casan/state
|
||||||
CASAN_CP_OUTPUT_DIR=/srv/casan/output
|
CASAN_CP_OUTPUT_DIR=/srv/casan/output
|
||||||
|
CASAN_CP_IDP_PUBLIC_KEY=/opt/casan-control-panel/idp-public.pem
|
||||||
|
|
||||||
# CI must publish immutable image references, not tags such as :latest.
|
# CI must publish immutable image references, not tags such as :latest.
|
||||||
CASAN_CP_API_IMAGE=registry.example.internal/casan/control-panel-api@sha256:replace-with-64-hex-digest
|
CASAN_CP_API_IMAGE=registry.example.internal/casan/control-panel-api@sha256:replace-with-64-hex-digest
|
||||||
CASAN_CP_UI_IMAGE=registry.example.internal/casan/control-panel-ui@sha256:replace-with-64-hex-digest
|
CASAN_CP_UI_IMAGE=registry.example.internal/casan/control-panel-ui@sha256:replace-with-64-hex-digest
|
||||||
|
CASAN_CP_OAUTH2_PROXY_IMAGE=quay.io/oauth2-proxy/oauth2-proxy@sha256:replace-with-64-hex-digest
|
||||||
|
|
||||||
# WORM anchor destination. The deploy identity needs only PutObject and
|
# WORM anchor destination. The deploy identity needs only PutObject and
|
||||||
# GetObjectLockConfiguration for this bucket/prefix; use workload identity,
|
# GetObjectLockConfiguration for this bucket/prefix; use workload identity,
|
||||||
|
|||||||
@@ -8,6 +8,6 @@ OAUTH2_PROXY_COOKIE_SECURE=true
|
|||||||
OAUTH2_PROXY_REDIRECT_URL=https://control.casan.company.internal/oauth2/callback
|
OAUTH2_PROXY_REDIRECT_URL=https://control.casan.company.internal/oauth2/callback
|
||||||
OAUTH2_PROXY_OIDC_GROUPS_CLAIM=groups
|
OAUTH2_PROXY_OIDC_GROUPS_CLAIM=groups
|
||||||
OAUTH2_PROXY_SET_XAUTHREQUEST=true
|
OAUTH2_PROXY_SET_XAUTHREQUEST=true
|
||||||
OAUTH2_PROXY_PASS_ACCESS_TOKEN=false
|
OAUTH2_PROXY_PASS_ACCESS_TOKEN=true
|
||||||
OAUTH2_PROXY_PASS_AUTHORIZATION_HEADER=false
|
OAUTH2_PROXY_PASS_AUTHORIZATION_HEADER=true
|
||||||
OAUTH2_PROXY_SCOPE="openid profile email groups"
|
OAUTH2_PROXY_SCOPE="openid profile email groups"
|
||||||
|
|||||||
@@ -2,10 +2,19 @@
|
|||||||
# companion Vault file and the production preflight validation.
|
# companion Vault file and the production preflight validation.
|
||||||
CASAN_PROFILE=prod
|
CASAN_PROFILE=prod
|
||||||
CASAN_CP_STRICT=1
|
CASAN_CP_STRICT=1
|
||||||
CASAN_CP_TRUST_AUTH_PROXY=1
|
CASAN_CP_AUTH_MODE=jwt
|
||||||
|
CASAN_CP_JWT_ISSUER=https://id.example.internal/realms/casan
|
||||||
|
CASAN_CP_JWT_AUDIENCE=casan-control-plane
|
||||||
|
CASAN_CP_JWT_PUBLIC_KEY_FILE=/run/casan-idp/idp-public.pem
|
||||||
|
CASAN_CP_JWT_ROLE_CLAIM=groups
|
||||||
|
CASAN_CP_JWT_TENANT_CLAIM=casan_tenant
|
||||||
|
CASAN_CP_JWT_PROJECT_CLAIM=casan_project
|
||||||
|
CASAN_CP_JWT_CLOCK_SKEW_SECONDS=60
|
||||||
CP_BIND=0.0.0.0
|
CP_BIND=0.0.0.0
|
||||||
CP_PORT=3010
|
CP_PORT=3010
|
||||||
CASAN_APP_ROOT=/app
|
CASAN_APP_ROOT=/app
|
||||||
CASAN_DASHBOARD_STALE_S=300
|
CASAN_DASHBOARD_STALE_S=300
|
||||||
CASAN_PREFLIGHT=1
|
CASAN_PREFLIGHT=1
|
||||||
CASAN_CHAT_MODEL_MODE=deterministic
|
CASAN_CHAT_MODEL_MODE=deterministic
|
||||||
|
CASAN_SIGNING_PROVIDER=vault_kms
|
||||||
|
CASAN_IMMUTABLE_ANCHOR_PROVIDER=s3_object_lock
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ server {
|
|||||||
auth_request /oauth2/auth;
|
auth_request /oauth2/auth;
|
||||||
auth_request_set $auth_user $upstream_http_x_auth_request_user;
|
auth_request_set $auth_user $upstream_http_x_auth_request_user;
|
||||||
auth_request_set $auth_groups $upstream_http_x_auth_request_groups;
|
auth_request_set $auth_groups $upstream_http_x_auth_request_groups;
|
||||||
|
auth_request_set $auth_access_token $upstream_http_x_auth_request_access_token;
|
||||||
|
|
||||||
proxy_pass http://control-panel-api:3010/api/v1/;
|
proxy_pass http://control-panel-api:3010/api/v1/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -55,16 +56,23 @@ server {
|
|||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto https;
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
|
||||||
# Overwrite browser-supplied identity headers. The API maps these IdP
|
# Remove every caller-supplied identity assertion. The API derives
|
||||||
# group claims through rbac-check.py map-claim.
|
# actor/role/scope only after cryptographic JWT verification.
|
||||||
proxy_set_header X-CASAN-Actor $auth_user;
|
proxy_set_header X-CASAN-Actor "";
|
||||||
proxy_set_header X-CASAN-Groups $auth_groups;
|
proxy_set_header X-CASAN-Groups "";
|
||||||
proxy_set_header X-CASAN-Role "";
|
proxy_set_header X-CASAN-Role "";
|
||||||
|
proxy_set_header X-CASAN-Project "";
|
||||||
|
proxy_set_header X-CASAN-Tenant "";
|
||||||
|
proxy_set_header X-Forwarded-User "";
|
||||||
|
proxy_set_header X-Forwarded-Groups "";
|
||||||
|
proxy_set_header Authorization "Bearer $auth_access_token";
|
||||||
}
|
}
|
||||||
|
|
||||||
location = /healthz {
|
location = /healthz {
|
||||||
auth_request /oauth2/auth;
|
auth_request /oauth2/auth;
|
||||||
|
auth_request_set $auth_access_token $upstream_http_x_auth_request_access_token;
|
||||||
proxy_pass http://control-panel-api:3010/healthz;
|
proxy_pass http://control-panel-api:3010/healthz;
|
||||||
|
proxy_set_header Authorization "Bearer $auth_access_token";
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
|||||||
@@ -101,8 +101,10 @@ Goal workspace context:
|
|||||||
a different actor. The executor verifies the artifact hash, applies it, runs fixed
|
a different actor. The executor verifies the artifact hash, applies it, runs fixed
|
||||||
project build/test commands, and reverses the patch if verification fails.
|
project build/test commands, and reverses the patch if verification fails.
|
||||||
|
|
||||||
Local management headers: `x-casan-actor`, `x-casan-role`, `x-casan-project`,
|
In explicit loopback development mode only, local management headers are
|
||||||
`x-casan-tenant`. Missing role defaults to `viewer`, so writes fail closed.
|
`x-casan-actor`, `x-casan-role`, `x-casan-project`, and `x-casan-tenant`.
|
||||||
|
Missing role defaults to `viewer`, so writes fail closed. JWT mode discards
|
||||||
|
these caller assertions and derives them only from verified token claims.
|
||||||
|
|
||||||
Kill-switch management:
|
Kill-switch management:
|
||||||
|
|
||||||
@@ -169,12 +171,19 @@ App root + telemetry paths resolve via the same marker walk-up as `casan-paths.s
|
|||||||
compatibility aliases. Freshness is calculated independently from each file's mtime using
|
compatibility aliases. Freshness is calculated independently from each file's mtime using
|
||||||
`CASAN_DASHBOARD_STALE_S` (default `3600`).
|
`CASAN_DASHBOARD_STALE_S` (default `3600`).
|
||||||
|
|
||||||
## Security posture (MVP)
|
## Security posture
|
||||||
Binds `127.0.0.1` by default. Refuses a non-loopback bind under `CASAN_PROFILE=prod` /
|
Binds `127.0.0.1` in explicit local development mode. Every non-loopback bind,
|
||||||
`CASAN_CP_STRICT=1` unless `CASAN_CP_TRUST_AUTH_PROXY=1` is set for an authenticated reverse
|
and every production profile, requires `CASAN_CP_AUTH_MODE=jwt`. Production
|
||||||
proxy that overwrites identity headers. Management endpoints are RBAC-gated via the harness
|
requires an RS256 public key plus configured issuer and audience; missing or
|
||||||
`rbac-check.py`; IdP group claims such as `casan-approver` are mapped to RBAC roles through
|
invalid configuration refuses startup. The API verifies signature, expiry,
|
||||||
the same harness engine.
|
issuer, audience, `nbf`/`iat`, and bounded clock skew in-process, then maps
|
||||||
|
verified group claims through `rbac-check.py`. Arbitrary `X-CASAN-*` and
|
||||||
|
forwarded-user headers are not an authentication mechanism.
|
||||||
|
|
||||||
|
Production variables are documented in `infra/production/runtime.env.example`.
|
||||||
|
The packaged boundary is oauth2-proxy → Nginx header stripping/bearer forwarding
|
||||||
|
→ API cryptographic verification. The current provider uses a mounted RS256
|
||||||
|
public key; automated JWKS discovery/rotation remains future work.
|
||||||
|
|
||||||
## Test
|
## Test
|
||||||
```bash
|
```bash
|
||||||
@@ -189,8 +198,8 @@ bash packages/casan-control-panel/scripts/local-prod-smoke.sh
|
|||||||
|
|
||||||
The scaffold includes `Dockerfile.control-panel-api`, `Dockerfile.control-panel-ui`, and
|
The scaffold includes `Dockerfile.control-panel-api`, `Dockerfile.control-panel-ui`, and
|
||||||
`nginx/control-panel.conf`. Nginx protects UI/API through oauth2-proxy `auth_request`,
|
`nginx/control-panel.conf`. Nginx protects UI/API through oauth2-proxy `auth_request`,
|
||||||
overwrites browser-supplied `X-CASAN-*` headers, and passes IdP group claims to the API for
|
removes browser-supplied identity headers, and forwards the signed bearer token for API
|
||||||
RBAC mapping. The local smoke starts a self-signed HTTPS stack with a mock OIDC IdP and
|
verification. The local smoke starts a self-signed HTTPS stack with a mock OIDC IdP and
|
||||||
expects `CP_LOCAL_SMOKE_PASS https_oidc=true actor=oidc-ops role=org-admin`; it also
|
expects `CP_LOCAL_SMOKE_PASS https_oidc=true actor=oidc-ops role=org-admin`; it also
|
||||||
asserts the Command Center returns all nine widgets with provenance envelopes and invokes
|
asserts the Command Center returns all nine widgets with provenance envelopes and invokes
|
||||||
`managed-prod-smoke.sh` with the authenticated mock-IdP cookie jar. A passing local run
|
`managed-prod-smoke.sh` with the authenticated mock-IdP cookie jar. A passing local run
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { APP_ROOT } from './app-root.js';
|
import { APP_ROOT } from './app-root.js';
|
||||||
import type { SettingsActor } from '../settings/settings.service.js';
|
import type { SettingsActor } from '../settings/settings.service.js';
|
||||||
|
import { UnauthorizedException } from '@nestjs/common';
|
||||||
|
|
||||||
const RBAC_CLI = join(APP_ROOT, 'packages', 'casan-harness', 'scripts', 'bash', 'rbac-check.py');
|
const RBAC_CLI = join(APP_ROOT, 'packages', 'casan-harness', 'scripts', 'bash', 'rbac-check.py');
|
||||||
const LOCAL_ROLES = new Set(['org-admin', 'project-admin', 'approver', 'operator', 'viewer', 'auditor']);
|
const LOCAL_ROLES = new Set(['org-admin', 'project-admin', 'approver', 'operator', 'viewer', 'auditor']);
|
||||||
@@ -23,7 +24,7 @@ function mapClaim(claim: string): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function roleFromClaim(raw: string | undefined): string {
|
export function roleFromClaim(raw: string | undefined): string {
|
||||||
if (!raw) return 'viewer';
|
if (!raw) return 'viewer';
|
||||||
const claims = raw.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
|
const claims = raw.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
|
||||||
for (const claim of claims) {
|
for (const claim of claims) {
|
||||||
@@ -33,6 +34,23 @@ function roleFromClaim(raw: string | undefined): string {
|
|||||||
return 'viewer';
|
return 'viewer';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VerifiedClaimInput {
|
||||||
|
subject: string;
|
||||||
|
issuer: string;
|
||||||
|
roles: string[];
|
||||||
|
project: string;
|
||||||
|
tenant: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function actorFromVerifiedClaims(claims: VerifiedClaimInput): SettingsActor {
|
||||||
|
return {
|
||||||
|
actor: claims.subject,
|
||||||
|
role: roleFromClaim(claims.roles.join(',')),
|
||||||
|
project: /^[A-Za-z0-9._-]+$/.test(claims.project) ? claims.project : 'default',
|
||||||
|
tenant: /^[A-Za-z0-9._-]+$/.test(claims.tenant) ? claims.tenant : 'default',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function projectFromClaims(raw: string | undefined): string | undefined {
|
function projectFromClaims(raw: string | undefined): string | undefined {
|
||||||
if (!raw) return undefined;
|
if (!raw) return undefined;
|
||||||
const prefix = 'casan-project:';
|
const prefix = 'casan-project:';
|
||||||
@@ -42,6 +60,9 @@ function projectFromClaims(raw: string | undefined): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function actorFromHeaders(headers: Record<string, string | string[] | undefined>): SettingsActor {
|
export function actorFromHeaders(headers: Record<string, string | string[] | undefined>): SettingsActor {
|
||||||
|
if (process.env.CASAN_CP_AUTH_MODE === 'jwt' && firstHeader(headers['x-casan-identity-verified']) !== '1') {
|
||||||
|
throw new UnauthorizedException('AUTH_VERIFIED_IDENTITY_REQUIRED');
|
||||||
|
}
|
||||||
const actor = firstHeader(headers['x-casan-actor'])
|
const actor = firstHeader(headers['x-casan-actor'])
|
||||||
|| firstHeader(headers['x-auth-request-user'])
|
|| firstHeader(headers['x-auth-request-user'])
|
||||||
|| firstHeader(headers['x-forwarded-user'])
|
|| firstHeader(headers['x-forwarded-user'])
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { createHash, createHmac, createPublicKey, createVerify, timingSafeEqual } from 'node:crypto';
|
||||||
|
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, writeSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
|
import { APP_ROOT } from './app-root.js';
|
||||||
|
import { actorFromVerifiedClaims, type VerifiedClaimInput } from './auth-context.js';
|
||||||
|
|
||||||
|
export type AuthMode = 'local' | 'jwt';
|
||||||
|
export type JwtAlgorithm = 'RS256' | 'HS256';
|
||||||
|
|
||||||
|
export interface ControlPlaneAuthConfig {
|
||||||
|
mode: AuthMode;
|
||||||
|
profile: string;
|
||||||
|
bind: string;
|
||||||
|
issuer?: string;
|
||||||
|
audience?: string;
|
||||||
|
algorithm?: JwtAlgorithm;
|
||||||
|
publicKey?: string;
|
||||||
|
hmacSecret?: string;
|
||||||
|
clockSkewSeconds: number;
|
||||||
|
roleClaim: string;
|
||||||
|
tenantClaim: string;
|
||||||
|
projectClaim: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifiedClaims {
|
||||||
|
subject: string;
|
||||||
|
issuer: string;
|
||||||
|
audience: string[];
|
||||||
|
expiresAt: number;
|
||||||
|
issuedAt?: number;
|
||||||
|
roles: string[];
|
||||||
|
tenant: string;
|
||||||
|
project: string;
|
||||||
|
authenticationMethod: 'jwt';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthenticationDecision {
|
||||||
|
allowed: boolean;
|
||||||
|
reasonCode: string;
|
||||||
|
claims?: VerifiedClaims;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthProvider {
|
||||||
|
authenticate(headers: Record<string, string | string[] | undefined>, nowSeconds?: number): AuthenticationDecision;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JwtHeader {
|
||||||
|
alg?: string;
|
||||||
|
typ?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type JwtPayload = Record<string, unknown>;
|
||||||
|
|
||||||
|
const IDENTITY_HEADERS = [
|
||||||
|
'x-casan-actor', 'x-casan-role', 'x-casan-groups', 'x-casan-project', 'x-casan-tenant',
|
||||||
|
'x-auth-request-user', 'x-auth-request-groups', 'x-forwarded-user', 'x-forwarded-groups',
|
||||||
|
'x-casan-identity-verified', 'x-casan-identity-issuer',
|
||||||
|
];
|
||||||
|
|
||||||
|
function firstHeader(value: string | string[] | undefined): string | undefined {
|
||||||
|
return Array.isArray(value) ? value[0] : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeSegment<T>(segment: string): T {
|
||||||
|
const decoded = Buffer.from(segment, 'base64url').toString('utf8');
|
||||||
|
const payload: unknown = JSON.parse(decoded);
|
||||||
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new Error('jwt_segment_not_object');
|
||||||
|
return payload as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringClaim(value: unknown): string | undefined {
|
||||||
|
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringListClaim(value: unknown): string[] {
|
||||||
|
if (Array.isArray(value)) return value.filter((item): item is string => typeof item === 'string' && item.length > 0);
|
||||||
|
if (typeof value === 'string') return value.split(/[\s,]+/).filter(Boolean);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function audienceClaim(value: unknown): string[] {
|
||||||
|
return typeof value === 'string' ? [value] : stringListClaim(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function numericClaim(value: unknown): number | undefined {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeScope(value: string | undefined, fallback: string): string {
|
||||||
|
return value && /^[A-Za-z0-9._-]+$/.test(value) ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifySignature(input: string, signature: Buffer, config: ControlPlaneAuthConfig): boolean {
|
||||||
|
if (config.algorithm === 'RS256' && config.publicKey) {
|
||||||
|
const verifier = createVerify('RSA-SHA256');
|
||||||
|
verifier.update(input);
|
||||||
|
verifier.end();
|
||||||
|
return verifier.verify(config.publicKey, signature);
|
||||||
|
}
|
||||||
|
if (config.algorithm === 'HS256' && config.hmacSecret) {
|
||||||
|
const expected = createHmac('sha256', config.hmacSecret).update(input).digest();
|
||||||
|
return expected.length === signature.length && timingSafeEqual(expected, signature);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class JwtAuthProvider implements AuthProvider {
|
||||||
|
constructor(private readonly config: ControlPlaneAuthConfig) {}
|
||||||
|
|
||||||
|
authenticate(headers: Record<string, string | string[] | undefined>, nowSeconds = Math.floor(Date.now() / 1000)): AuthenticationDecision {
|
||||||
|
const authorization = firstHeader(headers.authorization);
|
||||||
|
if (!authorization?.startsWith('Bearer ')) return { allowed: false, reasonCode: 'auth_bearer_token_required' };
|
||||||
|
const token = authorization.slice('Bearer '.length).trim();
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 3 || parts.some((part) => !part)) return { allowed: false, reasonCode: 'auth_token_malformed' };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const header = decodeSegment<JwtHeader>(parts[0]);
|
||||||
|
const payload = decodeSegment<JwtPayload>(parts[1]);
|
||||||
|
if (header.alg !== this.config.algorithm) return { allowed: false, reasonCode: 'auth_algorithm_mismatch' };
|
||||||
|
if (!verifySignature(`${parts[0]}.${parts[1]}`, Buffer.from(parts[2], 'base64url'), this.config)) {
|
||||||
|
return { allowed: false, reasonCode: 'auth_signature_invalid' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const issuer = stringClaim(payload.iss);
|
||||||
|
const audience = audienceClaim(payload.aud);
|
||||||
|
const subject = stringClaim(payload.sub);
|
||||||
|
const expiresAt = numericClaim(payload.exp);
|
||||||
|
const notBefore = numericClaim(payload.nbf);
|
||||||
|
const issuedAt = numericClaim(payload.iat);
|
||||||
|
const skew = this.config.clockSkewSeconds;
|
||||||
|
if (!issuer || issuer !== this.config.issuer) return { allowed: false, reasonCode: 'auth_issuer_invalid' };
|
||||||
|
if (!this.config.audience || !audience.includes(this.config.audience)) return { allowed: false, reasonCode: 'auth_audience_invalid' };
|
||||||
|
if (!subject) return { allowed: false, reasonCode: 'auth_subject_required' };
|
||||||
|
if (!expiresAt || nowSeconds - skew >= expiresAt) return { allowed: false, reasonCode: 'auth_token_expired' };
|
||||||
|
if (notBefore !== undefined && nowSeconds + skew < notBefore) return { allowed: false, reasonCode: 'auth_token_not_yet_valid' };
|
||||||
|
if (issuedAt !== undefined && issuedAt > nowSeconds + skew) return { allowed: false, reasonCode: 'auth_issued_at_invalid' };
|
||||||
|
|
||||||
|
const roles = stringListClaim(payload[this.config.roleClaim]);
|
||||||
|
const rawTenant = stringClaim(payload[this.config.tenantClaim]);
|
||||||
|
const rawProject = stringClaim(payload[this.config.projectClaim]);
|
||||||
|
if ((rawTenant && safeScope(rawTenant, '') === '') || (rawProject && safeScope(rawProject, '') === '')) {
|
||||||
|
return { allowed: false, reasonCode: 'auth_scope_invalid' };
|
||||||
|
}
|
||||||
|
const tenant = safeScope(rawTenant, 'default');
|
||||||
|
const project = safeScope(rawProject, 'default');
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
reasonCode: 'auth_verified',
|
||||||
|
claims: { subject, issuer, audience, expiresAt, issuedAt, roles, tenant, project, authenticationMethod: 'jwt' },
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { allowed: false, reasonCode: 'auth_token_malformed' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadAuthConfig(env: NodeJS.ProcessEnv = process.env): ControlPlaneAuthConfig {
|
||||||
|
const profile = env.CASAN_CP_STRICT === '1' ? 'strict' : (env.CASAN_PROFILE || 'development').toLowerCase();
|
||||||
|
const mode = (env.CASAN_CP_AUTH_MODE || 'local').toLowerCase() as AuthMode;
|
||||||
|
const bind = env.CP_BIND || '127.0.0.1';
|
||||||
|
const publicKeyPath = env.CASAN_CP_JWT_PUBLIC_KEY_FILE;
|
||||||
|
const publicKey = publicKeyPath && existsSync(publicKeyPath) ? readFileSync(publicKeyPath, 'utf8') : undefined;
|
||||||
|
const skew = Number(env.CASAN_CP_JWT_CLOCK_SKEW_SECONDS ?? 60);
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
profile,
|
||||||
|
bind,
|
||||||
|
issuer: env.CASAN_CP_JWT_ISSUER,
|
||||||
|
audience: env.CASAN_CP_JWT_AUDIENCE,
|
||||||
|
algorithm: publicKey ? 'RS256' : env.CASAN_CP_JWT_HS256_SECRET ? 'HS256' : undefined,
|
||||||
|
publicKey,
|
||||||
|
hmacSecret: env.CASAN_CP_JWT_HS256_SECRET,
|
||||||
|
clockSkewSeconds: Number.isFinite(skew) ? skew : -1,
|
||||||
|
roleClaim: env.CASAN_CP_JWT_ROLE_CLAIM || 'groups',
|
||||||
|
tenantClaim: env.CASAN_CP_JWT_TENANT_CLAIM || 'casan_tenant',
|
||||||
|
projectClaim: env.CASAN_CP_JWT_PROJECT_CLAIM || 'casan_project',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateAuthConfig(config: ControlPlaneAuthConfig): string[] {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const production = config.profile === 'prod' || config.profile === 'production' || config.profile === 'strict';
|
||||||
|
const nonLoopback = !['127.0.0.1', 'localhost', '::1'].includes(config.bind);
|
||||||
|
if (!['local', 'jwt'].includes(config.mode)) errors.push('auth_mode_invalid');
|
||||||
|
if ((production || nonLoopback) && config.mode !== 'jwt') errors.push('verified_identity_required');
|
||||||
|
if (config.clockSkewSeconds < 0 || config.clockSkewSeconds > 300) errors.push('auth_clock_skew_invalid');
|
||||||
|
if (config.mode === 'jwt') {
|
||||||
|
if (!config.issuer) errors.push('auth_issuer_required');
|
||||||
|
if (!config.audience) errors.push('auth_audience_required');
|
||||||
|
if (!config.algorithm) errors.push('auth_verification_key_required');
|
||||||
|
if (production && config.algorithm !== 'RS256') errors.push('auth_asymmetric_key_required_in_production');
|
||||||
|
if (config.algorithm === 'RS256') {
|
||||||
|
try {
|
||||||
|
if (!config.publicKey || createPublicKey(config.publicKey).asymmetricKeyType !== 'rsa') {
|
||||||
|
errors.push('auth_rsa_public_key_invalid');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
errors.push('auth_rsa_public_key_invalid');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (config.algorithm === 'HS256' && (!config.hmacSecret || Buffer.byteLength(config.hmacSecret) < 32)) {
|
||||||
|
errors.push('auth_hmac_secret_too_short');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditAuthentication(decision: AuthenticationDecision, request: Request): void {
|
||||||
|
const stateRoot = process.env.CASAN_STATE_ROOT || join(APP_ROOT, '.specify');
|
||||||
|
const path = join(stateRoot, 'logs', 'auth', 'decisions.jsonl');
|
||||||
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
|
const record = {
|
||||||
|
schema_version: '1.0.0', category: 'runtime_control', policy_id: 'casan.control-plane.authentication',
|
||||||
|
timestamp: new Date().toISOString(), decision: decision.allowed ? 'allow' : 'deny', reason_code: decision.reasonCode,
|
||||||
|
actor: decision.claims?.subject, issuer: decision.claims?.issuer, tenant: decision.claims?.tenant,
|
||||||
|
project: decision.claims?.project, method: decision.claims?.authenticationMethod,
|
||||||
|
request: { method: request.method, path: request.path },
|
||||||
|
};
|
||||||
|
const fd = openSync(path, 'a', 0o600);
|
||||||
|
try {
|
||||||
|
writeSync(fd, `${JSON.stringify(record)}\n`);
|
||||||
|
fsyncSync(fd);
|
||||||
|
} finally {
|
||||||
|
closeSync(fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function overwriteVerifiedHeaders(request: Request, claims: VerifiedClaims): void {
|
||||||
|
for (const header of IDENTITY_HEADERS) delete request.headers[header];
|
||||||
|
const input: VerifiedClaimInput = {
|
||||||
|
subject: claims.subject,
|
||||||
|
issuer: claims.issuer,
|
||||||
|
roles: claims.roles,
|
||||||
|
tenant: claims.tenant,
|
||||||
|
project: claims.project,
|
||||||
|
};
|
||||||
|
const actor = actorFromVerifiedClaims(input);
|
||||||
|
request.headers['x-casan-actor'] = actor.actor;
|
||||||
|
request.headers['x-casan-role'] = actor.role;
|
||||||
|
request.headers['x-casan-project'] = actor.project;
|
||||||
|
request.headers['x-casan-tenant'] = actor.tenant;
|
||||||
|
request.headers['x-casan-identity-verified'] = '1';
|
||||||
|
request.headers['x-casan-identity-issuer'] = claims.issuer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAuthMiddleware(config: ControlPlaneAuthConfig) {
|
||||||
|
const provider = config.mode === 'jwt' ? new JwtAuthProvider(config) : undefined;
|
||||||
|
return (request: Request, response: Response, next: NextFunction): void => {
|
||||||
|
if (!provider) {
|
||||||
|
request.headers['x-casan-identity-verified'] = 'local-development-only';
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const headers = request.headers as Record<string, string | string[] | undefined>;
|
||||||
|
const decision = provider.authenticate(headers);
|
||||||
|
auditAuthentication(decision, request);
|
||||||
|
if (!decision.allowed || !decision.claims) {
|
||||||
|
response.status(401).json({ success: false, error: { code: decision.reasonCode, message: 'Authentication failed' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
overwriteVerifiedHeaders(request, decision.claims);
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tokenFingerprint(token: string): string {
|
||||||
|
return createHash('sha256').update(token).digest('hex').slice(0, 12);
|
||||||
|
}
|
||||||
@@ -3,23 +3,28 @@ import { NestFactory } from '@nestjs/core';
|
|||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { AppModule } from './app.module.js';
|
import { AppModule } from './app.module.js';
|
||||||
import { APP_ROOT } from './common/app-root.js';
|
import { APP_ROOT } from './common/app-root.js';
|
||||||
|
import { createAuthMiddleware, loadAuthConfig, validateAuthConfig } from './common/auth-provider.js';
|
||||||
|
|
||||||
// Ops Console API (Plan-13). Binds loopback by default and refuses a non-loopback
|
// Ops Console API (Plan-13). Binds loopback by default. Networked and production
|
||||||
// bind under CASAN_PROFILE=prod / CASAN_CP_STRICT=1 unless an authenticated reverse
|
// deployments require in-process cryptographic identity verification.
|
||||||
// proxy is explicitly configured to overwrite identity headers.
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
|
const authConfig = loadAuthConfig();
|
||||||
|
const authErrors = validateAuthConfig(authConfig);
|
||||||
|
if (authErrors.length > 0) {
|
||||||
|
console.error(`CP_AUTH_CONFIGURATION_INVALID reasons=${authErrors.join(',')}`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
const app = await NestFactory.create(AppModule, { cors: true });
|
const app = await NestFactory.create(AppModule, { cors: true });
|
||||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||||
|
app.use(createAuthMiddleware(authConfig));
|
||||||
|
|
||||||
const port = Number(process.env.CP_PORT ?? 3010);
|
const port = Number(process.env.CP_PORT ?? 3010);
|
||||||
let host = process.env.CP_BIND ?? '127.0.0.1';
|
let host = process.env.CP_BIND ?? '127.0.0.1';
|
||||||
const strict = process.env.CASAN_PROFILE === 'prod' || process.env.CASAN_CP_STRICT === '1';
|
const strict = process.env.CASAN_PROFILE === 'prod' || process.env.CASAN_CP_STRICT === '1';
|
||||||
const authProxy = process.env.CASAN_CP_TRUST_AUTH_PROXY === '1';
|
if (strict && host !== '127.0.0.1' && host !== 'localhost' && authConfig.mode !== 'jwt') {
|
||||||
if (strict && host !== '127.0.0.1' && host !== 'localhost' && !authProxy) {
|
// Networked production requires in-process cryptographic verification.
|
||||||
// The console must not expose telemetry/management off-loopback without TLS/OIDC
|
|
||||||
// at the reverse proxy, which must overwrite X-CASAN-* identity headers.
|
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.error(`CP_REFUSE_NONLOOPBACK host=${host} (set up TLS/OIDC per Plan-13 Track 4 first)`);
|
console.error(`CP_REFUSE_NONLOOPBACK host=${host} reason=verified_identity_required`);
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,18 @@ function coverage(label: string, value: number, detail: string): string {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function distributionRows(report: H6Report): string {
|
||||||
|
const maximum = Math.max(...report.details.by_status.map((row) => row.count), 1);
|
||||||
|
if (report.details.by_status.length === 0) return '<p class="empty">No measured outcome distribution is available.</p>';
|
||||||
|
return `<ol class="distribution">${report.details.by_status.map((row) => {
|
||||||
|
const value = Math.max(row.count, 0);
|
||||||
|
const width = value === 0 ? 0 : Math.max((value / maximum) * 100, 3);
|
||||||
|
const tone = row.status === 'failed' ? 'fail' : row.status === 'degraded' ? 'attention' : 'pass';
|
||||||
|
const share = report.summary.runs ? Math.round((value / report.summary.runs) * 100) : 0;
|
||||||
|
return `<li><div><span><strong>${escapeHtml(row.status.replaceAll('_', ' '))}</strong><small>${share}% of selected runs</small></span><b>${number(value)}</b></div><i><em class="${tone}" style="width:${width}%"></em></i></li>`;
|
||||||
|
}).join('')}</ol>`;
|
||||||
|
}
|
||||||
|
|
||||||
export function renderH6ReportHtml(report: H6Report): string {
|
export function renderH6ReportHtml(report: H6Report): string {
|
||||||
const verdictClass = report.verdict === 'pass' ? 'pass' : report.verdict === 'fail' ? 'fail' : report.verdict === 'attention' ? 'attention' : 'neutral';
|
const verdictClass = report.verdict === 'pass' ? 'pass' : report.verdict === 'fail' ? 'fail' : report.verdict === 'attention' ? 'attention' : 'neutral';
|
||||||
const scope = [
|
const scope = [
|
||||||
@@ -59,6 +71,7 @@ export function renderH6ReportHtml(report: H6Report): string {
|
|||||||
const warnings = report.data_quality.warnings.length
|
const warnings = report.data_quality.warnings.length
|
||||||
? report.data_quality.warnings.map((warning) => `<li>${escapeHtml(warning)}</li>`).join('')
|
? report.data_quality.warnings.map((warning) => `<li>${escapeHtml(warning)}</li>`).join('')
|
||||||
: '<li class="positive">All required sources are present and no estimation warning was detected.</li>';
|
: '<li class="positive">All required sources are present and no estimation warning was detected.</li>';
|
||||||
|
const outcomes = distributionRows(report);
|
||||||
|
|
||||||
return `<!doctype html>
|
return `<!doctype html>
|
||||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
@@ -73,10 +86,12 @@ export function renderH6ReportHtml(report: H6Report): string {
|
|||||||
.section{margin-top:16px;border:1px solid var(--line);border-radius:17px;background:var(--paper);box-shadow:0 8px 22px rgba(16,24,40,.04)}.section-head{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;border-bottom:1px solid #f2f4f7;padding:17px 20px}.kicker{color:#087e8b;font-size:9px;font-weight:900;letter-spacing:.16em;text-transform:uppercase}.section-head h2{margin:3px 0 0;font-size:16px;letter-spacing:-.01em}.section-head p{max-width:580px;margin:3px 0 0;color:var(--muted);font-size:10px}.section-body{padding:18px 20px}
|
.section{margin-top:16px;border:1px solid var(--line);border-radius:17px;background:var(--paper);box-shadow:0 8px 22px rgba(16,24,40,.04)}.section-head{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;border-bottom:1px solid #f2f4f7;padding:17px 20px}.kicker{color:#087e8b;font-size:9px;font-weight:900;letter-spacing:.16em;text-transform:uppercase}.section-head h2{margin:3px 0 0;font-size:16px;letter-spacing:-.01em}.section-head p{max-width:580px;margin:3px 0 0;color:var(--muted);font-size:10px}.section-body{padding:18px 20px}
|
||||||
.findings{display:grid;gap:10px}.finding{display:grid;grid-template-columns:38px minmax(0,1fr);gap:12px;border:1px solid #eaecf0;border-left:4px solid var(--cyan);border-radius:10px;padding:12px}.finding.warning{border-left-color:var(--amber);background:#fffcf5}.finding.critical{border-left-color:var(--red);background:#fffbfa}.finding.success{border-left-color:var(--green);background:#f6fef9}.finding-index{color:#98a2b3;font:15px ui-monospace,SFMono-Regular,Menlo,monospace}.finding-head{display:flex;align-items:center;justify-content:space-between;gap:12px}.finding-head strong{font:10px ui-monospace,SFMono-Regular,Menlo,monospace}.finding-head code{color:#667085;font-size:8px}.finding p{margin:5px 0 0;color:#475467;font-size:11px}
|
.findings{display:grid;gap:10px}.finding{display:grid;grid-template-columns:38px minmax(0,1fr);gap:12px;border:1px solid #eaecf0;border-left:4px solid var(--cyan);border-radius:10px;padding:12px}.finding.warning{border-left-color:var(--amber);background:#fffcf5}.finding.critical{border-left-color:var(--red);background:#fffbfa}.finding.success{border-left-color:var(--green);background:#f6fef9}.finding-index{color:#98a2b3;font:15px ui-monospace,SFMono-Regular,Menlo,monospace}.finding-head{display:flex;align-items:center;justify-content:space-between;gap:12px}.finding-head strong{font:10px ui-monospace,SFMono-Regular,Menlo,monospace}.finding-head code{color:#667085;font-size:8px}.finding p{margin:5px 0 0;color:#475467;font-size:11px}
|
||||||
.quality-grid{display:grid;grid-template-columns:.75fr 1.25fr;gap:20px}.coverage+.coverage{margin-top:20px}.coverage-head{display:flex;justify-content:space-between;gap:12px}.coverage-head span{color:#667085;font-size:9px;font-weight:900;letter-spacing:.12em;text-transform:uppercase}.coverage-head strong{font:13px ui-monospace,SFMono-Regular,Menlo,monospace}.track{height:6px;margin-top:7px;overflow:hidden;border-radius:99px;background:#f2f4f7}.track i{display:block;height:100%;border-radius:99px;background:#d0d5dd}.track i.good{background:var(--green)}.track i.warn{background:var(--amber)}.coverage small{display:block;margin-top:6px;color:#98a2b3}.disclosure{border:1px solid #eaecf0;border-radius:12px;background:#f9fafb;padding:14px}.disclosure strong{font-size:10px;text-transform:uppercase;letter-spacing:.1em}.disclosure ul{margin:10px 0 0;padding-left:17px;color:#475467;font-size:10px}.disclosure li+li{margin-top:6px}.disclosure .positive{color:#027a48}
|
.quality-grid{display:grid;grid-template-columns:.75fr 1.25fr;gap:20px}.coverage+.coverage{margin-top:20px}.coverage-head{display:flex;justify-content:space-between;gap:12px}.coverage-head span{color:#667085;font-size:9px;font-weight:900;letter-spacing:.12em;text-transform:uppercase}.coverage-head strong{font:13px ui-monospace,SFMono-Regular,Menlo,monospace}.track{height:6px;margin-top:7px;overflow:hidden;border-radius:99px;background:#f2f4f7}.track i{display:block;height:100%;border-radius:99px;background:#d0d5dd}.track i.good{background:var(--green)}.track i.warn{background:var(--amber)}.coverage small{display:block;margin-top:6px;color:#98a2b3}.disclosure{border:1px solid #eaecf0;border-radius:12px;background:#f9fafb;padding:14px}.disclosure strong{font-size:10px;text-transform:uppercase;letter-spacing:.1em}.disclosure ul{margin:10px 0 0;padding-left:17px;color:#475467;font-size:10px}.disclosure li+li{margin-top:6px}.disclosure .positive{color:#027a48}
|
||||||
|
.distribution{display:grid;gap:15px;margin:0;padding:0;list-style:none}.distribution li>div{display:flex;align-items:end;justify-content:space-between;gap:16px}.distribution span strong,.distribution span small{display:block}.distribution span strong{font-size:11px;text-transform:capitalize}.distribution span small{margin-top:3px;color:var(--muted);font-size:9px}.distribution b{font:10px ui-monospace,SFMono-Regular,Menlo,monospace}.distribution i{display:block;overflow:hidden;height:7px;margin-top:7px;border-radius:99px;background:#eaecf0}.distribution em{display:block;height:100%;border-radius:inherit;background:var(--cyan)}.distribution em.pass{background:var(--green)}.distribution em.attention{background:var(--amber)}.distribution em.fail{background:var(--red)}
|
||||||
|
.technical{margin-top:16px;border:1px solid var(--line);border-radius:17px;background:var(--paper);box-shadow:0 8px 22px rgba(16,24,40,.04)}.technical>summary{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:17px 20px;cursor:pointer;list-style:none}.technical>summary::-webkit-details-marker{display:none}.technical>summary strong,.technical>summary small{display:block}.technical>summary strong{font-size:13px}.technical>summary small{margin-top:3px;color:var(--muted);font-size:10px}.technical>summary b{color:var(--soft);font-size:18px;font-weight:400}.technical[open]>summary b{transform:rotate(45deg)}.technical-body{border-top:1px solid #f2f4f7;padding:18px 20px}.technical .summary{margin:0 0 16px}.technical .section{box-shadow:none}
|
||||||
.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse;font-size:10px}th{border-block:1px solid #eaecf0;background:#f9fafb;padding:9px 10px;color:#667085;font-size:8px;letter-spacing:.1em;text-align:left;text-transform:uppercase}td{border-bottom:1px solid #f2f4f7;padding:10px;vertical-align:top}tr:last-child td{border-bottom:0}.numeric{text-align:right;font:9px ui-monospace,SFMono-Regular,Menlo,monospace}.negative{color:#b42318;font-weight:700}td code{display:block;max-width:420px;color:#98a2b3;font-size:8px;word-break:break-all}.status{display:inline-flex;border:1px solid #d0d5dd;border-radius:999px;padding:3px 7px;color:#475467;font-size:8px;font-weight:900;text-transform:uppercase}.status.fresh{border-color:#a6f4c5;background:#ecfdf3;color:#027a48}.status.stale{border-color:#fedf89;background:#fffaeb;color:#b54708}.status.missing{border-color:#fecdca;background:#fef3f2;color:#b42318}.empty{padding:30px;color:#98a2b3;text-align:center}
|
.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse;font-size:10px}th{border-block:1px solid #eaecf0;background:#f9fafb;padding:9px 10px;color:#667085;font-size:8px;letter-spacing:.1em;text-align:left;text-transform:uppercase}td{border-bottom:1px solid #f2f4f7;padding:10px;vertical-align:top}tr:last-child td{border-bottom:0}.numeric{text-align:right;font:9px ui-monospace,SFMono-Regular,Menlo,monospace}.negative{color:#b42318;font-weight:700}td code{display:block;max-width:420px;color:#98a2b3;font-size:8px;word-break:break-all}.status{display:inline-flex;border:1px solid #d0d5dd;border-radius:999px;padding:3px 7px;color:#475467;font-size:8px;font-weight:900;text-transform:uppercase}.status.fresh{border-color:#a6f4c5;background:#ecfdf3;color:#027a48}.status.stale{border-color:#fedf89;background:#fffaeb;color:#b54708}.status.missing{border-color:#fecdca;background:#fef3f2;color:#b42318}.empty{padding:30px;color:#98a2b3;text-align:center}
|
||||||
.footer{display:flex;justify-content:space-between;gap:20px;margin-top:20px;border-top:1px solid var(--line);padding-top:12px;color:#98a2b3;font-size:9px}
|
.footer{display:flex;justify-content:space-between;gap:20px;margin-top:20px;border-top:1px solid var(--line);padding-top:12px;color:#98a2b3;font-size:9px}
|
||||||
@media(max-width:820px){main{padding:18px 12px 44px}.cover{padding:24px}.cover h1{font-size:28px}.cover-foot{display:block}.document-meta{margin-top:18px;text-align:left}.summary{grid-template-columns:repeat(2,1fr)}.quality-grid{grid-template-columns:1fr}.finding-head{display:block}.finding-head code{display:block;margin-top:4px}}
|
@media(max-width:820px){main{padding:18px 12px 44px}.cover{padding:24px}.cover h1{font-size:28px}.cover-foot{display:block}.document-meta{margin-top:18px;text-align:left}.summary{grid-template-columns:repeat(2,1fr)}.quality-grid{grid-template-columns:1fr}.finding-head{display:block}.finding-head code{display:block;margin-top:4px}}
|
||||||
@page{size:A4;margin:12mm}@media print{html,body{background:#fff}main{max-width:none;padding:0}.cover,.metric,.section{box-shadow:none}.cover{-webkit-print-color-adjust:exact;print-color-adjust:exact}.summary{grid-template-columns:repeat(4,1fr)}.section,.metric,.finding{break-inside:avoid}.cover h1{font-size:30px}}
|
@page{size:A4;margin:12mm}@media print{html,body{background:#fff}main{max-width:none;padding:0}.cover,.metric,.section,.technical{box-shadow:none}.cover{-webkit-print-color-adjust:exact;print-color-adjust:exact}.summary{grid-template-columns:repeat(4,1fr)}.section,.metric,.finding{break-inside:avoid}.cover h1{font-size:30px}.technical>summary b{display:none}.technical-body{display:block!important}}
|
||||||
</style></head><body><main>
|
</style></head><body><main>
|
||||||
<header class="cover">
|
<header class="cover">
|
||||||
<div class="eyebrow">CASAN · H6 AgentOps assurance</div>
|
<div class="eyebrow">CASAN · H6 AgentOps assurance</div>
|
||||||
@@ -93,31 +108,33 @@ export function renderH6ReportHtml(report: H6Report): string {
|
|||||||
<div class="metric"><span>Failure rate</span><strong>${report.summary.failure_rate_pct}%</strong><small>${number(report.summary.failed)} failed · ${number(report.summary.alerts)} alerts</small></div>
|
<div class="metric"><span>Failure rate</span><strong>${report.summary.failure_rate_pct}%</strong><small>${number(report.summary.failed)} failed · ${number(report.summary.alerts)} alerts</small></div>
|
||||||
<div class="metric"><span>P95 latency</span><strong>${number(report.summary.latency_ms.p95)} ms</strong><small>P50 ${number(report.summary.latency_ms.p50)} ms</small></div>
|
<div class="metric"><span>P95 latency</span><strong>${number(report.summary.latency_ms.p95)} ms</strong><small>P50 ${number(report.summary.latency_ms.p50)} ms</small></div>
|
||||||
<div class="metric"><span>Data quality</span><strong>${escapeHtml(report.data_quality.status.replaceAll('_', ' '))}</strong><small>Coverage disclosed below</small></div>
|
<div class="metric"><span>Data quality</span><strong>${escapeHtml(report.data_quality.status.replaceAll('_', ' '))}</strong><small>Coverage disclosed below</small></div>
|
||||||
<div class="metric"><span>Provider tokens</span><strong>${report.summary.coverage.token_records > 0 ? number(report.summary.tokens.provider_total ?? report.summary.tokens.total ?? 0) : '<span class="unavailable">Unavailable</span>'}</strong><small>${report.summary.coverage.token_pct}% record coverage</small></div>
|
|
||||||
<div class="metric"><span>Actual provider cost</span><strong>${report.summary.cost_usd.provider_actual !== null ? money(report.summary.cost_usd.provider_actual) : '<span class="unavailable">Unavailable</span>'}</strong><small>${number(report.summary.provider_calls)} provider calls</small></div>
|
|
||||||
<div class="metric"><span>Cost attribution</span><strong>${report.summary.coverage.cost_pct}%</strong><small>${report.summary.coverage.cost_records}/${report.summary.coverage.runtime_records} runtime records</small></div>
|
|
||||||
<div class="metric"><span>Retries</span><strong>${number(report.summary.retries)}</strong><small>Observed reroute attempts</small></div>
|
|
||||||
</section>
|
</section>
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="section-head"><div><div class="kicker">Decision brief</div><h2>What requires attention</h2><p>Threshold findings are operational review signals, not maturity claims.</p></div><span class="status ${verdictClass}">${escapeHtml(report.verdict)}</span></div>
|
<div class="section-head"><div><div class="kicker">Decision brief</div><h2>What requires attention</h2><p>Threshold findings are operational review signals, not maturity claims.</p></div><span class="status ${verdictClass}">${escapeHtml(report.verdict)}</span></div>
|
||||||
<div class="section-body"><div class="findings">${findings}</div></div>
|
<div class="section-body"><div class="findings">${findings}</div></div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-head"><div><div class="kicker">Outcome mix</div><h2>Run distribution</h2><p>Canonical runtime status values, normalized against the largest observed group.</p></div><span class="status">${number(report.summary.runs)} total</span></div>
|
||||||
|
<div class="section-body">${outcomes}</div>
|
||||||
|
</section>
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="section-head"><div><div class="kicker">Evidence quality</div><h2>Coverage and disclosure</h2><p>Unavailable telemetry remains null and is never rendered as zero.</p></div><span class="status">${escapeHtml(report.data_quality.status)}</span></div>
|
<div class="section-head"><div><div class="kicker">Evidence quality</div><h2>Coverage and disclosure</h2><p>Unavailable telemetry remains null and is never rendered as zero.</p></div><span class="status">${escapeHtml(report.data_quality.status)}</span></div>
|
||||||
<div class="section-body"><div class="quality-grid"><div>${coverage('Token attribution', report.summary.coverage.token_pct, `${report.summary.coverage.token_records}/${report.summary.coverage.runtime_records} runtime records`)}${coverage('Cost attribution', report.summary.coverage.cost_pct, `${report.summary.coverage.cost_records}/${report.summary.coverage.runtime_records} runtime records`)}</div><div class="disclosure"><strong>Disclosure notes</strong><ul>${warnings}</ul></div></div></div>
|
<div class="section-body"><div class="quality-grid"><div>${coverage('Token attribution', report.summary.coverage.token_pct, `${report.summary.coverage.token_records}/${report.summary.coverage.runtime_records} runtime records`)}${coverage('Cost attribution', report.summary.coverage.cost_pct, `${report.summary.coverage.cost_records}/${report.summary.coverage.runtime_records} runtime records`)}</div><div class="disclosure"><strong>Disclosure notes</strong><ul>${warnings}</ul></div></div></div>
|
||||||
</section>
|
</section>
|
||||||
<section class="section">
|
<details class="technical">
|
||||||
<div class="section-head"><div><div class="kicker">Source integrity</div><h2>Evidence provenance</h2><p>Freshness and presence are evaluated independently for every canonical source.</p></div></div>
|
<summary><span><strong>Technical evidence and breakdowns</strong><small>Provider counters, canonical paths and raw tables are preserved for audit.</small></span><b>+</b></summary>
|
||||||
<div class="table-wrap"><table><thead><tr><th>Source</th><th>State</th><th class="numeric">Age</th><th class="numeric">Records</th><th>Canonical path</th></tr></thead><tbody>${sourceRows}</tbody></table></div>
|
<div class="technical-body">
|
||||||
</section>
|
<section class="summary">
|
||||||
<section class="section">
|
<div class="metric"><span>Provider tokens</span><strong>${report.summary.coverage.token_records > 0 ? number(report.summary.tokens.provider_total ?? report.summary.tokens.total ?? 0) : '<span class="unavailable">Unavailable</span>'}</strong><small>${report.summary.coverage.token_pct}% record coverage</small></div>
|
||||||
<div class="section-head"><div><div class="kicker">Runtime</div><h2>Execution profile by step</h2></div></div>
|
<div class="metric"><span>Actual provider cost</span><strong>${report.summary.cost_usd.provider_actual !== null ? money(report.summary.cost_usd.provider_actual) : '<span class="unavailable">Unavailable</span>'}</strong><small>${number(report.summary.provider_calls)} provider calls</small></div>
|
||||||
<div class="table-wrap"><table><thead><tr><th>Step</th><th class="numeric">Runs</th><th class="numeric">Failures</th><th class="numeric">Avg latency</th><th class="numeric">Tokens</th><th class="numeric">Cost</th></tr></thead><tbody>${breakdownRows(report.details.by_step)}</tbody></table></div>
|
<div class="metric"><span>Cost attribution</span><strong>${report.summary.coverage.cost_pct}%</strong><small>${report.summary.coverage.cost_records}/${report.summary.coverage.runtime_records} runtime records</small></div>
|
||||||
</section>
|
<div class="metric"><span>Retries</span><strong>${number(report.summary.retries)}</strong><small>Observed reroute attempts</small></div>
|
||||||
<section class="section">
|
</section>
|
||||||
<div class="section-head"><div><div class="kicker">Provider</div><h2>Model usage and provenance</h2><p>Only provider-reported token and actual cost values are shown.</p></div></div>
|
<section class="section"><div class="section-head"><div><div class="kicker">Source integrity</div><h2>Evidence provenance</h2></div></div><div class="table-wrap"><table><thead><tr><th>Source</th><th>State</th><th class="numeric">Age</th><th class="numeric">Records</th><th>Canonical path</th></tr></thead><tbody>${sourceRows}</tbody></table></div></section>
|
||||||
<div class="table-wrap"><table><thead><tr><th>Provider · model</th><th class="numeric">Calls</th><th class="numeric">Failures</th><th class="numeric">Avg latency</th><th class="numeric">Tokens</th><th class="numeric">Cost</th></tr></thead><tbody>${breakdownRows(report.details.by_provider)}</tbody></table></div>
|
<section class="section"><div class="section-head"><div><div class="kicker">Runtime</div><h2>Execution profile by step</h2></div></div><div class="table-wrap"><table><thead><tr><th>Step</th><th class="numeric">Runs</th><th class="numeric">Failures</th><th class="numeric">Avg latency</th><th class="numeric">Tokens</th><th class="numeric">Cost</th></tr></thead><tbody>${breakdownRows(report.details.by_step)}</tbody></table></div></section>
|
||||||
</section>
|
<section class="section"><div class="section-head"><div><div class="kicker">Provider</div><h2>Model usage and provenance</h2></div></div><div class="table-wrap"><table><thead><tr><th>Provider · model</th><th class="numeric">Calls</th><th class="numeric">Failures</th><th class="numeric">Avg latency</th><th class="numeric">Tokens</th><th class="numeric">Cost</th></tr></thead><tbody>${breakdownRows(report.details.by_provider)}</tbody></table></div></section>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
<footer class="footer"><span>Generated from CASAN runtime evidence. No maturity score or telemetry value is hard-coded in this document.</span><span>Contract v${report.schema_version} · ${escapeHtml(report.report_id)}</span></footer>
|
<footer class="footer"><span>Generated from CASAN runtime evidence. No maturity score or telemetry value is hard-coded in this document.</span><span>Contract v${report.schema_version} · ${escapeHtml(report.report_id)}</span></footer>
|
||||||
</main></body></html>`;
|
</main></body></html>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -298,6 +298,8 @@ export function buildH6Report(input: H6ReportInput, query: H6ReportQuery): H6Rep
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
schema_version: 1,
|
schema_version: 1,
|
||||||
|
category: 'report_dimension',
|
||||||
|
dimension_id: 'ReportDimension.H6',
|
||||||
report_id: `H6-${input.now.toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}`,
|
report_id: `H6-${input.now.toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}`,
|
||||||
harness: 'H6',
|
harness: 'H6',
|
||||||
title: 'H6 · AgentOps Report',
|
title: 'H6 · AgentOps Report',
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export interface HarnessReportEvidenceSource extends SourceFreshness {
|
|||||||
|
|
||||||
export interface HarnessReport<TSummary, TDetails> {
|
export interface HarnessReport<TSummary, TDetails> {
|
||||||
schema_version: 1;
|
schema_version: 1;
|
||||||
|
category: 'report_dimension';
|
||||||
|
dimension_id: `ReportDimension.${HarnessReportId}`;
|
||||||
report_id: string;
|
report_id: string;
|
||||||
harness: HarnessReportId;
|
harness: HarnessReportId;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -71,6 +73,8 @@ export interface HarnessReportCatalogEntry {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
contract_version: 1;
|
contract_version: 1;
|
||||||
|
category: 'report_dimension';
|
||||||
|
dimension_id: `ReportDimension.${HarnessReportId}`;
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
availability: 'implemented' | 'contract_ready';
|
availability: 'implemented' | 'contract_ready';
|
||||||
}
|
}
|
||||||
@@ -78,6 +82,8 @@ export interface HarnessReportCatalogEntry {
|
|||||||
export const HARNESS_REPORT_CATALOG: HarnessReportCatalogEntry[] = HARNESS_REPORT_DEFINITIONS.map((definition) => ({
|
export const HARNESS_REPORT_CATALOG: HarnessReportCatalogEntry[] = HARNESS_REPORT_DEFINITIONS.map((definition) => ({
|
||||||
...definition,
|
...definition,
|
||||||
contract_version: 1,
|
contract_version: 1,
|
||||||
|
category: 'report_dimension',
|
||||||
|
dimension_id: `ReportDimension.${definition.id}`,
|
||||||
endpoint: `/api/v1/reports/${definition.id.toLowerCase()}`,
|
endpoint: `/api/v1/reports/${definition.id.toLowerCase()}`,
|
||||||
availability: definition.id === 'H6' ? 'implemented' : 'contract_ready',
|
availability: definition.id === 'H6' ? 'implemented' : 'contract_ready',
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -60,8 +60,9 @@ main{max-width:1180px;margin:0 auto;padding:38px 24px 64px}.cover{position:relat
|
|||||||
.spine{position:relative;display:grid;grid-template-columns:repeat(7,minmax(0,1fr));padding:24px 18px 20px}.spine:before{position:absolute;left:8%;right:8%;top:43px;height:1px;background:var(--line);content:""}.spine-node{position:relative;text-align:center}.spine-dot{display:flex;width:38px;height:38px;margin:0 auto;align-items:center;justify-content:center;border:2px solid #d0d5dd;border-radius:99px;background:#fff;color:#667085;font-weight:900;box-shadow:0 0 0 5px #fff}.spine-node.pass .spine-dot{border-color:#6ce9a6;color:#027a48}.spine-node.warn .spine-dot{border-color:#fec84b;color:#b54708}.spine-node.fail .spine-dot{border-color:#fda29b;color:#b42318}.spine-node strong{display:block;margin-top:8px;color:#98a2b3;font-size:9px;letter-spacing:.13em}.spine-node small{display:block;margin-top:3px;overflow:hidden;color:#344054;font-size:9px;font-weight:700;text-overflow:ellipsis;white-space:nowrap}
|
.spine{position:relative;display:grid;grid-template-columns:repeat(7,minmax(0,1fr));padding:24px 18px 20px}.spine:before{position:absolute;left:8%;right:8%;top:43px;height:1px;background:var(--line);content:""}.spine-node{position:relative;text-align:center}.spine-dot{display:flex;width:38px;height:38px;margin:0 auto;align-items:center;justify-content:center;border:2px solid #d0d5dd;border-radius:99px;background:#fff;color:#667085;font-weight:900;box-shadow:0 0 0 5px #fff}.spine-node.pass .spine-dot{border-color:#6ce9a6;color:#027a48}.spine-node.warn .spine-dot{border-color:#fec84b;color:#b54708}.spine-node.fail .spine-dot{border-color:#fda29b;color:#b42318}.spine-node strong{display:block;margin-top:8px;color:#98a2b3;font-size:9px;letter-spacing:.13em}.spine-node small{display:block;margin-top:3px;overflow:hidden;color:#344054;font-size:9px;font-weight:700;text-overflow:ellipsis;white-space:nowrap}
|
||||||
.controls{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;padding:18px}.gate{display:grid;grid-template-columns:48px minmax(0,1fr);overflow:hidden;border:1px solid var(--line);border-radius:14px;background:#fff;break-inside:avoid}.gate-index{display:flex;align-items:flex-start;justify-content:center;border-right:1px solid var(--line);padding-top:18px;color:#98a2b3;font-size:12px;font-weight:900}.gate.pass{border-top:3px solid var(--green)}.gate.warn{border-top:3px solid var(--amber)}.gate.fail{border-top:3px solid var(--red)}.gate-copy{padding:15px}.gate-heading{display:flex;align-items:center;justify-content:space-between;gap:10px}.gate-heading h3{margin:0;font-size:14px}.badge{border:1px solid #d0d5dd;border-radius:999px;padding:3px 7px;color:#475467;font-size:8px;font-weight:900;letter-spacing:.08em;text-transform:uppercase}.badge.pass{border-color:#a6f4c5;background:#ecfdf3;color:#027a48}.badge.warn{border-color:#fedf89;background:#fffaeb;color:#b54708}.badge.fail{border-color:#fecdca;background:#fef3f2;color:#b42318}.gate-description{min-height:34px;margin:6px 0;color:var(--muted);font-size:10px}.decision{margin-top:10px;border-left:3px solid #d0d5dd;background:#f9fafb;padding:9px 10px}.decision span,.evidence-title{display:block;color:#667085;font-size:8px;font-weight:900;letter-spacing:.12em;text-transform:uppercase}.decision strong{display:block;margin-top:4px;font-size:10px;font-weight:600}.decision small{display:block;margin-top:4px;color:#98a2b3;font:8px ui-monospace,SFMono-Regular,Menlo,monospace}.evidence-title{margin-top:12px}.manifest{margin:6px 0 0;border:1px solid #eaecf0;border-radius:8px}.manifest div{display:grid;grid-template-columns:110px minmax(0,1fr);border-bottom:1px solid #eaecf0}.manifest div:last-child{border-bottom:0}.manifest dt,.manifest dd{margin:0;padding:6px 7px;font:8px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all}.manifest dt{background:#f9fafb;color:#667085;font-weight:700}.manifest dd{color:#344054}.empty-evidence{margin-top:6px;border:1px dashed #d0d5dd;border-radius:8px;padding:10px;color:#98a2b3;font-size:9px;text-align:center}
|
.controls{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;padding:18px}.gate{display:grid;grid-template-columns:48px minmax(0,1fr);overflow:hidden;border:1px solid var(--line);border-radius:14px;background:#fff;break-inside:avoid}.gate-index{display:flex;align-items:flex-start;justify-content:center;border-right:1px solid var(--line);padding-top:18px;color:#98a2b3;font-size:12px;font-weight:900}.gate.pass{border-top:3px solid var(--green)}.gate.warn{border-top:3px solid var(--amber)}.gate.fail{border-top:3px solid var(--red)}.gate-copy{padding:15px}.gate-heading{display:flex;align-items:center;justify-content:space-between;gap:10px}.gate-heading h3{margin:0;font-size:14px}.badge{border:1px solid #d0d5dd;border-radius:999px;padding:3px 7px;color:#475467;font-size:8px;font-weight:900;letter-spacing:.08em;text-transform:uppercase}.badge.pass{border-color:#a6f4c5;background:#ecfdf3;color:#027a48}.badge.warn{border-color:#fedf89;background:#fffaeb;color:#b54708}.badge.fail{border-color:#fecdca;background:#fef3f2;color:#b42318}.gate-description{min-height:34px;margin:6px 0;color:var(--muted);font-size:10px}.decision{margin-top:10px;border-left:3px solid #d0d5dd;background:#f9fafb;padding:9px 10px}.decision span,.evidence-title{display:block;color:#667085;font-size:8px;font-weight:900;letter-spacing:.12em;text-transform:uppercase}.decision strong{display:block;margin-top:4px;font-size:10px;font-weight:600}.decision small{display:block;margin-top:4px;color:#98a2b3;font:8px ui-monospace,SFMono-Regular,Menlo,monospace}.evidence-title{margin-top:12px}.manifest{margin:6px 0 0;border:1px solid #eaecf0;border-radius:8px}.manifest div{display:grid;grid-template-columns:110px minmax(0,1fr);border-bottom:1px solid #eaecf0}.manifest div:last-child{border-bottom:0}.manifest dt,.manifest dd{margin:0;padding:6px 7px;font:8px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all}.manifest dt{background:#f9fafb;color:#667085;font-weight:700}.manifest dd{color:#344054}.empty-evidence{margin-top:6px;border:1px dashed #d0d5dd;border-radius:8px;padding:10px;color:#98a2b3;font-size:9px;text-align:center}
|
||||||
.certification{display:grid;grid-template-columns:180px minmax(0,1fr);gap:20px;padding:19px 20px}.certification label{color:#667085;font-size:9px;font-weight:900;letter-spacing:.12em;text-transform:uppercase}.certification strong{display:block;margin-top:5px;font-size:15px}.certification p{margin:0;color:#475467;font-size:11px}.footer{display:flex;justify-content:space-between;gap:20px;margin-top:20px;border-top:1px solid var(--line);padding-top:12px;color:#98a2b3;font-size:9px}
|
.certification{display:grid;grid-template-columns:180px minmax(0,1fr);gap:20px;padding:19px 20px}.certification label{color:#667085;font-size:9px;font-weight:900;letter-spacing:.12em;text-transform:uppercase}.certification strong{display:block;margin-top:5px;font-size:15px}.certification p{margin:0;color:#475467;font-size:11px}.footer{display:flex;justify-content:space-between;gap:20px;margin-top:20px;border-top:1px solid var(--line);padding-top:12px;color:#98a2b3;font-size:9px}
|
||||||
|
.technical{margin-top:16px;border:1px solid var(--line);border-radius:17px;background:var(--paper);box-shadow:0 8px 22px rgba(16,24,40,.04)}.technical>summary{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:17px 20px;cursor:pointer;list-style:none}.technical>summary::-webkit-details-marker{display:none}.technical>summary strong,.technical>summary small{display:block}.technical>summary strong{font-size:13px}.technical>summary small{margin-top:3px;color:var(--muted);font-size:10px}.technical>summary b{color:var(--soft);font-size:18px;font-weight:400}.technical[open]>summary b{transform:rotate(45deg)}.technical-body{border-top:1px solid #f2f4f7;padding:18px}.technical .summary{margin:0 0 16px}.technical .section{box-shadow:none}
|
||||||
@media(max-width:820px){main{padding:18px 12px 44px}.cover{padding:24px}.cover h1{font-size:28px}.cover-grid{grid-template-columns:1fr}.summary{grid-template-columns:repeat(2,1fr)}.spine{min-width:720px}.spine-wrap{overflow:auto}.controls{grid-template-columns:1fr}.document-meta{grid-template-columns:1fr 1fr}.certification{grid-template-columns:1fr}}
|
@media(max-width:820px){main{padding:18px 12px 44px}.cover{padding:24px}.cover h1{font-size:28px}.cover-grid{grid-template-columns:1fr}.summary{grid-template-columns:repeat(2,1fr)}.spine{min-width:720px}.spine-wrap{overflow:auto}.controls{grid-template-columns:1fr}.document-meta{grid-template-columns:1fr 1fr}.certification{grid-template-columns:1fr}}
|
||||||
@page{size:A4;margin:12mm}@media print{html,body{background:#fff}main{max-width:none;padding:0}.cover,.metric,.section{box-shadow:none}.cover{-webkit-print-color-adjust:exact;print-color-adjust:exact}.summary{grid-template-columns:repeat(4,1fr)}.spine{padding-inline:8px}.controls{grid-template-columns:repeat(2,1fr);padding:12px}.section,.metric,.gate{break-inside:avoid}.footer{position:relative}.cover h1{font-size:30px}}
|
@page{size:A4;margin:12mm}@media print{html,body{background:#fff}main{max-width:none;padding:0}.cover,.metric,.section,.technical{box-shadow:none}.cover{-webkit-print-color-adjust:exact;print-color-adjust:exact}.summary{grid-template-columns:repeat(4,1fr)}.spine{padding-inline:8px}.controls{grid-template-columns:repeat(2,1fr);padding:12px}.section,.metric,.gate{break-inside:avoid}.footer{position:relative}.cover h1{font-size:30px}.technical>summary b{display:none}.technical-body{display:block!important}}
|
||||||
</style></head><body><main>
|
</style></head><body><main>
|
||||||
<header class="cover">
|
<header class="cover">
|
||||||
<div class="eyebrow">CASAN · independent assurance dossier</div>
|
<div class="eyebrow">CASAN · independent assurance dossier</div>
|
||||||
@@ -82,23 +83,27 @@ main{max-width:1180px;margin:0 auto;padding:38px 24px 64px}.cover{position:relat
|
|||||||
<div class="metric"><span>Evidence source</span><strong>${evidenceState}</strong><small>Trace + graph reconstruction</small></div>
|
<div class="metric"><span>Evidence source</span><strong>${evidenceState}</strong><small>Trace + graph reconstruction</small></div>
|
||||||
<div class="metric"><span>H6 quality</span><strong>${escapeHtml(report.summary.telemetry_quality)}</strong><small>Telemetry disclosure status</small></div>
|
<div class="metric"><span>H6 quality</span><strong>${escapeHtml(report.summary.telemetry_quality)}</strong><small>Telemetry disclosure status</small></div>
|
||||||
<div class="metric"><span>Duration</span><strong>${report.summary.duration_ms === null ? 'Unavailable' : `${report.summary.duration_ms} ms`}</strong><small>End-to-end governed run</small></div>
|
<div class="metric"><span>Duration</span><strong>${report.summary.duration_ms === null ? 'Unavailable' : `${report.summary.duration_ms} ms`}</strong><small>End-to-end governed run</small></div>
|
||||||
<div class="metric"><span>Tool calls</span><strong>${report.summary.tool_calls}</strong><small>Observed side-effect attempts</small></div>
|
|
||||||
<div class="metric"><span>Failures</span><strong>${report.summary.failures}</strong><small>Recorded execution failures</small></div>
|
|
||||||
<div class="metric"><span>Token usage</span><strong>${tokenValue}</strong><small>Never inferred as zero</small></div>
|
|
||||||
<div class="metric"><span>Actual cost</span><strong>${costValue}</strong><small>Provider attribution only</small></div>
|
|
||||||
</section>
|
</section>
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="section-head"><div><div class="kicker">Control sequence</div><h2>Live assurance rail · H1 → H7</h2><p>The evidence spine shows the terminal state of every governed control.</p></div><span class="badge ${badgeClass(report.verdict)}">${escapeHtml(report.verdict)}</span></div>
|
<div class="section-head"><div><div class="kicker">Control sequence</div><h2>Live assurance rail · H1 → H7</h2><p>The evidence spine shows the terminal state of every governed control.</p></div><span class="badge ${badgeClass(report.verdict)}">${escapeHtml(report.verdict)}</span></div>
|
||||||
<div class="spine-wrap"><div class="spine">${spine}</div></div>
|
<div class="spine-wrap"><div class="spine">${spine}</div></div>
|
||||||
</section>
|
</section>
|
||||||
<section class="section">
|
|
||||||
<div class="section-head"><div><div class="kicker">Control dossiers</div><h2>Decision and evidence detail</h2><p>Every field below is sanitized before persistence. Raw prompts and tool output are excluded.</p></div></div>
|
|
||||||
<div class="controls">${gates}</div>
|
|
||||||
</section>
|
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="section-head"><div><div class="kicker">Certification</div><h2>Assurance conclusion</h2></div></div>
|
<div class="section-head"><div><div class="kicker">Certification</div><h2>Assurance conclusion</h2></div></div>
|
||||||
<div class="certification"><div><label>Strength</label><strong>${escapeHtml(report.certification.strength ?? 'unknown')}</strong></div><p>${escapeHtml(report.certification.reasons.join(' · ') || 'No certification reason recorded.')}</p></div>
|
<div class="certification"><div><label>Strength</label><strong>${escapeHtml(report.certification.strength ?? 'unknown')}</strong></div><p>${escapeHtml(report.certification.reasons.join(' · ') || 'No certification reason recorded.')}</p></div>
|
||||||
</section>
|
</section>
|
||||||
|
<details class="technical">
|
||||||
|
<summary><span><strong>Technical evidence and control dossiers</strong><small>Execution counters and sanitized field manifests are preserved for audit.</small></span><b>+</b></summary>
|
||||||
|
<div class="technical-body">
|
||||||
|
<section class="summary">
|
||||||
|
<div class="metric"><span>Tool calls</span><strong>${report.summary.tool_calls}</strong><small>Observed side-effect attempts</small></div>
|
||||||
|
<div class="metric"><span>Failures</span><strong>${report.summary.failures}</strong><small>Recorded execution failures</small></div>
|
||||||
|
<div class="metric"><span>Token usage</span><strong>${tokenValue}</strong><small>Never inferred as zero</small></div>
|
||||||
|
<div class="metric"><span>Actual cost</span><strong>${costValue}</strong><small>Provider attribution only</small></div>
|
||||||
|
</section>
|
||||||
|
<section class="section"><div class="section-head"><div><div class="kicker">Control dossiers</div><h2>Decision and evidence detail</h2><p>Every field below is sanitized before persistence. Raw prompts and tool output are excluded.</p></div></div><div class="controls">${gates}</div></section>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
<footer class="footer"><span>Generated from CASAN runtime evidence. Unavailable values remain null.</span><span>Schema v${report.schema_version} · ${escapeHtml(report.trace_id)}</span></footer>
|
<footer class="footer"><span>Generated from CASAN runtime evidence. Unavailable values remain null.</span><span>Schema v${report.schema_version} · ${escapeHtml(report.trace_id)}</span></footer>
|
||||||
</main></body></html>`;
|
</main></body></html>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,3 +29,17 @@ test('auth context fails closed to viewer for unknown role claim', () => {
|
|||||||
});
|
});
|
||||||
assert.equal(actor.role, 'viewer');
|
assert.equal(actor.role, 'viewer');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('JWT mode rejects direct spoofed identity headers without middleware verification', () => {
|
||||||
|
const prior = process.env.CASAN_CP_AUTH_MODE;
|
||||||
|
process.env.CASAN_CP_AUTH_MODE = 'jwt';
|
||||||
|
try {
|
||||||
|
assert.throws(() => actorFromHeaders({
|
||||||
|
'x-casan-actor': 'attacker',
|
||||||
|
'x-casan-role': 'org-admin',
|
||||||
|
}), /AUTH_VERIFIED_IDENTITY_REQUIRED/);
|
||||||
|
} finally {
|
||||||
|
if (prior === undefined) delete process.env.CASAN_CP_AUTH_MODE;
|
||||||
|
else process.env.CASAN_CP_AUTH_MODE = prior;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { createHmac, createSign, generateKeyPairSync } from 'node:crypto';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
JwtAuthProvider,
|
||||||
|
validateAuthConfig,
|
||||||
|
type ControlPlaneAuthConfig,
|
||||||
|
} from '../src/common/auth-provider.js';
|
||||||
|
|
||||||
|
const secret = 'test-only-secret-with-sufficient-length';
|
||||||
|
const baseConfig: ControlPlaneAuthConfig = {
|
||||||
|
mode: 'jwt',
|
||||||
|
profile: 'test',
|
||||||
|
bind: '0.0.0.0',
|
||||||
|
issuer: 'https://issuer.test',
|
||||||
|
audience: 'casan-control-plane',
|
||||||
|
algorithm: 'HS256',
|
||||||
|
hmacSecret: secret,
|
||||||
|
clockSkewSeconds: 30,
|
||||||
|
roleClaim: 'groups',
|
||||||
|
tenantClaim: 'casan_tenant',
|
||||||
|
projectClaim: 'casan_project',
|
||||||
|
};
|
||||||
|
|
||||||
|
function token(payload: Record<string, unknown>, signingSecret = secret): string {
|
||||||
|
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
|
||||||
|
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||||
|
const signature = createHmac('sha256', signingSecret).update(`${header}.${body}`).digest('base64url');
|
||||||
|
return `${header}.${body}.${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rsToken(payload: Record<string, unknown>, privateKey: string): string {
|
||||||
|
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
|
||||||
|
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||||
|
const signer = createSign('RSA-SHA256');
|
||||||
|
signer.update(`${header}.${body}`);
|
||||||
|
signer.end();
|
||||||
|
return `${header}.${body}.${signer.sign(privateKey).toString('base64url')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function claims(now: number, overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
iss: baseConfig.issuer,
|
||||||
|
aud: baseConfig.audience,
|
||||||
|
sub: 'verified-user',
|
||||||
|
exp: now + 300,
|
||||||
|
iat: now,
|
||||||
|
groups: ['project-admin'],
|
||||||
|
casan_tenant: 'tenant-a',
|
||||||
|
casan_project: 'project-a',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('JWT provider verifies claims and ignores spoofed forwarded identity headers', () => {
|
||||||
|
const now = 1_800_000_000;
|
||||||
|
const provider = new JwtAuthProvider(baseConfig);
|
||||||
|
const decision = provider.authenticate({
|
||||||
|
authorization: `Bearer ${token(claims(now))}`,
|
||||||
|
'x-forwarded-user': 'attacker',
|
||||||
|
'x-casan-role': 'org-admin',
|
||||||
|
'x-casan-tenant': 'victim',
|
||||||
|
}, now);
|
||||||
|
assert.equal(decision.allowed, true);
|
||||||
|
assert.equal(decision.claims?.subject, 'verified-user');
|
||||||
|
assert.equal(decision.claims?.tenant, 'tenant-a');
|
||||||
|
assert.deepEqual(decision.claims?.roles, ['project-admin']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('JWT provider rejects missing, invalid, expired, wrong-audience and wrong-issuer tokens', () => {
|
||||||
|
const now = 1_800_000_000;
|
||||||
|
const provider = new JwtAuthProvider(baseConfig);
|
||||||
|
assert.equal(provider.authenticate({}, now).reasonCode, 'auth_bearer_token_required');
|
||||||
|
assert.equal(provider.authenticate({ authorization: `Bearer ${token(claims(now), 'wrong-secret')}` }, now).reasonCode, 'auth_signature_invalid');
|
||||||
|
assert.equal(provider.authenticate({ authorization: `Bearer ${token(claims(now, { exp: now - 31 }))}` }, now).reasonCode, 'auth_token_expired');
|
||||||
|
assert.equal(provider.authenticate({ authorization: `Bearer ${token(claims(now, { aud: 'wrong' }))}` }, now).reasonCode, 'auth_audience_invalid');
|
||||||
|
assert.equal(provider.authenticate({ authorization: `Bearer ${token(claims(now, { iss: 'https://wrong.test' }))}` }, now).reasonCode, 'auth_issuer_invalid');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('production-compatible RS256 verification accepts a valid asymmetric token', () => {
|
||||||
|
const now = 1_800_000_000;
|
||||||
|
const keys = generateKeyPairSync('rsa', {
|
||||||
|
modulusLength: 2048,
|
||||||
|
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||||
|
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||||
|
});
|
||||||
|
const provider = new JwtAuthProvider({
|
||||||
|
...baseConfig,
|
||||||
|
profile: 'production',
|
||||||
|
algorithm: 'RS256',
|
||||||
|
publicKey: keys.publicKey,
|
||||||
|
hmacSecret: undefined,
|
||||||
|
});
|
||||||
|
const decision = provider.authenticate({ authorization: `Bearer ${rsToken(claims(now), keys.privateKey)}` }, now);
|
||||||
|
assert.equal(decision.allowed, true);
|
||||||
|
assert.equal(decision.claims?.subject, 'verified-user');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('production and non-loopback startup refuse local or symmetric identity modes', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
validateAuthConfig({ ...baseConfig, mode: 'local', profile: 'production' }),
|
||||||
|
['verified_identity_required'],
|
||||||
|
);
|
||||||
|
assert.ok(validateAuthConfig({ ...baseConfig, profile: 'production' }).includes('auth_asymmetric_key_required_in_production'));
|
||||||
|
assert.ok(validateAuthConfig({ ...baseConfig, mode: 'local', profile: 'development' }).includes('verified_identity_required'));
|
||||||
|
assert.ok(validateAuthConfig({ ...baseConfig, mode: 'local', profile: 'strict', bind: '127.0.0.1' }).includes('verified_identity_required'));
|
||||||
|
assert.ok(validateAuthConfig({
|
||||||
|
...baseConfig, profile: 'production', algorithm: 'RS256', publicKey: 'not-a-public-key', hmacSecret: undefined,
|
||||||
|
}).includes('auth_rsa_public_key_invalid'));
|
||||||
|
});
|
||||||
@@ -145,6 +145,8 @@ test('HTML export is standalone, escaped and contains no hard-coded maturity sco
|
|||||||
assert.doesNotMatch(html, /Average\s+\d|\/100|218 core tests/i);
|
assert.doesNotMatch(html, /Average\s+\d|\/100|218 core tests/i);
|
||||||
assert.match(html, /No maturity score or telemetry value is hard-coded/);
|
assert.match(html, /No maturity score or telemetry value is hard-coded/);
|
||||||
assert.match(html, /Operational evidence, ready for a decision/);
|
assert.match(html, /Operational evidence, ready for a decision/);
|
||||||
|
assert.match(html, /Run distribution/);
|
||||||
|
assert.match(html, /<details class="technical">/);
|
||||||
assert.match(html, /Evidence provenance/);
|
assert.match(html, /Evidence provenance/);
|
||||||
assert.match(html, /@page\{size:A4/);
|
assert.match(html, /@page\{size:A4/);
|
||||||
});
|
});
|
||||||
@@ -192,6 +194,7 @@ test('per-run assurance export carries H1-H7 and truthful H6 availability', () =
|
|||||||
assert.match(html, /Live assurance rail · H1 → H7/);
|
assert.match(html, /Live assurance rail · H1 → H7/);
|
||||||
assert.match(html, /Evidence fields/);
|
assert.match(html, /Evidence fields/);
|
||||||
assert.match(html, /Decision and evidence detail/);
|
assert.match(html, /Decision and evidence detail/);
|
||||||
|
assert.match(html, /<details class="technical">/);
|
||||||
assert.match(html, /@page\{size:A4/);
|
assert.match(html, /@page\{size:A4/);
|
||||||
assert.match(html, /Unavailable/);
|
assert.match(html, /Unavailable/);
|
||||||
assert.doesNotMatch(html, /\$0(?:\.0+)?/);
|
assert.doesNotMatch(html, /\$0(?:\.0+)?/);
|
||||||
|
|||||||
@@ -172,5 +172,80 @@ export function CoverageBar({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DistributionBars({
|
||||||
|
label,
|
||||||
|
rows,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
rows: Array<{
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
detail?: ReactNode;
|
||||||
|
tone?: Tone;
|
||||||
|
}>;
|
||||||
|
}) {
|
||||||
|
const maximum = Math.max(...rows.map((row) => row.value), 1);
|
||||||
|
return (
|
||||||
|
<div role="img" aria-label={label}>
|
||||||
|
<ol className="space-y-4">
|
||||||
|
{rows.map((row) => {
|
||||||
|
const bounded = Math.max(row.value, 0);
|
||||||
|
const width = bounded === 0 ? 0 : Math.max((bounded / maximum) * 100, 3);
|
||||||
|
const tone = row.tone ?? 'info';
|
||||||
|
return (
|
||||||
|
<li key={row.label}>
|
||||||
|
<div className="flex items-end justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-xs font-semibold text-slate-800">{row.label}</p>
|
||||||
|
{row.detail && <div className="mt-1 text-[11px] text-slate-500">{row.detail}</div>}
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 font-mono text-xs font-semibold text-slate-900">{integerLabel(bounded)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-2 overflow-hidden rounded-full bg-slate-100" aria-hidden="true">
|
||||||
|
<div className={`h-full rounded-full ${TONE[tone].line}`} style={{ width: `${width}%` }} />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
{rows.length === 0 && <p className="text-sm text-slate-500">No measured distribution is available.</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function integerLabel(value: number): string {
|
||||||
|
return new Intl.NumberFormat('en-US').format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DisclosurePanel({
|
||||||
|
summary,
|
||||||
|
description,
|
||||||
|
badge,
|
||||||
|
children,
|
||||||
|
open = false,
|
||||||
|
}: {
|
||||||
|
summary: string;
|
||||||
|
description: string;
|
||||||
|
badge?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
open?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<details open={open} className="group overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-[0_10px_28px_rgba(15,23,42,0.04)]">
|
||||||
|
<summary className="flex min-h-16 cursor-pointer list-none items-center justify-between gap-4 px-5 py-4 outline-none transition hover:bg-slate-50 focus-visible:ring-4 focus-visible:ring-inset focus-visible:ring-cyan-100 [&::-webkit-details-marker]:hidden sm:px-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-slate-900">{summary}</p>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-slate-500">{description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-3">
|
||||||
|
{badge}
|
||||||
|
<span className="select-none text-lg text-slate-400 transition-transform group-open:rotate-45" aria-hidden="true">+</span>
|
||||||
|
</div>
|
||||||
|
</summary>
|
||||||
|
<div className="border-t border-slate-100 p-5 sm:p-6">{children}</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const reportActionPrimary = 'inline-flex min-h-11 items-center justify-center rounded-xl bg-white px-4 py-2.5 text-xs font-bold text-slate-950 shadow-sm transition hover:-translate-y-px hover:bg-cyan-50 focus:outline-none focus:ring-2 focus:ring-cyan-300';
|
export const reportActionPrimary = 'inline-flex min-h-11 items-center justify-center rounded-xl bg-white px-4 py-2.5 text-xs font-bold text-slate-950 shadow-sm transition hover:-translate-y-px hover:bg-cyan-50 focus:outline-none focus:ring-2 focus:ring-cyan-300';
|
||||||
export const reportActionSecondary = 'inline-flex min-h-11 items-center justify-center rounded-xl border border-slate-700 bg-slate-900/70 px-4 py-2.5 text-xs font-bold text-slate-200 transition hover:-translate-y-px hover:border-slate-500 hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-cyan-400';
|
export const reportActionSecondary = 'inline-flex min-h-11 items-center justify-center rounded-xl border border-slate-700 bg-slate-900/70 px-4 py-2.5 text-xs font-bold text-slate-200 transition hover:-translate-y-px hover:border-slate-500 hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-cyan-400';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { api, runReportExportUrl, type HarnessGateNode, type HarnessTraceGraph } from '../../lib/api';
|
import { api, runReportExportUrl, type HarnessGateNode, type HarnessTraceGraph } from '../../lib/api';
|
||||||
import { ReportPanel } from '../report/ReportPrimitives';
|
import { DisclosurePanel, ReportPanel } from '../report/ReportPrimitives';
|
||||||
import { StatusBadge } from '../ui/Card';
|
import { StatusBadge } from '../ui/Card';
|
||||||
import { AssuranceRail } from './AssuranceRail';
|
import { AssuranceRail } from './AssuranceRail';
|
||||||
|
|
||||||
@@ -35,8 +35,10 @@ function GateDossier({ node }: { node: HarnessGateNode }) {
|
|||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-sm font-medium leading-6 text-slate-800">{node.reason || 'No decision reason was emitted.'}</p>
|
<p className="mt-3 text-sm font-medium leading-6 text-slate-800">{node.reason || 'No decision reason was emitted.'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-slate-200 bg-white p-4">
|
<DisclosurePanel
|
||||||
<p className="text-[10px] font-black uppercase tracking-[0.15em] text-slate-500">Event history</p>
|
summary="Event history"
|
||||||
|
description={`${node.events.length} sanitized lifecycle events · hidden by default`}
|
||||||
|
>
|
||||||
<div className="mt-3 space-y-3">
|
<div className="mt-3 space-y-3">
|
||||||
{node.events.slice(-4).reverse().map((event, index) => (
|
{node.events.slice(-4).reverse().map((event, index) => (
|
||||||
<div key={`${event.timestamp}-${index}`} className="grid grid-cols-[0.65rem_minmax(0,1fr)] gap-3">
|
<div key={`${event.timestamp}-${index}`} className="grid grid-cols-[0.65rem_minmax(0,1fr)] gap-3">
|
||||||
@@ -55,33 +57,30 @@ function GateDossier({ node }: { node: HarnessGateNode }) {
|
|||||||
))}
|
))}
|
||||||
{node.events.length === 0 && <p className="text-sm text-slate-500">No individual event was emitted for this control.</p>}
|
{node.events.length === 0 && <p className="text-sm text-slate-500">No individual event was emitted for this control.</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DisclosurePanel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-2xl border border-slate-800 bg-slate-950 text-slate-100 shadow-[0_18px_50px_rgba(15,23,42,0.18)]">
|
<DisclosurePanel
|
||||||
<div className="flex items-center justify-between gap-3 border-b border-slate-800 px-5 py-4">
|
summary="Safe evidence manifest"
|
||||||
<div>
|
description="Sanitized fields persisted by the harness · hidden by default"
|
||||||
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-cyan-300">Safe evidence manifest</p>
|
badge={<span className="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 font-mono text-[10px] text-slate-500">{entries.length} fields</span>}
|
||||||
<p className="mt-1 text-xs text-slate-500">Sanitized fields persisted by the harness</p>
|
>
|
||||||
</div>
|
|
||||||
<span className="rounded-full border border-slate-700 bg-slate-900 px-2.5 py-1 font-mono text-[10px] text-slate-400">{entries.length} fields</span>
|
|
||||||
</div>
|
|
||||||
{entries.length > 0 ? (
|
{entries.length > 0 ? (
|
||||||
<dl className="divide-y divide-slate-800">
|
<dl className="-m-5 divide-y divide-slate-100 sm:-m-6">
|
||||||
{entries.map(([key, value]) => (
|
{entries.map(([key, value]) => (
|
||||||
<div key={key} className="grid gap-1 px-5 py-3.5 sm:grid-cols-[10rem_minmax(0,1fr)] sm:gap-4">
|
<div key={key} className="grid gap-1 px-5 py-3.5 sm:grid-cols-[10rem_minmax(0,1fr)] sm:gap-4">
|
||||||
<dt className="font-mono text-[10px] font-semibold uppercase tracking-[0.08em] text-slate-500">{key}</dt>
|
<dt className="font-mono text-[10px] font-semibold uppercase tracking-[0.08em] text-slate-500">{key}</dt>
|
||||||
<dd className="break-all font-mono text-xs leading-5 text-slate-200"><EvidenceValue value={value} /></dd>
|
<dd className="break-all font-mono text-xs leading-5 text-slate-700"><EvidenceValue value={value} /></dd>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
) : (
|
) : (
|
||||||
<div className="px-5 py-12 text-center">
|
<div className="px-5 py-12 text-center">
|
||||||
<div className="mx-auto flex h-10 w-10 items-center justify-center rounded-full border border-slate-700 text-slate-500">∅</div>
|
<div className="mx-auto flex h-10 w-10 items-center justify-center rounded-full border border-slate-200 text-slate-400">∅</div>
|
||||||
<p className="mt-3 text-sm text-slate-400">No safe evidence fields are available yet.</p>
|
<p className="mt-3 text-sm text-slate-500">No safe evidence fields are available yet.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</DisclosurePanel>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { useMemo, useState, type FormEvent, type ReactNode } from 'react';
|
|||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
CoverageBar,
|
CoverageBar,
|
||||||
|
DisclosurePanel,
|
||||||
|
DistributionBars,
|
||||||
ReportHero,
|
ReportHero,
|
||||||
ReportPanel,
|
ReportPanel,
|
||||||
SignalMetric,
|
SignalMetric,
|
||||||
@@ -140,11 +142,10 @@ export function H6ReportPage() {
|
|||||||
) : undefined}
|
) : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ReportPanel
|
<DisclosurePanel
|
||||||
eyebrow="Scope"
|
summary="Filter the evidence boundary"
|
||||||
title="Evidence boundary"
|
description="Project, date and trace filters stay out of the executive view until they are needed."
|
||||||
description="Filters are applied server-side to the screen, JSON contract and print-ready dossier."
|
badge={Object.values(draft).some(Boolean) ? <span className="rounded-full bg-cyan-50 px-3 py-1 text-[10px] font-black uppercase tracking-[0.12em] text-cyan-700">Filtered</span> : <span className="text-xs text-slate-400">All evidence</span>}
|
||||||
right={Object.values(draft).some(Boolean) ? <span className="rounded-full bg-cyan-50 px-3 py-1 text-[10px] font-black uppercase tracking-[0.12em] text-cyan-700">Filtered view</span> : <span className="text-xs text-slate-400">All available evidence</span>}
|
|
||||||
>
|
>
|
||||||
<form onSubmit={applyFilters} className="grid gap-4 md:grid-cols-2 xl:grid-cols-[1fr_1fr_1fr_1.45fr_auto] xl:items-end">
|
<form onSubmit={applyFilters} className="grid gap-4 md:grid-cols-2 xl:grid-cols-[1fr_1fr_1fr_1.45fr_auto] xl:items-end">
|
||||||
<FilterField label="Project">
|
<FilterField label="Project">
|
||||||
@@ -168,7 +169,7 @@ export function H6ReportPage() {
|
|||||||
<button type="button" onClick={clearFilters} className="min-h-11 rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm font-bold text-slate-600 transition hover:bg-slate-50">Clear</button>
|
<button type="button" onClick={clearFilters} className="min-h-11 rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm font-bold text-slate-600 transition hover:bg-slate-50">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</ReportPanel>
|
</DisclosurePanel>
|
||||||
|
|
||||||
{report.isLoading && <LoadingReport />}
|
{report.isLoading && <LoadingReport />}
|
||||||
{report.isError && (
|
{report.isError && (
|
||||||
@@ -187,24 +188,17 @@ export function H6ReportPage() {
|
|||||||
<SignalMetric label="Telemetry integrity" value={data.data_quality.status.replaceAll('_', ' ')} detail={`${data.summary.coverage.token_pct}% token · ${data.summary.coverage.cost_pct}% cost coverage`} tone={reportTone(data.data_quality.status)} primary />
|
<SignalMetric label="Telemetry integrity" value={data.data_quality.status.replaceAll('_', ' ')} detail={`${data.summary.coverage.token_pct}% token · ${data.summary.coverage.cost_pct}% cost coverage`} tone={reportTone(data.data_quality.status)} primary />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid gap-3 rounded-2xl border border-slate-200 bg-white p-4 shadow-[0_12px_32px_rgba(15,23,42,0.04)] sm:grid-cols-2 lg:grid-cols-4">
|
<DisclosurePanel
|
||||||
<div className="border-b border-slate-100 p-2 sm:border-b-0 sm:border-r">
|
summary="Provider telemetry"
|
||||||
<p className="text-[10px] font-black uppercase tracking-[0.14em] text-slate-400">Provider tokens</p>
|
description="Token, cost, call and retry counters are technical evidence, hidden by default."
|
||||||
<p className="mt-2 font-mono text-lg font-semibold text-slate-900">{data.summary.coverage.token_records > 0 ? integer(data.summary.tokens.provider_total ?? data.summary.tokens.total ?? 0) : 'Unavailable'}</p>
|
>
|
||||||
</div>
|
<section className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<div className="border-b border-slate-100 p-2 sm:border-b-0 lg:border-r">
|
<SignalMetric label="Provider tokens" value={data.summary.coverage.token_records > 0 ? integer(data.summary.tokens.provider_total ?? data.summary.tokens.total ?? 0) : 'Unavailable'} detail={`${data.summary.coverage.token_pct}% record coverage`} tone={data.summary.coverage.token_records > 0 ? 'info' : 'warning'} />
|
||||||
<p className="text-[10px] font-black uppercase tracking-[0.14em] text-slate-400">Actual cost</p>
|
<SignalMetric label="Actual cost" value={data.summary.cost_usd.provider_actual !== null ? money(data.summary.cost_usd.provider_actual) : 'Unavailable'} detail={`${data.summary.coverage.cost_pct}% record coverage`} tone={data.summary.cost_usd.provider_actual !== null ? 'info' : 'warning'} />
|
||||||
<p className="mt-2 font-mono text-lg font-semibold text-slate-900">{data.summary.cost_usd.provider_actual !== null ? money(data.summary.cost_usd.provider_actual) : 'Unavailable'}</p>
|
<SignalMetric label="Provider calls" value={integer(data.summary.provider_calls)} detail="Provider-attributed requests" />
|
||||||
</div>
|
<SignalMetric label="Retries" value={integer(data.summary.retries)} detail="Observed reroute attempts" tone={data.summary.retries > 0 ? 'warning' : 'neutral'} />
|
||||||
<div className="border-b border-slate-100 p-2 sm:border-b-0 sm:border-r">
|
</section>
|
||||||
<p className="text-[10px] font-black uppercase tracking-[0.14em] text-slate-400">Provider calls</p>
|
</DisclosurePanel>
|
||||||
<p className="mt-2 font-mono text-lg font-semibold text-slate-900">{integer(data.summary.provider_calls)}</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-2">
|
|
||||||
<p className="text-[10px] font-black uppercase tracking-[0.14em] text-slate-400">Retries</p>
|
|
||||||
<p className="mt-2 font-mono text-lg font-semibold text-slate-900">{integer(data.summary.retries)}</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="grid gap-5 xl:grid-cols-[1.18fr_0.82fr]">
|
<div className="grid gap-5 xl:grid-cols-[1.18fr_0.82fr]">
|
||||||
<ReportPanel
|
<ReportPanel
|
||||||
@@ -236,37 +230,20 @@ export function H6ReportPage() {
|
|||||||
</ReportPanel>
|
</ReportPanel>
|
||||||
|
|
||||||
<ReportPanel
|
<ReportPanel
|
||||||
eyebrow="Source integrity"
|
eyebrow="Outcome mix"
|
||||||
title="Evidence provenance"
|
title="Run distribution"
|
||||||
description="Freshness is evaluated independently for every canonical source."
|
description="Counts are grouped from canonical runtime status values; the longest bar is the largest observed group."
|
||||||
right={<StatusBadge value={data.freshness.status} />}
|
right={<span className="font-mono text-xs text-slate-400">{integer(data.summary.runs)} total</span>}
|
||||||
>
|
>
|
||||||
<div className="space-y-3">
|
<DistributionBars
|
||||||
{data.evidence_sources.map((source) => {
|
label="Governed run outcome distribution"
|
||||||
const state = !source.present ? 'missing' : source.stale ? 'stale' : 'fresh';
|
rows={data.details.by_status.map((row) => ({
|
||||||
return (
|
label: row.status.replaceAll('_', ' '),
|
||||||
<article key={source.source} className="rounded-xl border border-slate-200 p-4">
|
value: row.count,
|
||||||
<div className="flex items-start justify-between gap-4">
|
detail: `${data.summary.runs ? Math.round((row.count / data.summary.runs) * 100) : 0}% of selected runs`,
|
||||||
<div>
|
tone: row.status === 'failed' ? 'danger' as const : row.status === 'degraded' ? 'warning' as const : 'success' as const,
|
||||||
<h3 className="text-sm font-semibold capitalize text-slate-900">{source.source}</h3>
|
}))}
|
||||||
<p className="mt-1 font-mono text-[10px] text-slate-400">{source.records} records · {source.age_s === null ? 'no timestamp' : `${integer(source.age_s)}s old`}</p>
|
/>
|
||||||
</div>
|
|
||||||
<StatusBadge value={state} />
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 break-all border-t border-slate-100 pt-3 font-mono text-[10px] leading-4 text-slate-400">{source.path}</p>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</ReportPanel>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-5 xl:grid-cols-2">
|
|
||||||
<ReportPanel eyebrow="Runtime" title="Execution profile by step" description="Latency, failure and attribution by governed lifecycle step.">
|
|
||||||
<BreakdownTable rows={data.details.by_step} subject="Step" />
|
|
||||||
</ReportPanel>
|
|
||||||
<ReportPanel eyebrow="Provider" title="Model usage and provenance" description="Only provider-reported token and actual cost values are shown.">
|
|
||||||
<BreakdownTable rows={data.details.by_provider} subject="Provider · model" />
|
|
||||||
</ReportPanel>
|
</ReportPanel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -297,17 +274,47 @@ export function H6ReportPage() {
|
|||||||
</div>
|
</div>
|
||||||
</ReportPanel>
|
</ReportPanel>
|
||||||
|
|
||||||
<div className="rounded-2xl border border-slate-800 bg-slate-950 p-5 text-white shadow-[0_18px_48px_rgba(15,23,42,0.16)] sm:flex sm:items-center sm:justify-between sm:gap-6 sm:p-6">
|
<DisclosurePanel
|
||||||
<div>
|
summary="Technical evidence and breakdowns"
|
||||||
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-cyan-300">Independent evidence package</p>
|
description="Canonical paths, step tables and provider attribution are preserved for audit without crowding the decision view."
|
||||||
<h2 className="mt-2 text-lg font-semibold">Same contract. Two review formats.</h2>
|
badge={<StatusBadge value={data.freshness.status} />}
|
||||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-400">JSON is machine-auditable. HTML is a self-contained, print-ready assurance dossier generated only when requested.</p>
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section>
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-[0.16em] text-cyan-700">Source integrity</p>
|
||||||
|
<h3 className="mt-1 text-base font-semibold text-slate-900">Evidence provenance</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 lg:grid-cols-2">
|
||||||
|
{data.evidence_sources.map((source) => {
|
||||||
|
const state = !source.present ? 'missing' : source.stale ? 'stale' : 'fresh';
|
||||||
|
return (
|
||||||
|
<article key={source.source} className="rounded-xl border border-slate-200 p-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold capitalize text-slate-900">{source.source}</h4>
|
||||||
|
<p className="mt-1 font-mono text-[10px] text-slate-400">{source.records} records · {source.age_s === null ? 'no timestamp' : `${integer(source.age_s)}s old`}</p>
|
||||||
|
</div>
|
||||||
|
<StatusBadge value={state} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 break-all border-t border-slate-100 pt-3 font-mono text-[10px] leading-4 text-slate-400">{source.path}</p>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div className="grid gap-5 xl:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-3 text-sm font-semibold text-slate-900">Execution profile by step</h3>
|
||||||
|
<BreakdownTable rows={data.details.by_step} subject="Step" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-3 text-sm font-semibold text-slate-900">Model usage and provenance</h3>
|
||||||
|
<BreakdownTable rows={data.details.by_provider} subject="Provider · model" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 flex shrink-0 flex-wrap gap-2 sm:mt-0">
|
</DisclosurePanel>
|
||||||
<a href={h6ReportExportUrl(query, 'json')} className={reportActionSecondary}>Download JSON</a>
|
|
||||||
<a href={h6ReportExportUrl(query, 'html')} className={reportActionPrimary}>Download HTML</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ if [[ ! -f "$TLS_DIR/tls.crt" || ! -f "$TLS_DIR/tls.key" ]]; then
|
|||||||
-subj "/CN=localhost" \
|
-subj "/CN=localhost" \
|
||||||
-days 1 >/dev/null 2>&1
|
-days 1 >/dev/null 2>&1
|
||||||
fi
|
fi
|
||||||
|
IDP_PRIVATE="$ROOT/tmp/control-panel-local/idp-private.pem"
|
||||||
|
IDP_PUBLIC="$ROOT/tmp/control-panel-local/idp-public.pem"
|
||||||
|
if [[ ! -f "$IDP_PRIVATE" || ! -f "$IDP_PUBLIC" ]]; then
|
||||||
|
openssl genrsa -out "$IDP_PRIVATE" 2048 >/dev/null 2>&1
|
||||||
|
openssl rsa -in "$IDP_PRIVATE" -pubout -out "$IDP_PUBLIC" >/dev/null 2>&1
|
||||||
|
chmod 0600 "$IDP_PRIVATE"
|
||||||
|
chmod 0644 "$IDP_PUBLIC"
|
||||||
|
fi
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
docker compose -f "$COMPOSE" down --remove-orphans >/dev/null 2>&1 || true
|
docker compose -f "$COMPOSE" down --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
@@ -160,6 +161,9 @@ def _render_init(result):
|
|||||||
("Clients", _client_names(result["clients"])),
|
("Clients", _client_names(result["clients"])),
|
||||||
("Files", "%d created or updated" % len(result["created"])),
|
("Files", "%d created or updated" % len(result["created"])),
|
||||||
("Harness", result["harness_version"]),
|
("Harness", result["harness_version"]),
|
||||||
|
("Core readiness", result["readiness"]["dimensions"]["core"]["status"].replace("_", " ")),
|
||||||
|
("Domain Pipeline", result["readiness"]["dimensions"]["domain_pipeline"]["status"].replace("_", " ")),
|
||||||
|
("Provider Telemetry", result["readiness"]["dimensions"]["provider_telemetry"]["status"].replace("_", " ")),
|
||||||
])
|
])
|
||||||
print()
|
print()
|
||||||
print(_color("1", "Integrations"))
|
print(_color("1", "Integrations"))
|
||||||
@@ -178,9 +182,13 @@ def _render_init(result):
|
|||||||
_mark(ok), "VS Code extension", extension.get("status")))
|
_mark(ok), "VS Code extension", extension.get("status")))
|
||||||
print()
|
print()
|
||||||
print(_color("1", "Next steps"))
|
print(_color("1", "Next steps"))
|
||||||
print(" 1. Run `casan doctor`")
|
actions = result["readiness"].get("next_actions") or []
|
||||||
if "codex" in result["clients"]:
|
steps = [action["message"] for action in actions]
|
||||||
print(" 2. In a local Codex client, open `/hooks` and trust this project's hook.")
|
steps.append("Run `casan doctor` for the deep integrity and client smoke check.")
|
||||||
|
steps.append(
|
||||||
|
"After a governed prompt, run `casan view` to inspect its H1–H7 evidence.")
|
||||||
|
for index, step in enumerate(steps, 1):
|
||||||
|
print(" %d. %s" % (index, step))
|
||||||
|
|
||||||
|
|
||||||
def _render_verify(result):
|
def _render_verify(result):
|
||||||
@@ -1221,6 +1229,7 @@ def cmd_init(args):
|
|||||||
cfg = {
|
cfg = {
|
||||||
"schema_version": "21.3",
|
"schema_version": "21.3",
|
||||||
"project_id": project,
|
"project_id": project,
|
||||||
|
"project_name": project,
|
||||||
"created_at": now_iso(),
|
"created_at": now_iso(),
|
||||||
"enforcement_mode": args.mode,
|
"enforcement_mode": args.mode,
|
||||||
"integration_mode": args.integration_mode,
|
"integration_mode": args.integration_mode,
|
||||||
@@ -1413,6 +1422,40 @@ def cmd_init(args):
|
|||||||
newly_owned_files.update(domain_files)
|
newly_owned_files.update(domain_files)
|
||||||
level_extras.append("apps/%s/domain (%d files)" % (project, n))
|
level_extras.append("apps/%s/domain (%d files)" % (project, n))
|
||||||
|
|
||||||
|
# ── Commercial readiness contract.
|
||||||
|
# Core acceptance never invents or modifies application-domain inputs.
|
||||||
|
# Domain Pipeline and Provider Telemetry have independent readiness states.
|
||||||
|
readiness_module_path = os.path.join(
|
||||||
|
active_harness, "scripts", "python", "readiness.py")
|
||||||
|
if not os.path.isfile(readiness_module_path):
|
||||||
|
sys.stderr.write(
|
||||||
|
"casan init: resolved Core is missing the readiness contract\n")
|
||||||
|
return 1
|
||||||
|
readiness_state_paths = (
|
||||||
|
os.path.join(target, ".casan", "discovery.json"),
|
||||||
|
os.path.join(target, ".casan", "readiness.json"),
|
||||||
|
)
|
||||||
|
for state_path in readiness_state_paths:
|
||||||
|
mark_owned_if_absent(state_path)
|
||||||
|
try:
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"casan_runtime_readiness", readiness_module_path)
|
||||||
|
readiness_module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(readiness_module)
|
||||||
|
client_state = {}
|
||||||
|
if "codex" in clients:
|
||||||
|
client_state["codex"] = _codex_project_hook_trust(
|
||||||
|
target, timeout=3)
|
||||||
|
readiness = readiness_module.assess(
|
||||||
|
target, persist=True, client_state=client_state)
|
||||||
|
except (AttributeError, OSError, TypeError, ValueError) as error:
|
||||||
|
sys.stderr.write(
|
||||||
|
"casan init: cannot generate commercial readiness state: %s\n" %
|
||||||
|
error)
|
||||||
|
return 1
|
||||||
|
for state_path in readiness_state_paths:
|
||||||
|
created_add(state_path)
|
||||||
|
|
||||||
# ── manifest (so uninstall/verify know what init created) ──
|
# ── manifest (so uninstall/verify know what init created) ──
|
||||||
owned_file_hashes = {
|
owned_file_hashes = {
|
||||||
path: digest
|
path: digest
|
||||||
@@ -1460,6 +1503,7 @@ def cmd_init(args):
|
|||||||
"level_extras": level_extras,
|
"level_extras": level_extras,
|
||||||
"level_removed": level_removed,
|
"level_removed": level_removed,
|
||||||
"level_retained": level_retained,
|
"level_retained": level_retained,
|
||||||
|
"readiness": readiness,
|
||||||
"note": (
|
"note": (
|
||||||
"managed runtime is referenced by version/hash lock"
|
"managed runtime is referenced by version/hash lock"
|
||||||
if runtime_mode == "managed" else
|
if runtime_mode == "managed" else
|
||||||
@@ -1910,6 +1954,18 @@ def cmd_doctor(args):
|
|||||||
ready = ready and client_ready
|
ready = ready and client_ready
|
||||||
checks["client_checks"][client] = item
|
checks["client_checks"][client] = item
|
||||||
|
|
||||||
|
for client, item in checks["client_checks"].items():
|
||||||
|
if item.get("ready") or client == "codex":
|
||||||
|
continue
|
||||||
|
checks["required_actions"].append({
|
||||||
|
"code": "%s_activation" % client.replace("-", "_"),
|
||||||
|
"message": (
|
||||||
|
item.get("install_action")
|
||||||
|
or "Repair the selected %s client route, then run `casan doctor`."
|
||||||
|
% CLIENT_LABELS.get(client, client)
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
if "codex" in clients:
|
if "codex" in clients:
|
||||||
codex_check = checks["client_checks"].get("codex", {})
|
codex_check = checks["client_checks"].get("codex", {})
|
||||||
trust = codex_check.get("trust", {})
|
trust = codex_check.get("trust", {})
|
||||||
@@ -1964,6 +2020,31 @@ def cmd_doctor(args):
|
|||||||
else "user_action_required" if checks["required_actions"]
|
else "user_action_required" if checks["required_actions"]
|
||||||
else "ready"
|
else "ready"
|
||||||
)
|
)
|
||||||
|
if harness:
|
||||||
|
readiness_module_path = os.path.join(
|
||||||
|
harness, "scripts", "python", "readiness.py")
|
||||||
|
try:
|
||||||
|
if not os.path.isfile(readiness_module_path):
|
||||||
|
raise FileNotFoundError
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"casan_doctor_readiness", readiness_module_path)
|
||||||
|
readiness_module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(readiness_module)
|
||||||
|
readiness_clients = {
|
||||||
|
client: {
|
||||||
|
**(item.get("trust", {}) if client == "codex" else {}),
|
||||||
|
"operational": bool(item.get("ready")),
|
||||||
|
}
|
||||||
|
for client, item in checks["client_checks"].items()
|
||||||
|
}
|
||||||
|
checks["readiness"] = readiness_module.assess(
|
||||||
|
target, persist=True, client_state=readiness_clients)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
except (AttributeError, OSError, TypeError, ValueError) as error:
|
||||||
|
checks["warnings"].append(
|
||||||
|
"Commercial readiness state could not be refreshed: %s." %
|
||||||
|
error)
|
||||||
_emit_json_or_human(args, checks, _render_doctor)
|
_emit_json_or_human(args, checks, _render_doctor)
|
||||||
return 0 if ready else 2
|
return 0 if ready else 2
|
||||||
|
|
||||||
@@ -2150,6 +2231,9 @@ def cmd_uninstall(args):
|
|||||||
".casan/version.lock",
|
".casan/version.lock",
|
||||||
".casan/agentic.env",
|
".casan/agentic.env",
|
||||||
".casan/casan-hook.py",
|
".casan/casan-hook.py",
|
||||||
|
".casan/domain.json",
|
||||||
|
".casan/discovery.json",
|
||||||
|
".casan/readiness.json",
|
||||||
".casan/init-manifest.json"):
|
".casan/init-manifest.json"):
|
||||||
_remove_owned_file(
|
_remove_owned_file(
|
||||||
os.path.join(target, *relative.split("/")), removed, target)
|
os.path.join(target, *relative.split("/")), removed, target)
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ assert {path.name for path in (root / "packages").iterdir()} == {
|
|||||||
"casan-harness", "casan-devkit",
|
"casan-harness", "casan-devkit",
|
||||||
}
|
}
|
||||||
assert {path.name for path in (root / "packages/casan-harness").iterdir()} == {
|
assert {path.name for path in (root / "packages/casan-harness").iterdir()} == {
|
||||||
"adapters", "agentops", "config", "governance", "memory", "prompts",
|
"adapters", "agentops", "assets", "config", "governance", "memory", "prompts",
|
||||||
"schemas", "scripts", "security", "templates",
|
"schemas", "scripts", "security", "templates",
|
||||||
}
|
}
|
||||||
assert {path.name for path in (root / "packages/casan-devkit").iterdir()} == {
|
assert {path.name for path in (root / "packages/casan-devkit").iterdir()} == {
|
||||||
@@ -70,10 +70,21 @@ for forbidden in (
|
|||||||
):
|
):
|
||||||
assert not (root / forbidden).exists(), forbidden
|
assert not (root / forbidden).exists(), forbidden
|
||||||
for required in (
|
for required in (
|
||||||
|
".codex-plugin/plugin.json",
|
||||||
|
".claude-plugin/plugin.json",
|
||||||
|
".claude-plugin/marketplace.json",
|
||||||
|
"skills/casan/SKILL.md",
|
||||||
|
"packages/casan-harness/assets/local-viewer/index.html",
|
||||||
|
"packages/casan-harness/assets/local-viewer/app.css",
|
||||||
|
"packages/casan-harness/assets/local-viewer/app.js",
|
||||||
"packages/casan-harness/config/project-registry.json",
|
"packages/casan-harness/config/project-registry.json",
|
||||||
"packages/casan-harness/config/tool-registry.yaml",
|
"packages/casan-harness/config/tool-registry.yaml",
|
||||||
"packages/casan-harness/scripts/bash/project-gate.sh",
|
"packages/casan-harness/scripts/bash/project-gate.sh",
|
||||||
"packages/casan-harness/scripts/python/agentic_bridge.py",
|
"packages/casan-harness/scripts/python/agentic_bridge.py",
|
||||||
|
"packages/casan-harness/scripts/python/local_report.py",
|
||||||
|
"packages/casan-harness/scripts/python/local_viewer.py",
|
||||||
|
"packages/casan-harness/scripts/python/report_cli.py",
|
||||||
|
"packages/casan-harness/scripts/python/readiness.py",
|
||||||
):
|
):
|
||||||
assert (root / required).is_file(), required
|
assert (root / required).is_file(), required
|
||||||
registry = json.loads(
|
registry = json.loads(
|
||||||
@@ -138,8 +149,11 @@ for forbidden in (
|
|||||||
):
|
):
|
||||||
assert not any(name == forbidden or name.startswith(forbidden + "/") for name in names), forbidden
|
assert not any(name == forbidden or name.startswith(forbidden + "/") for name in names), forbidden
|
||||||
for required in (
|
for required in (
|
||||||
|
"packages/casan-harness/assets/local-viewer/index.html",
|
||||||
"packages/casan-harness/config/tool-registry.yaml",
|
"packages/casan-harness/config/tool-registry.yaml",
|
||||||
"packages/casan-harness/scripts/bash/casan-harness.sh",
|
"packages/casan-harness/scripts/bash/casan-harness.sh",
|
||||||
|
"packages/casan-harness/scripts/python/local_viewer.py",
|
||||||
|
"packages/casan-harness/scripts/python/readiness.py",
|
||||||
"packaging/runtime-layout.json",
|
"packaging/runtime-layout.json",
|
||||||
"scripts/copy-runtime.py",
|
"scripts/copy-runtime.py",
|
||||||
"install.sh",
|
"install.sh",
|
||||||
@@ -151,9 +165,31 @@ 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 ) \
|
||||||
&& pass "casan init completes" || fail "casan init failed"
|
&& pass "casan init completes" || fail "casan init failed"
|
||||||
for f in .casan/config.json .casan/version.lock .casan/agentic.env .casan/casan-hook.py .claude/settings.json .codex/hooks.json .vscode/extensions.json .specify/.gitignore; do
|
for f in .casan/config.json .casan/version.lock .casan/agentic.env .casan/casan-hook.py .casan/discovery.json .casan/readiness.json .claude/settings.json .codex/hooks.json .vscode/extensions.json .specify/.gitignore; do
|
||||||
[[ -f "$PROJ/$f" ]] && pass "init wrote $f" || fail "init missing $f"
|
[[ -f "$PROJ/$f" ]] && pass "init wrote $f" || fail "init missing $f"
|
||||||
done
|
done
|
||||||
|
python3 - "$PROJ/.casan/readiness.json" <<'PY' \
|
||||||
|
&& pass "Core readiness is independent from optional Domain/Provider states" \
|
||||||
|
|| fail "commercial readiness contract is invalid"
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
d = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||||
|
assert d["dimensions"]["core"]["status"] in {"ready", "ready_with_attention"}
|
||||||
|
assert d["dimensions"]["domain_pipeline"]["status"] == "not_configured"
|
||||||
|
assert d["dimensions"]["provider_telemetry"]["status"] == "optional_unavailable"
|
||||||
|
PY
|
||||||
|
CASAN_APP_ROOT="$PROJ" "$CASAN" pipeline --help 2>&1 \
|
||||||
|
| grep -q "optional Domain Pack" \
|
||||||
|
&& pass "pipeline help describes the optional capability without executing it" \
|
||||||
|
|| fail "pipeline help executed or hid the Core/Domain boundary"
|
||||||
|
set +e
|
||||||
|
PIPELINE_MISSING="$(CASAN_APP_ROOT="$PROJ" "$CASAN" pipeline 2>&1)"
|
||||||
|
PIPELINE_RC=$?
|
||||||
|
set -e
|
||||||
|
[[ "$PIPELINE_RC" -eq 2 && "$PIPELINE_MISSING" == *"CASAN_PIPELINE_NOT_CONFIGURED"* ]] \
|
||||||
|
&& pass "missing Domain Pipeline is explicit and does not impersonate a Core failure" \
|
||||||
|
|| fail "missing Domain Pipeline capability was not reported truthfully"
|
||||||
python3 - "$PROJ/.casan/config.json" <<'PY' \
|
python3 - "$PROJ/.casan/config.json" <<'PY' \
|
||||||
&& pass "config declares supported and excluded client surfaces" \
|
&& pass "config declares supported and excluded client surfaces" \
|
||||||
|| fail "config client surface contract is incomplete"
|
|| fail "config client surface contract is incomplete"
|
||||||
@@ -343,6 +379,12 @@ echo "$VENDORED_OUT" | grep -q "Runtime.*Vendored" \
|
|||||||
( cd "$VENDORED" && "$VENDORED_ROOT/bin/casan" version >/dev/null ) \
|
( cd "$VENDORED" && "$VENDORED_ROOT/bin/casan" version >/dev/null ) \
|
||||||
&& pass "project-vendored Core includes a working local CLI" \
|
&& pass "project-vendored Core includes a working local CLI" \
|
||||||
|| fail "project-vendored CLI cannot resolve its Core runtime"
|
|| fail "project-vendored CLI cannot resolve its Core runtime"
|
||||||
|
printf 'vendored consumer smoke\n' > "$VENDORED/input.txt"
|
||||||
|
( cd "$VENDORED" && "$VENDORED_ROOT/bin/casan" run input.txt output.txt agent_step -- printf 'ok\n' >/dev/null ) \
|
||||||
|
&& [ -s "$VENDORED/.specify/logs/cost/metrics.jsonl" ] \
|
||||||
|
&& [ ! -d "$VENDORED_ROOT/.specify" ] \
|
||||||
|
&& pass "vendored CLI exports the consumer project root to harness commands" \
|
||||||
|
|| fail "vendored CLI wrote governed state inside the runtime bundle"
|
||||||
( cd "$VENDORED" && "$DKC" verify-harness >/dev/null ) \
|
( cd "$VENDORED" && "$DKC" verify-harness >/dev/null ) \
|
||||||
&& pass "global launcher resolves and verifies the project-vendored Core" \
|
&& pass "global launcher resolves and verifies the project-vendored Core" \
|
||||||
|| fail "vendored Core is not honored by the global launcher"
|
|| fail "vendored Core is not honored by the global launcher"
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validate the repository-root CASAN native plugin facades."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
VERSION = (ROOT / "VERSION").read_text(encoding="utf-8").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def load_json(relative_path: str) -> dict[str, object]:
|
||||||
|
payload = json.loads((ROOT / relative_path).read_text(encoding="utf-8"))
|
||||||
|
assert isinstance(payload, dict), relative_path
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
codex = load_json(".codex-plugin/plugin.json")
|
||||||
|
assert codex["name"] == "casan"
|
||||||
|
assert codex["version"] == VERSION
|
||||||
|
assert codex["skills"] == "./skills/"
|
||||||
|
assert "hooks" not in codex
|
||||||
|
assert codex["license"] == "Proprietary"
|
||||||
|
|
||||||
|
interface = codex["interface"]
|
||||||
|
assert isinstance(interface, dict)
|
||||||
|
for field in (
|
||||||
|
"displayName",
|
||||||
|
"shortDescription",
|
||||||
|
"longDescription",
|
||||||
|
"developerName",
|
||||||
|
"category",
|
||||||
|
"capabilities",
|
||||||
|
"defaultPrompt",
|
||||||
|
):
|
||||||
|
assert interface.get(field), field
|
||||||
|
|
||||||
|
claude = load_json(".claude-plugin/plugin.json")
|
||||||
|
assert claude["name"] == "casan"
|
||||||
|
assert claude["version"] == VERSION
|
||||||
|
assert claude["skills"] == ["./skills/"]
|
||||||
|
assert "hooks" not in claude
|
||||||
|
|
||||||
|
marketplace = load_json(".claude-plugin/marketplace.json")
|
||||||
|
plugins = marketplace["plugins"]
|
||||||
|
assert isinstance(plugins, list) and len(plugins) == 1
|
||||||
|
entry = plugins[0]
|
||||||
|
assert isinstance(entry, dict)
|
||||||
|
assert entry["name"] == "casan"
|
||||||
|
assert entry["source"] == "./"
|
||||||
|
assert entry["version"] == VERSION
|
||||||
|
|
||||||
|
skill = (ROOT / "skills/casan/SKILL.md").read_text(encoding="utf-8")
|
||||||
|
assert skill.startswith("---\nname: casan\n")
|
||||||
|
assert "description:" in skill
|
||||||
|
assert "[TODO:" not in skill
|
||||||
|
assert (ROOT / "skills/casan/agents/openai.yaml").is_file()
|
||||||
|
|
||||||
|
levels = load_json("packaging/levels.json")
|
||||||
|
level_map = levels["levels"]
|
||||||
|
assert isinstance(level_map, dict)
|
||||||
|
core = level_map["core"]
|
||||||
|
assert isinstance(core, dict)
|
||||||
|
includes = core["includes"]
|
||||||
|
assert isinstance(includes, list)
|
||||||
|
for required in (".codex-plugin", ".claude-plugin", "skills"):
|
||||||
|
assert required in includes, required
|
||||||
|
|
||||||
|
print(f"NATIVE_PLUGIN_PASS version={VERSION} codex=true claude=true")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -5,6 +5,7 @@ Populated incrementally by Plan-01 (Phase 0→6). During migration, files move h
|
|||||||
`.specify/` wave-by-wave; the full harness gate must stay green (`PASS=64 FAIL=0`) after each phase.
|
`.specify/` wave-by-wave; the full harness gate must stay green (`PASS=64 FAIL=0`) after each phase.
|
||||||
|
|
||||||
Layout:
|
Layout:
|
||||||
|
- `assets/local-viewer/` — production static UI for the Core Local Assurance Viewer
|
||||||
- `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
|
||||||
@@ -12,6 +13,20 @@ Layout:
|
|||||||
- `templates/` — spec/plan templates
|
- `templates/` — spec/plan templates
|
||||||
- `tests/` — source-hub verification only; excluded from production installs
|
- `tests/` — source-hub verification only; excluded from production installs
|
||||||
|
|
||||||
|
Core visual reporting is intentionally lazy and zero-dependency:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
casan report latest
|
||||||
|
casan view
|
||||||
|
casan report export --format html
|
||||||
|
casan report export --h6 --format html
|
||||||
|
```
|
||||||
|
|
||||||
|
Prompt hooks only write canonical evidence and a small receipt. `casan view`
|
||||||
|
starts a loopback-only, token-protected, read-only Python server on demand.
|
||||||
|
No Node/npm, external font/CDN, Platform service or per-prompt HTML generation
|
||||||
|
is required.
|
||||||
|
|
||||||
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
|
||||||
`apps/okr/domain/` under `CASAN_DOMAIN_ROOT`.
|
`apps/okr/domain/` under `CASAN_DOMAIN_ROOT`.
|
||||||
|
|||||||
@@ -0,0 +1,316 @@
|
|||||||
|
:root {
|
||||||
|
--ink: #101828;
|
||||||
|
--muted: #667085;
|
||||||
|
--faint: #98a2b3;
|
||||||
|
--line: #e4e7ec;
|
||||||
|
--paper: #f6f7f9;
|
||||||
|
--white: #fff;
|
||||||
|
--navy: #101828;
|
||||||
|
--navy-2: #1d2939;
|
||||||
|
--cyan: #0891b2;
|
||||||
|
--cyan-soft: #ecfeff;
|
||||||
|
--green: #067647;
|
||||||
|
--green-soft: #ecfdf3;
|
||||||
|
--amber: #b54708;
|
||||||
|
--amber-soft: #fffaeb;
|
||||||
|
--rose: #b42318;
|
||||||
|
--rose-soft: #fef3f2;
|
||||||
|
--shadow: 0 12px 36px rgba(16, 24, 40, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
button, input, select { font: inherit; }
|
||||||
|
button, a { -webkit-tap-highlight-color: transparent; }
|
||||||
|
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, summary:focus-visible {
|
||||||
|
outline: 3px solid rgba(6, 182, 212, 0.28);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
.skip-link {
|
||||||
|
position: fixed; z-index: 100; left: 16px; top: -60px; padding: 10px 14px;
|
||||||
|
border-radius: 10px; background: white; color: var(--ink); font-weight: 700;
|
||||||
|
}
|
||||||
|
.skip-link:focus { top: 16px; }
|
||||||
|
.sidebar {
|
||||||
|
position: fixed; inset: 0 auto 0 0; z-index: 30; width: 244px; display: flex;
|
||||||
|
flex-direction: column; border-right: 1px solid #253044; background: var(--navy);
|
||||||
|
color: white; padding: 22px 16px;
|
||||||
|
}
|
||||||
|
.brand { display: flex; align-items: center; gap: 12px; color: white; text-decoration: none; padding: 4px 8px 24px; }
|
||||||
|
.brand-mark {
|
||||||
|
display: grid; place-items: center; width: 38px; height: 38px; border: 1px solid #344054;
|
||||||
|
border-radius: 12px; background: #172033; color: #67e8f9; font: 800 16px ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
.brand strong, .brand small { display: block; }
|
||||||
|
.brand strong { font-size: 14px; letter-spacing: .08em; }
|
||||||
|
.brand small { margin-top: 3px; color: #98a2b3; font-size: 10px; letter-spacing: .06em; text-transform: uppercase; }
|
||||||
|
.sidebar nav { display: grid; gap: 5px; }
|
||||||
|
.nav-item {
|
||||||
|
display: grid; grid-template-columns: 30px 1fr; align-items: center; min-height: 46px; width: 100%;
|
||||||
|
border: 1px solid transparent; border-radius: 12px; background: transparent; color: #98a2b3;
|
||||||
|
padding: 8px 12px; text-align: left; font-size: 13px; font-weight: 650; cursor: pointer; transition: .18s ease;
|
||||||
|
}
|
||||||
|
.nav-item span { color: #667085; font: 700 11px ui-monospace, monospace; }
|
||||||
|
.nav-item:hover { background: #172033; color: white; }
|
||||||
|
.nav-item.active { border-color: #344054; background: #1d2939; color: white; }
|
||||||
|
.nav-item.active span { color: #67e8f9; }
|
||||||
|
.local-seal {
|
||||||
|
display: grid; grid-template-columns: 8px 1fr; gap: 10px; align-items: start; margin-top: auto;
|
||||||
|
border: 1px solid #344054; border-radius: 14px; background: #172033; padding: 13px;
|
||||||
|
}
|
||||||
|
.local-seal strong, .local-seal small { display: block; }
|
||||||
|
.local-seal strong { font-size: 11px; }
|
||||||
|
.local-seal small { margin-top: 4px; color: #667085; font: 10px ui-monospace, monospace; }
|
||||||
|
.live-dot { width: 7px; height: 7px; margin-top: 3px; border-radius: 50%; background: #32d583; box-shadow: 0 0 0 4px rgba(50,213,131,.12); }
|
||||||
|
.shell { margin-left: 244px; min-height: 100vh; }
|
||||||
|
.topbar {
|
||||||
|
position: sticky; top: 0; z-index: 20; display: flex; align-items: center; min-height: 76px;
|
||||||
|
border-bottom: 1px solid var(--line); background: rgba(255,255,255,.92); padding: 12px clamp(20px,4vw,48px);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
.topbar h1 { margin: 2px 0 0; font-size: 18px; letter-spacing: -.02em; }
|
||||||
|
.top-eyebrow { margin: 0; color: var(--faint); font-size: 9px; font-weight: 800; letter-spacing: .16em; text-transform: uppercase; }
|
||||||
|
.top-actions { display: flex; align-items: center; gap: 9px; margin-left: auto; }
|
||||||
|
.icon-button {
|
||||||
|
display: grid; place-items: center; min-width: 44px; min-height: 44px; border: 1px solid var(--line);
|
||||||
|
border-radius: 12px; background: white; color: #475467; cursor: pointer; transition: .18s ease;
|
||||||
|
}
|
||||||
|
.icon-button:hover { border-color: #98a2b3; transform: translateY(-1px); }
|
||||||
|
.menu-button { display: none; margin-right: 12px; }
|
||||||
|
main { max-width: 1500px; margin: 0 auto; padding: 28px clamp(20px,4vw,48px) 64px; }
|
||||||
|
.view { display: grid; gap: 18px; }
|
||||||
|
.hero {
|
||||||
|
position: relative; overflow: hidden; border-radius: 28px; background: var(--navy); color: white;
|
||||||
|
padding: clamp(28px,5vw,48px); box-shadow: 0 24px 70px rgba(16,24,40,.16);
|
||||||
|
}
|
||||||
|
.hero::after {
|
||||||
|
content: ""; position: absolute; width: 360px; height: 360px; right: -160px; top: -230px;
|
||||||
|
border: 1px solid rgba(103,232,249,.15); border-radius: 50%; box-shadow: 0 0 0 60px rgba(103,232,249,.025), 0 0 0 120px rgba(103,232,249,.02);
|
||||||
|
}
|
||||||
|
.hero-grid { position: relative; z-index: 1; display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 28px; align-items: end; }
|
||||||
|
.eyebrow { margin: 0; color: var(--cyan); font-size: 10px; font-weight: 850; letter-spacing: .18em; text-transform: uppercase; }
|
||||||
|
.hero .eyebrow { color: #67e8f9; }
|
||||||
|
.hero h2 { max-width: 760px; margin: 13px 0 12px; font-size: clamp(30px,4vw,48px); line-height: 1.02; letter-spacing: -.045em; }
|
||||||
|
.hero-description { max-width: 760px; margin: 0; color: #98a2b3; font-size: 14px; line-height: 1.7; }
|
||||||
|
.hero-meta { display: flex; flex-wrap: wrap; gap: 8px 18px; margin-top: 24px; color: #667085; font: 10px ui-monospace, monospace; }
|
||||||
|
.hero-actions { display: flex; flex-wrap: wrap; gap: 9px; justify-content: flex-end; }
|
||||||
|
.trace-hero { border-radius: 18px; padding: 20px 22px; box-shadow: none; }
|
||||||
|
.run-dossier-anchor { scroll-margin-top: 94px; outline: none; }
|
||||||
|
.run-dossier-anchor:focus-visible {
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 0 0 3px rgba(6, 182, 212, 0.22);
|
||||||
|
}
|
||||||
|
.trace-title { margin: 8px 0 !important; font-size: 18px !important; }
|
||||||
|
.trace-meta { margin-top: 8px; }
|
||||||
|
.rail-spaced { margin-top: 20px; }
|
||||||
|
.button {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; min-height: 44px; border: 1px solid transparent;
|
||||||
|
border-radius: 12px; padding: 9px 15px; text-decoration: none; font-size: 12px; font-weight: 750; cursor: pointer; transition: .18s ease;
|
||||||
|
}
|
||||||
|
.button:hover { transform: translateY(-1px); }
|
||||||
|
.button.primary { background: #67e8f9; color: var(--navy); }
|
||||||
|
.button.primary:hover { background: #a5f3fc; }
|
||||||
|
.button.secondary { border-color: #344054; background: #1d2939; color: #e4e7ec; }
|
||||||
|
.button.light { border-color: #d0d5dd; background: white; color: #344054; }
|
||||||
|
.pill {
|
||||||
|
display: inline-flex; align-items: center; min-height: 28px; border-radius: 999px; padding: 5px 10px;
|
||||||
|
font-size: 9px; font-weight: 850; letter-spacing: .1em; text-transform: uppercase; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.pill.pass, .pill.certified, .pill.success, .pill.fresh, .pill.operational { background: var(--green-soft); color: var(--green); }
|
||||||
|
.pill.warning, .pill.attention, .pill.ready-with-attention, .pill.ready_with_attention, .pill.partial, .pill.stale, .pill.in-progress, .pill.in_progress { background: var(--amber-soft); color: var(--amber); }
|
||||||
|
.pill.error, .pill.failed, .pill.fail, .pill.blocked, .pill.non-certified, .pill.non_certified { background: var(--rose-soft); color: var(--rose); }
|
||||||
|
.pill.neutral, .pill.queued, .pill.unknown, .pill.no-data, .pill.no_data { background: #f2f4f7; color: #475467; }
|
||||||
|
.hero .pill { border: 1px solid #344054; background: #1d2939; color: #d0d5dd; }
|
||||||
|
.metric-grid { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 14px; }
|
||||||
|
.metric-grid.readiness-grid { grid-template-columns: repeat(3,minmax(0,1fr)); }
|
||||||
|
.readiness-grid .metric-value { font-size: 24px; text-transform: capitalize; }
|
||||||
|
.onboarding-actions { margin-bottom: 18px; }
|
||||||
|
.metric-card, .panel {
|
||||||
|
border: 1px solid var(--line); border-radius: 18px; background: white; box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.metric-card { min-height: 136px; padding: 19px; }
|
||||||
|
.metric-label { color: var(--faint); font-size: 9px; font-weight: 850; letter-spacing: .14em; text-transform: uppercase; }
|
||||||
|
.metric-value { margin-top: 18px; font-size: 27px; font-weight: 720; letter-spacing: -.04em; }
|
||||||
|
.metric-detail { margin-top: 7px; color: var(--muted); font-size: 11px; line-height: 1.45; }
|
||||||
|
.panel { overflow: hidden; }
|
||||||
|
.panel-head { display: flex; justify-content: space-between; gap: 20px; align-items: start; padding: 23px 24px 18px; }
|
||||||
|
.panel h2 { margin: 6px 0 0; font-size: 19px; letter-spacing: -.025em; }
|
||||||
|
.panel-description { max-width: 720px; margin: 7px 0 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
|
||||||
|
.panel-body { border-top: 1px solid #f2f4f7; padding: 22px 24px 24px; }
|
||||||
|
.disclosure-panel {
|
||||||
|
overflow: hidden; border: 1px solid var(--line); border-radius: 18px; background: white;
|
||||||
|
box-shadow: 0 10px 28px rgba(16,24,40,.04);
|
||||||
|
}
|
||||||
|
.disclosure-panel > summary {
|
||||||
|
display: flex; min-height: 66px; align-items: center; justify-content: space-between; gap: 18px;
|
||||||
|
padding: 15px 20px; list-style: none; cursor: pointer; transition: background .16s ease;
|
||||||
|
}
|
||||||
|
.disclosure-panel > summary::-webkit-details-marker { display: none; }
|
||||||
|
.disclosure-panel > summary:hover { background: #f9fafb; }
|
||||||
|
.disclosure-panel > summary strong, .disclosure-panel > summary small { display: block; }
|
||||||
|
.disclosure-panel > summary strong { color: #344054; font-size: 13px; }
|
||||||
|
.disclosure-panel > summary small { margin-top: 5px; color: var(--muted); font-size: 11px; line-height: 1.45; }
|
||||||
|
.disclosure-side { display: flex; flex-shrink: 0; align-items: center; gap: 10px; }
|
||||||
|
.disclosure-side > b { color: var(--faint); font-size: 20px; font-weight: 400; transition: transform .16s ease; }
|
||||||
|
.disclosure-panel[open] .disclosure-side > b { transform: rotate(45deg); }
|
||||||
|
.disclosure-body { border-top: 1px solid #f2f4f7; padding: 20px; }
|
||||||
|
.disclosure-body > dl { margin: -20px; }
|
||||||
|
.disclosure-panel .manifest-row { border-bottom: 1px solid #f2f4f7; }
|
||||||
|
.disclosure-panel .manifest-row:last-child { border-bottom: 0; }
|
||||||
|
.disclosure-panel .manifest-row dt { color: var(--faint); font: 750 9px ui-monospace,monospace; letter-spacing: .07em; text-transform: uppercase; }
|
||||||
|
.disclosure-panel .manifest-row dd { margin: 0; overflow-wrap: anywhere; color: #344054; font: 11px/1.5 ui-monospace,monospace; }
|
||||||
|
.two-col { display: grid; grid-template-columns: minmax(0,1.2fr) minmax(330px,.8fr); gap: 18px; }
|
||||||
|
.rail-wrap { overflow-x: auto; padding-bottom: 4px; }
|
||||||
|
.evidence-rail { display: grid; grid-template-columns: repeat(7,minmax(116px,1fr)); min-width: 840px; gap: 7px; }
|
||||||
|
.gate-button {
|
||||||
|
position: relative; min-height: 92px; border: 1px solid var(--line); border-radius: 14px; background: #fafafa;
|
||||||
|
padding: 13px; color: #344054; text-align: left; cursor: pointer; transition: .18s ease;
|
||||||
|
}
|
||||||
|
.gate-button::after { content: ""; position: absolute; top: 27px; right: -8px; width: 8px; height: 1px; background: #d0d5dd; }
|
||||||
|
.gate-button:last-child::after { display: none; }
|
||||||
|
.gate-button:hover { border-color: #98a2b3; transform: translateY(-1px); }
|
||||||
|
.gate-button.selected { border-color: var(--cyan); background: var(--cyan-soft); box-shadow: 0 0 0 3px rgba(6,182,212,.09); }
|
||||||
|
.gate-index { color: var(--faint); font: 800 9px ui-monospace, monospace; letter-spacing: .1em; }
|
||||||
|
.gate-name { display: block; margin-top: 14px; font-size: 11px; font-weight: 750; }
|
||||||
|
.gate-state { display: flex; align-items: center; gap: 6px; margin-top: 8px; color: var(--muted); font-size: 9px; text-transform: uppercase; }
|
||||||
|
.state-dot { width: 7px; height: 7px; border-radius: 50%; background: #98a2b3; }
|
||||||
|
.state-dot.pass { background: #12b76a; }.state-dot.warning { background: #f79009; }.state-dot.error,.state-dot.blocked { background: #f04438; }.state-dot.running { background: #06b6d4; }
|
||||||
|
.dossier { display: grid; grid-template-columns: minmax(0,.9fr) minmax(360px,1.1fr); gap: 18px; margin-top: 20px; }
|
||||||
|
.decision-card { display: grid; gap: 12px; }
|
||||||
|
.inset { border: 1px solid var(--line); border-radius: 14px; background: #f9fafb; padding: 15px; }
|
||||||
|
.inset-label { color: var(--faint); font-size: 9px; font-weight: 850; letter-spacing: .13em; text-transform: uppercase; }
|
||||||
|
.inset p { margin: 9px 0 0; color: #344054; font-size: 12px; line-height: 1.6; }
|
||||||
|
.manifest { overflow: hidden; border: 1px solid #1d2939; border-radius: 16px; background: var(--navy); color: #e4e7ec; }
|
||||||
|
.manifest-head { display: flex; justify-content: space-between; gap: 16px; padding: 16px 18px; border-bottom: 1px solid #1d2939; }
|
||||||
|
.manifest-head .eyebrow { color: #67e8f9; }
|
||||||
|
.manifest dl { margin: 0; }
|
||||||
|
.manifest-row { display: grid; grid-template-columns: 140px minmax(0,1fr); gap: 16px; border-bottom: 1px solid #1d2939; padding: 12px 18px; }
|
||||||
|
.manifest-row:last-child { border-bottom: 0; }
|
||||||
|
.manifest dt { color: #667085; font: 750 9px ui-monospace,monospace; letter-spacing: .07em; text-transform: uppercase; }
|
||||||
|
.manifest dd { margin: 0; overflow-wrap: anywhere; color: #d0d5dd; font: 11px/1.5 ui-monospace,monospace; }
|
||||||
|
.timeline { display: grid; gap: 10px; margin-top: 12px; }
|
||||||
|
.timeline-item { display: grid; grid-template-columns: 8px minmax(0,1fr); gap: 10px; }
|
||||||
|
.timeline-line { display: flex; flex-direction: column; align-items: center; }
|
||||||
|
.timeline-line .state-dot { margin-top: 5px; }
|
||||||
|
.timeline-line::after { content:""; width: 1px; flex: 1; margin-top: 4px; background: var(--line); }
|
||||||
|
.timeline-item:last-child .timeline-line::after { display: none; }
|
||||||
|
.timeline-copy { padding-bottom: 7px; }
|
||||||
|
.timeline-copy strong { font-size: 11px; text-transform: capitalize; }
|
||||||
|
.timeline-copy time { float: right; color: var(--faint); font: 9px ui-monospace,monospace; }
|
||||||
|
.timeline-copy p { margin: 4px 0 0; color: var(--muted); font-size: 11px; line-height: 1.5; }
|
||||||
|
.table-scroll { overflow-x: auto; }
|
||||||
|
table { width: 100%; min-width: 780px; border-collapse: collapse; font-size: 12px; }
|
||||||
|
th { background: #f9fafb; color: var(--faint); padding: 11px 15px; text-align: left; font-size: 9px; letter-spacing: .12em; text-transform: uppercase; }
|
||||||
|
td { border-top: 1px solid #f2f4f7; padding: 14px 15px; color: #344054; }
|
||||||
|
tbody tr { transition: background .15s ease; }
|
||||||
|
tbody tr:hover { background: #f0fdfa; }
|
||||||
|
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; }
|
||||||
|
.right { text-align: right; }
|
||||||
|
.empty { padding: 52px 20px; color: var(--muted); text-align: center; }
|
||||||
|
.empty-mark { display: grid; place-items: center; width: 42px; height: 42px; margin: 0 auto 13px; border: 1px solid var(--line); border-radius: 50%; color: var(--faint); }
|
||||||
|
.notice { border: 1px solid; border-radius: 14px; padding: 15px 18px; font-size: 12px; line-height: 1.55; }
|
||||||
|
.notice.warning { border-color: #fedf89; background: var(--amber-soft); color: var(--amber); }
|
||||||
|
.notice.error { border-color: #fecdca; background: var(--rose-soft); color: var(--rose); }
|
||||||
|
.notice.success { border-color: #abefc6; background: var(--green-soft); color: var(--green); }
|
||||||
|
.findings { display: grid; gap: 10px; }
|
||||||
|
.finding { display: grid; grid-template-columns: 30px minmax(0,1fr); gap: 12px; border-left: 4px solid var(--cyan); border-radius: 11px; background: #f9fafb; padding: 14px; }
|
||||||
|
.finding.critical { border-color: #f04438; background: var(--rose-soft); }
|
||||||
|
.finding.warning { border-color: #f79009; background: var(--amber-soft); }
|
||||||
|
.finding-index { color: var(--faint); font: 700 14px ui-monospace,monospace; }
|
||||||
|
.finding code { font-size: 10px; font-weight: 800; color: #344054; }
|
||||||
|
.finding p { margin: 6px 0 0; color: var(--muted); font-size: 11px; line-height: 1.55; }
|
||||||
|
.source-grid { display: grid; gap: 10px; }
|
||||||
|
.source { border: 1px solid var(--line); border-radius: 13px; padding: 14px; }
|
||||||
|
.source-top { display: flex; justify-content: space-between; gap: 12px; }
|
||||||
|
.source h3 { margin: 0; font-size: 12px; text-transform: capitalize; }
|
||||||
|
.source-meta, .source-path { color: var(--faint); font: 9px/1.5 ui-monospace,monospace; }
|
||||||
|
.source-meta { margin-top: 5px; }.source-path { overflow-wrap:anywhere; border-top: 1px solid #f2f4f7; margin-top: 12px; padding-top: 10px; }
|
||||||
|
.distribution { display: grid; gap: 17px; margin: 0; padding: 0; list-style: none; }
|
||||||
|
.distribution-head { display: flex; align-items: end; justify-content: space-between; gap: 16px; }
|
||||||
|
.distribution-head span strong, .distribution-head span small { display: block; }
|
||||||
|
.distribution-head span strong { color: #344054; font-size: 12px; text-transform: capitalize; }
|
||||||
|
.distribution-head span small { margin-top: 4px; color: var(--muted); font-size: 10px; }
|
||||||
|
.distribution-head > b { color: var(--ink); font: 700 11px ui-monospace,monospace; }
|
||||||
|
.distribution-track { overflow: hidden; height: 8px; margin-top: 8px; border-radius: 99px; background: #eaecf0; }
|
||||||
|
.distribution-track i { display: block; height: 100%; border-radius: inherit; background: var(--cyan); }
|
||||||
|
.distribution-track i.success { background: #12b76a; }
|
||||||
|
.distribution-track i.warning { background: #f79009; }
|
||||||
|
.distribution-track i.danger { background: #f04438; }
|
||||||
|
.metric-grid.compact .metric-card { min-height: 116px; }
|
||||||
|
.technical-section { margin-top: 24px; border-top: 1px solid #f2f4f7; padding-top: 22px; }
|
||||||
|
.technical-section h3 { margin: 6px 0 14px; font-size: 16px; }
|
||||||
|
.coverage { display: grid; gap: 18px; }
|
||||||
|
.coverage-head { display: flex; justify-content: space-between; gap: 12px; font-size: 11px; }
|
||||||
|
.coverage-track { overflow: hidden; height: 7px; margin-top: 8px; border-radius: 99px; background: #eaecf0; }
|
||||||
|
.coverage-fill { height: 100%; border-radius: inherit; background: var(--cyan); }
|
||||||
|
.coverage-fill.width-0 { width: 0; }.coverage-fill.width-5 { width: 5%; }
|
||||||
|
.coverage-fill.width-10 { width: 10%; }.coverage-fill.width-15 { width: 15%; }
|
||||||
|
.coverage-fill.width-20 { width: 20%; }.coverage-fill.width-25 { width: 25%; }
|
||||||
|
.coverage-fill.width-30 { width: 30%; }.coverage-fill.width-35 { width: 35%; }
|
||||||
|
.coverage-fill.width-40 { width: 40%; }.coverage-fill.width-45 { width: 45%; }
|
||||||
|
.coverage-fill.width-50 { width: 50%; }.coverage-fill.width-55 { width: 55%; }
|
||||||
|
.coverage-fill.width-60 { width: 60%; }.coverage-fill.width-65 { width: 65%; }
|
||||||
|
.coverage-fill.width-70 { width: 70%; }.coverage-fill.width-75 { width: 75%; }
|
||||||
|
.coverage-fill.width-80 { width: 80%; }.coverage-fill.width-85 { width: 85%; }
|
||||||
|
.coverage-fill.width-90 { width: 90%; }.coverage-fill.width-95 { width: 95%; }
|
||||||
|
.coverage-fill.width-100 { width: 100%; }
|
||||||
|
.success-copy { color: var(--green); }
|
||||||
|
.filters { display: grid; grid-template-columns: 1fr 1fr 1fr 1.5fr auto; gap: 12px; align-items: end; }
|
||||||
|
.field span { display: block; margin-bottom: 7px; color: var(--faint); font-size: 9px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
|
||||||
|
.field input, .field select { width: 100%; min-height: 44px; border: 1px solid #d0d5dd; border-radius: 11px; background: white; padding: 8px 11px; color: var(--ink); font-size: 12px; }
|
||||||
|
.field input:hover, .field select:hover { border-color: #98a2b3; }
|
||||||
|
.filter-actions { display: flex; gap: 7px; }
|
||||||
|
.breakdown-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
|
||||||
|
.loading-state { display: grid; gap: 18px; }
|
||||||
|
.skeleton { position: relative; overflow: hidden; border-radius: 20px; background: #eaecf0; }
|
||||||
|
.skeleton::after { content:""; position:absolute; inset:0; transform:translateX(-100%); background:linear-gradient(90deg,transparent,#ffffff80,transparent); animation: shimmer 1.4s infinite; }
|
||||||
|
.hero-skeleton { height: 280px; }.metric-skeleton { height: 136px; }
|
||||||
|
@keyframes shimmer { 100% { transform: translateX(100%); } }
|
||||||
|
.nav-scrim { position: fixed; z-index: 25; inset: 0; background: rgba(16,24,40,.5); }
|
||||||
|
|
||||||
|
@media (max-width: 1120px) {
|
||||||
|
.metric-grid { grid-template-columns: repeat(2,1fr); }
|
||||||
|
.metric-grid.readiness-grid { grid-template-columns: 1fr; }
|
||||||
|
.two-col, .breakdown-grid, .dossier { grid-template-columns: 1fr; }
|
||||||
|
.hero-grid { grid-template-columns: 1fr; }
|
||||||
|
.hero-actions { justify-content: flex-start; }
|
||||||
|
.panel-head { flex-wrap: wrap; }
|
||||||
|
.filters { grid-template-columns: 1fr 1fr; }
|
||||||
|
.filter-actions { grid-column: 1/-1; }
|
||||||
|
}
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.sidebar { transform: translateX(-100%); transition: transform .2s ease; box-shadow: 18px 0 50px rgba(16,24,40,.28); }
|
||||||
|
.sidebar.open { transform: translateX(0); }
|
||||||
|
.shell { margin-left: 0; }
|
||||||
|
.menu-button { display: grid; }
|
||||||
|
.hero-grid { grid-template-columns: 1fr; }
|
||||||
|
.hero-actions { justify-content: flex-start; }
|
||||||
|
.dossier { grid-template-columns: 1fr; }
|
||||||
|
.top-actions .pill { display: none; }
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
main { padding: 18px 14px 52px; }
|
||||||
|
.topbar { padding: 10px 14px; }
|
||||||
|
.metric-grid, .filters { grid-template-columns: 1fr; }
|
||||||
|
.hero { border-radius: 20px; padding: 25px 20px; }
|
||||||
|
.hero h2 { font-size: 31px; }
|
||||||
|
.panel-head, .panel-body { padding-left: 17px; padding-right: 17px; }
|
||||||
|
.manifest-row { grid-template-columns: 1fr; gap: 5px; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
|
||||||
|
}
|
||||||
|
@media print {
|
||||||
|
.sidebar, .topbar { display: none; }
|
||||||
|
.shell { margin: 0; }
|
||||||
|
main { max-width: none; padding: 0; }
|
||||||
|
.hero, .metric-card, .panel { box-shadow: none; break-inside: avoid; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,495 @@
|
|||||||
|
(() => {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const suppliedToken = params.get("token");
|
||||||
|
if (suppliedToken) sessionStorage.setItem("casan_viewer_token", suppliedToken);
|
||||||
|
const token = suppliedToken || sessionStorage.getItem("casan_viewer_token") || "";
|
||||||
|
params.delete("token");
|
||||||
|
history.replaceState({}, "", `${location.pathname}${params.size ? `?${params}` : ""}${location.hash}`);
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
view: params.get("view") || (params.get("trace") ? "runs" : "overview"),
|
||||||
|
trace: params.get("trace") || "",
|
||||||
|
project: null,
|
||||||
|
registry: null,
|
||||||
|
h6: null,
|
||||||
|
run: null,
|
||||||
|
selectedGate: "H1-context",
|
||||||
|
};
|
||||||
|
const $ = (selector) => document.querySelector(selector);
|
||||||
|
const esc = (value) => String(value ?? "")
|
||||||
|
.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """).replaceAll("'", "'");
|
||||||
|
const slug = (value) => String(value || "unknown").toLowerCase().replaceAll("_", "-").replace(/[^a-z0-9-]/g, "");
|
||||||
|
const text = (value, fallback = "Unavailable") => value === null || value === undefined || value === "" ? fallback : String(value);
|
||||||
|
const number = (value) => value === null || value === undefined || !Number.isFinite(Number(value))
|
||||||
|
? "Unavailable" : new Intl.NumberFormat("en-US").format(Number(value));
|
||||||
|
const time = (value) => value ? String(value).replace("T", " ").replace("Z", " UTC") : "No timestamp";
|
||||||
|
const money = (value) => value === null || value === undefined ? "Unavailable" : `$${Number(value).toFixed(6)}`;
|
||||||
|
const statusLabel = (value) => String(value || "unknown").replaceAll("_", " ");
|
||||||
|
const pill = (value) => `<span class="pill ${slug(value)}">${esc(String(value || "unknown").replaceAll("_", " "))}</span>`;
|
||||||
|
|
||||||
|
async function api(path) {
|
||||||
|
const response = await fetch(path, {
|
||||||
|
headers: { "X-CASAN-Viewer-Token": token },
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadUrl(path, query = {}) {
|
||||||
|
const next = new URLSearchParams({ ...query, token });
|
||||||
|
return `${path}?${next}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hero({ eyebrow, title, description, verdict, meta = [], actions = "" }) {
|
||||||
|
return `<article class="hero">
|
||||||
|
<div class="hero-grid">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">${esc(eyebrow)}</p>
|
||||||
|
<h2>${esc(title)}</h2>
|
||||||
|
<p class="hero-description">${esc(description)}</p>
|
||||||
|
<div class="hero-meta">${meta.map((item) => `<span>${esc(item)}</span>`).join("")}</div>
|
||||||
|
</div>
|
||||||
|
<div class="hero-actions">${verdict ? pill(verdict) : ""}${actions}</div>
|
||||||
|
</div>
|
||||||
|
</article>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function metric(label, value, detail) {
|
||||||
|
return `<article class="metric-card"><div class="metric-label">${esc(label)}</div>
|
||||||
|
<div class="metric-value">${esc(text(value))}</div><div class="metric-detail">${esc(detail)}</div></article>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function panel(eyebrow, title, description, body, right = "") {
|
||||||
|
return `<article class="panel"><header class="panel-head"><div><p class="eyebrow">${esc(eyebrow)}</p>
|
||||||
|
<h2>${esc(title)}</h2><p class="panel-description">${esc(description)}</p></div>${right}</header>
|
||||||
|
<div class="panel-body">${body}</div></article>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function disclosure(summary, description, body, badge = "") {
|
||||||
|
return `<details class="disclosure-panel"><summary><span><strong>${esc(summary)}</strong><small>${esc(description)}</small></span>
|
||||||
|
<span class="disclosure-side">${badge}<b aria-hidden="true">+</b></span></summary><div class="disclosure-body">${body}</div></details>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function distribution(label, rows) {
|
||||||
|
const maximum = Math.max(...rows.map((row) => Number(row.value) || 0), 1);
|
||||||
|
return `<ol class="distribution" role="img" aria-label="${esc(label)}">${rows.map((row) => {
|
||||||
|
const value = Math.max(Number(row.value) || 0, 0);
|
||||||
|
const width = value === 0 ? 0 : Math.max((value / maximum) * 100, 3);
|
||||||
|
return `<li><div class="distribution-head"><span><strong>${esc(row.label)}</strong><small>${esc(row.detail || "")}</small></span><b>${esc(number(value))}</b></div>
|
||||||
|
<div class="distribution-track" aria-hidden="true"><i class="${slug(row.tone || "info")}" style="width:${width}%"></i></div></li>`;
|
||||||
|
}).join("")}</ol>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function empty(title, detail) {
|
||||||
|
return `<div class="empty"><div class="empty-mark">∅</div><strong>${esc(title)}</strong><p>${esc(detail)}</p></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setView(view, push = true) {
|
||||||
|
state.view = ["overview", "runs", "h6"].includes(view) ? view : "overview";
|
||||||
|
document.querySelectorAll(".nav-item").forEach((item) => {
|
||||||
|
const active = item.dataset.view === state.view;
|
||||||
|
item.classList.toggle("active", active);
|
||||||
|
if (active) item.setAttribute("aria-current", "page");
|
||||||
|
else item.removeAttribute("aria-current");
|
||||||
|
});
|
||||||
|
document.querySelectorAll(".view").forEach((item) => item.classList.add("hidden"));
|
||||||
|
$(`#${state.view}-view`).classList.remove("hidden");
|
||||||
|
$("#page-title").textContent = {
|
||||||
|
overview: "Assurance overview", runs: "Run assurance registry", h6: "H6 AgentOps dossier",
|
||||||
|
}[state.view];
|
||||||
|
if (push) {
|
||||||
|
const next = new URL(location.href);
|
||||||
|
next.searchParams.set("view", state.view);
|
||||||
|
if (state.trace) next.searchParams.set("trace", state.trace);
|
||||||
|
else next.searchParams.delete("trace");
|
||||||
|
history.pushState({}, "", `${next.pathname}?${next.searchParams}`);
|
||||||
|
}
|
||||||
|
if (state.view === "runs") renderRuns();
|
||||||
|
if (state.view === "h6") renderH6();
|
||||||
|
closeNav();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOverview() {
|
||||||
|
const profile = state.project;
|
||||||
|
const registry = state.registry;
|
||||||
|
const latest = registry.latest || {};
|
||||||
|
const recent = registry.runs || [];
|
||||||
|
const failures = recent.filter((row) => row.status === "failed").length;
|
||||||
|
const certified = latest.certified === true;
|
||||||
|
const latestTrace = latest.trace_id || recent[0]?.trace_id;
|
||||||
|
const maturity = profile.maturity || {};
|
||||||
|
const readiness = profile.readiness || {};
|
||||||
|
const dimensions = readiness.dimensions || {};
|
||||||
|
const core = dimensions.core || { status: "not_assessed", summary: "Core readiness has not been assessed." };
|
||||||
|
const domain = dimensions.domain_pipeline || { status: "not_assessed", summary: "Domain Pipeline readiness has not been assessed." };
|
||||||
|
const telemetry = dimensions.provider_telemetry || { status: "not_assessed", summary: "Provider Telemetry readiness has not been assessed." };
|
||||||
|
const nextActions = readiness.next_actions || [];
|
||||||
|
const coreReady = ["ready", "ready_with_attention"].includes(core.status);
|
||||||
|
const readinessActionList = `<div class="findings">${nextActions.map((action, index) => `<div class="finding warning">
|
||||||
|
<span class="finding-index">${String(index + 1).padStart(2, "0")}</span>
|
||||||
|
<div><code>${esc(action.code)}</code><p>${esc(action.message)}</p></div>
|
||||||
|
</div>`).join("")}</div>`;
|
||||||
|
const readinessCards = `<section class="metric-grid readiness-grid" aria-label="Commercial readiness">
|
||||||
|
${metric("Core", statusLabel(core.status), core.summary)}
|
||||||
|
${metric("Domain Pipeline", statusLabel(domain.status), domain.summary)}
|
||||||
|
${metric("Provider Telemetry", statusLabel(telemetry.status), telemetry.summary)}
|
||||||
|
</section>`;
|
||||||
|
const actions = latestTrace
|
||||||
|
? `<button class="button primary" type="button" data-open-trace="${esc(latestTrace)}">Inspect latest run</button>
|
||||||
|
<button class="button secondary" type="button" data-open-h6="${esc(latestTrace)}">Open H6</button>`
|
||||||
|
: `<button class="button secondary" type="button" data-view-target="runs">View registry</button>`;
|
||||||
|
$("#overview-view").innerHTML =
|
||||||
|
hero({
|
||||||
|
eyebrow: "CASAN Core · Local assurance",
|
||||||
|
title: latestTrace ? "One prompt. One evidence trail." :
|
||||||
|
coreReady ? "Core reporting is ready." : "Finish Core activation.",
|
||||||
|
description: latestTrace
|
||||||
|
? "A read-only, offline-capable assurance workspace reconstructed from canonical project evidence. No Platform service or HTML generation on the prompt hot path."
|
||||||
|
: coreReady
|
||||||
|
? "Core visual reporting works without a Domain Pack or application-source changes. Send a governed prompt to create the first run dossier."
|
||||||
|
: "Core visual reporting works without a Domain Pack or application-source changes. Complete the activation action below, then send a governed prompt.",
|
||||||
|
verdict: latestTrace ? (certified ? "certified" : "review required") : core.status,
|
||||||
|
meta: [
|
||||||
|
`${profile.project_name} · ${profile.edition}`,
|
||||||
|
latestTrace ? `Trace ${latestTrace}` : "No finalized trace yet",
|
||||||
|
`Maturity ${maturity.level == null ? "not assessed" : `L${maturity.level}`} · ${statusLabel(maturity.status)}`,
|
||||||
|
],
|
||||||
|
actions,
|
||||||
|
}) + readinessCards +
|
||||||
|
(latestTrace && nextActions.length ? panel(
|
||||||
|
"Activation attention",
|
||||||
|
"A selected client route needs action.",
|
||||||
|
"Healthy routes remain operational; complete this step before relying on the affected integration.",
|
||||||
|
readinessActionList,
|
||||||
|
pill(core.status),
|
||||||
|
) : "") +
|
||||||
|
(latestTrace ? `<section class="metric-grid">
|
||||||
|
${metric("Governed records", number(registry.count), `${recent.length} recent records loaded`)}
|
||||||
|
${metric("Latest H6 quality", latest.telemetry_quality || "Unavailable", "Missing values are disclosed, never coerced to zero")}
|
||||||
|
${metric("Observed failures", number(failures), `${recent.length ? Math.round((failures / recent.length) * 100) : 0}% of loaded records`)}
|
||||||
|
${metric("Evidence gates", latestTrace ? "H1 → H7" : "Waiting", latestTrace ? "Control-by-control reconstruction available" : "Evidence appears after a prompt finalizes")}
|
||||||
|
</section>` : panel(
|
||||||
|
"First-run onboarding",
|
||||||
|
"No operational KPIs are shown until evidence exists.",
|
||||||
|
"CASAN does not turn missing telemetry into zeroes or claim a run is certified before a governed prompt finishes.",
|
||||||
|
`${nextActions.length ? `<div class="onboarding-actions">${readinessActionList}</div>` : ""}
|
||||||
|
<div class="two-col">
|
||||||
|
<div class="notice success"><strong>Available now</strong><br>Local viewer, H1–H7 run dossier, history, H6 AgentOps and on-demand JSON/HTML export.</div>
|
||||||
|
<div class="notice warning"><strong>Optional expansion</strong><br>Install a Domain Pack only when the team needs project-specific SRS→test acceptance.</div>
|
||||||
|
</div>`
|
||||||
|
)) +
|
||||||
|
disclosure(
|
||||||
|
"About this local report",
|
||||||
|
"Edition boundaries and implementation notes are hidden until needed.",
|
||||||
|
`<div class="two-col">
|
||||||
|
<div class="notice success"><strong>Included in Core</strong><br>Latest run, history, interactive H1–H7 evidence, H6 AgentOps, and on-demand JSON/HTML export.</div>
|
||||||
|
<div class="notice warning"><strong>Platform boundary</strong><br>Multi-project fleet views, centralized RBAC, shared approvals, ingestion and organization-wide retention.</div>
|
||||||
|
</div>`
|
||||||
|
) +
|
||||||
|
recentRunsPanel(recent.slice(0, 6), "Latest governed evidence");
|
||||||
|
bindActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
function recentRunsPanel(rows, title = "Recent governed runs") {
|
||||||
|
const body = rows.length ? `<div class="table-scroll"><table>
|
||||||
|
<thead><tr><th>Observed at</th><th>Lifecycle step</th><th>Verdict</th><th class="right">Latency</th><th class="right">Tokens</th><th class="right">Evidence</th></tr></thead>
|
||||||
|
<tbody>${rows.map((row) => `<tr>
|
||||||
|
<td class="mono">${esc(time(row.timestamp))}</td><td><strong>${esc(row.step || row.harness || "Agentic turn")}</strong></td>
|
||||||
|
<td>${pill(row.status)}</td><td class="right mono">${esc(row.latency_ms == null ? "Unavailable" : `${number(row.latency_ms)} ms`)}</td>
|
||||||
|
<td class="right mono">${esc(number(row.total_tokens))}</td>
|
||||||
|
<td class="right">${row.trace_id ? `<button class="button light" type="button" data-open-trace="${esc(row.trace_id)}">Inspect H1–H7</button>` : "—"}</td>
|
||||||
|
</tr>`).join("")}</tbody></table></div>` : empty("No governed run yet", "Complete a prompt in a configured client, then refresh this viewer.");
|
||||||
|
return panel("Evidence index", title, "Operational values are shown only when present in canonical telemetry.", body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRuns() {
|
||||||
|
const rows = state.registry.runs || [];
|
||||||
|
const selected = state.run;
|
||||||
|
const hasRuns = rows.length > 0;
|
||||||
|
const body = hero({
|
||||||
|
eyebrow: "Run assurance registry",
|
||||||
|
title: hasRuns ? "Every governed run, one evidence trail." : "No governed runs yet.",
|
||||||
|
description: hasRuns
|
||||||
|
? "Select a lifecycle record to reconstruct H1–H7 decisions, inspect sanitized evidence and export an independent dossier."
|
||||||
|
: "The registry will populate after a configured client finalizes its first governed prompt.",
|
||||||
|
verdict: hasRuns
|
||||||
|
? (rows.some((row) => row.status === "failed") ? "attention" : "operational")
|
||||||
|
: "waiting",
|
||||||
|
meta: hasRuns
|
||||||
|
? [`${state.registry.count} records`, `${rows.filter((row) => row.status === "failed").length} failures`, "Local canonical evidence"]
|
||||||
|
: ["No operational counters yet", "Local canonical evidence"],
|
||||||
|
actions: hasRuns
|
||||||
|
? `<button class="button primary" type="button" data-view-target="h6">Open H6 dossier</button>`
|
||||||
|
: "",
|
||||||
|
}) + (selected ? renderTrace(selected) : "") + recentRunsPanel(rows);
|
||||||
|
$("#runs-view").innerHTML = body;
|
||||||
|
bindActions();
|
||||||
|
bindGates();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTrace(report) {
|
||||||
|
const gates = report.gates || [];
|
||||||
|
let selected = gates.find((gate) => gate.id === state.selectedGate) || gates[0];
|
||||||
|
if (!selected) return "";
|
||||||
|
const rail = gates.map((gate, index) => `<button type="button" aria-pressed="${gate.id === selected.id}" class="gate-button ${gate.id === selected.id ? "selected" : ""}" data-gate="${esc(gate.id)}">
|
||||||
|
<span class="gate-index">0${index + 1}</span><span class="gate-name">${esc(gate.title)}</span>
|
||||||
|
<span class="gate-state"><span class="state-dot ${slug(gate.status)}"></span>${esc(gate.status)}</span></button>`).join("");
|
||||||
|
const events = (selected.events || []).slice(-4).reverse();
|
||||||
|
const timeline = events.length ? `<div class="timeline">${events.map((event) => `<div class="timeline-item">
|
||||||
|
<div class="timeline-line"><span class="state-dot ${slug(event.status)}"></span></div>
|
||||||
|
<div class="timeline-copy"><strong>${esc(event.status)}</strong><time>${esc(time(event.timestamp))}</time><p>${esc(event.reason)}</p></div>
|
||||||
|
</div>`).join("")}</div>` : `<p class="metric-detail">No individual event was emitted for this control.</p>`;
|
||||||
|
const entries = Object.entries(selected.evidence || {});
|
||||||
|
const manifest = entries.length ? `<dl>${entries.map(([key, value]) => `<div class="manifest-row"><dt>${esc(key)}</dt><dd>${esc(typeof value === "string" ? value : JSON.stringify(value))}</dd></div>`).join("")}</dl>`
|
||||||
|
: empty("No safe evidence fields", "This control has not emitted a sanitized evidence manifest.");
|
||||||
|
const actions = `<a class="button secondary" href="${downloadUrl(`/api/v1/reports/run/${encodeURIComponent(report.trace_id)}/export`, { format: "json" })}">JSON evidence</a>
|
||||||
|
<a class="button primary" href="${downloadUrl(`/api/v1/reports/run/${encodeURIComponent(report.trace_id)}/export`, { format: "html" })}">Export dossier</a>`;
|
||||||
|
return `<section id="run-dossier" class="run-dossier-anchor" tabindex="-1">${panel(
|
||||||
|
"Run assurance",
|
||||||
|
"Governed execution dossier",
|
||||||
|
"A control-by-control reconstruction backed by sanitized lifecycle evidence.",
|
||||||
|
`<div class="hero trace-hero">
|
||||||
|
<div class="hero-grid"><div><p class="eyebrow">Trace provenance</p><h2 class="trace-title">${esc(report.trace_id)}</h2>
|
||||||
|
<div class="hero-meta trace-meta"><span>${report.summary.gates_observed}/7 controls observed</span><span>${report.project.edition} edition</span></div></div>
|
||||||
|
<div>${pill(report.verdict)}</div></div></div>
|
||||||
|
<div class="rail-wrap rail-spaced"><div class="evidence-rail">${rail}</div></div>
|
||||||
|
<div class="dossier">
|
||||||
|
<div class="decision-card"><div><p class="eyebrow">Selected control</p><h2>${esc(selected.title)}</h2><p class="panel-description">${esc(selected.description)}</p></div>
|
||||||
|
<div class="inset"><span class="inset-label">Latest decision · ${esc(time(selected.updated_at))}</span><p>${esc(selected.reason)}</p></div>
|
||||||
|
${disclosure("Event history", `${events.length} recent lifecycle events · hidden by default`, timeline)}
|
||||||
|
</div>
|
||||||
|
${disclosure("Safe evidence manifest", "Sanitized fields persisted by the harness · hidden by default", manifest, pill(`${entries.length} fields`))}
|
||||||
|
</div>`,
|
||||||
|
actions,
|
||||||
|
)}</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderH6() {
|
||||||
|
const data = state.h6;
|
||||||
|
if (!data) return;
|
||||||
|
const hasRuns = Number(data.summary?.runs) > 0;
|
||||||
|
const query = data.scope || {};
|
||||||
|
const queryObject = Object.fromEntries(Object.entries(query).filter(([, value]) => value));
|
||||||
|
const actions = `<a class="button secondary" href="${downloadUrl("/api/v1/reports/h6/export", { ...queryObject, format: "json" })}">JSON evidence</a>
|
||||||
|
<a class="button primary" href="${downloadUrl("/api/v1/reports/h6/export", { ...queryObject, format: "html" })}">Export dossier</a>`;
|
||||||
|
if (!hasRuns) {
|
||||||
|
const provider = state.project?.readiness?.dimensions?.provider_telemetry || {};
|
||||||
|
$("#h6-view").innerHTML =
|
||||||
|
hero({
|
||||||
|
eyebrow: "H6 · AgentOps assurance",
|
||||||
|
title: "H6 begins with the first governed run.",
|
||||||
|
description: "No runtime record exists yet, so CASAN hides failure, latency, token and cost counters instead of presenting fabricated zeroes.",
|
||||||
|
verdict: "waiting",
|
||||||
|
meta: [data.report_id, `Generated ${time(data.generated_at)}`, "No operational evidence yet"],
|
||||||
|
actions: "",
|
||||||
|
}) +
|
||||||
|
panel(
|
||||||
|
"H6 onboarding",
|
||||||
|
"Provider telemetry is optional.",
|
||||||
|
provider.summary || "Provider token and cost telemetry has not been observed; this does not block Core.",
|
||||||
|
`<div class="two-col">
|
||||||
|
<div class="notice success"><strong>Core remains ready</strong><br>Send a prompt through a configured client. CASAN will persist runtime evidence automatically.</div>
|
||||||
|
<div class="notice warning"><strong>No automatic export</strong><br>Open H6 or export a dossier only when a review artifact is needed.</div>
|
||||||
|
</div>`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const findings = data.findings.length ? `<div class="findings">${data.findings.map((item, index) => `<article class="finding ${slug(item.severity)}">
|
||||||
|
<span class="finding-index">${String(index + 1).padStart(2, "0")}</span><div><code>${esc(item.code)}</code><p>${esc(item.message)}</p></div></article>`).join("")}</div>`
|
||||||
|
: `<div class="notice success">No threshold breach was detected in the selected evidence boundary.</div>`;
|
||||||
|
const sources = data.evidence_sources.map((source) => {
|
||||||
|
const status = !source.present ? "missing" : source.stale ? "stale" : "fresh";
|
||||||
|
return `<article class="source"><div class="source-top"><div><h3>${esc(source.source)}</h3><div class="source-meta">${source.records} records · ${source.age_s == null ? "no timestamp" : `${number(source.age_s)}s old`}</div></div>${pill(status)}</div>
|
||||||
|
<div class="source-path">${esc(source.path)}</div></article>`;
|
||||||
|
}).join("");
|
||||||
|
const breakdown = (rows, subject) => `<div class="table-scroll"><table><thead><tr><th>${esc(subject)}</th><th class="right">Runs</th><th class="right">Failures</th><th class="right">Avg latency</th><th class="right">Tokens</th><th class="right">Cost</th></tr></thead>
|
||||||
|
<tbody>${rows.length ? rows.map((row) => `<tr><td><strong>${esc(row.key)}</strong></td><td class="right mono">${row.runs}</td><td class="right mono">${row.failures}</td><td class="right mono">${number(row.latency_avg_ms)} ms</td><td class="right mono">${number(row.tokens)}</td><td class="right mono">${money(row.cost_usd)}</td></tr>`).join("") : `<tr><td colspan="6">${empty("No matching records", `No ${subject.toLowerCase()} evidence is available.`)}</td></tr>`}</tbody></table></div>`;
|
||||||
|
const outcomes = distribution("Governed run outcome distribution", data.details.by_status.map((row) => ({
|
||||||
|
label: String(row.status).replaceAll("_", " "),
|
||||||
|
value: row.count,
|
||||||
|
detail: `${data.summary.runs ? Math.round((row.count / data.summary.runs) * 100) : 0}% of selected runs`,
|
||||||
|
tone: row.status === "failed" ? "danger" : row.status === "degraded" ? "warning" : "success",
|
||||||
|
})));
|
||||||
|
$("#h6-view").innerHTML =
|
||||||
|
hero({
|
||||||
|
eyebrow: "H6 · AgentOps assurance",
|
||||||
|
title: "Operational evidence, ready for a decision.",
|
||||||
|
description: "Runtime health, reliability, cost provenance and telemetry integrity—designed to answer what happened, why it matters and where the evidence lives.",
|
||||||
|
verdict: data.verdict,
|
||||||
|
meta: [data.report_id, `Generated ${time(data.generated_at)}`, `Freshness ${data.freshness.status}`],
|
||||||
|
actions,
|
||||||
|
}) +
|
||||||
|
disclosure("Filter the evidence boundary", "Project, date and trace filters stay out of the executive view until needed.", h6Filters(data)) +
|
||||||
|
`<section class="metric-grid">
|
||||||
|
${metric("Governed runs", number(data.summary.runs), `${data.summary.success} successful · ${data.summary.degraded} degraded`)}
|
||||||
|
${metric("Failure rate", `${data.summary.failure_rate_pct}%`, `${data.summary.failed} failed · ${data.summary.alerts} alerts`)}
|
||||||
|
${metric("P95 latency", `${number(data.summary.latency_ms.p95)} ms`, `P50 ${number(data.summary.latency_ms.p50)} ms`)}
|
||||||
|
${metric("Telemetry integrity", data.data_quality.status.replaceAll("_", " "), `${data.summary.coverage.token_pct}% token · ${data.summary.coverage.cost_pct}% cost`)}
|
||||||
|
</section>` +
|
||||||
|
`<div class="two-col">
|
||||||
|
${panel("Decision brief", "What requires attention", "Threshold findings are review signals, not maturity claims.", findings, pill(data.verdict))}
|
||||||
|
${panel("Outcome mix", "Run distribution", "Canonical runtime status values, normalized against the largest observed group.", outcomes, `<span class="mono metric-detail">${number(data.summary.runs)} total</span>`)}
|
||||||
|
</div>` +
|
||||||
|
panel("Evidence quality", "Coverage and disclosure", "Unavailable telemetry remains null. Coverage gaps stay visible.", `<div class="two-col">
|
||||||
|
<div class="coverage">${coverage("Token attribution", data.summary.coverage.token_pct, `${data.summary.coverage.token_records}/${data.summary.coverage.runtime_records} records`)}
|
||||||
|
${coverage("Cost attribution", data.summary.coverage.cost_pct, `${data.summary.coverage.cost_records}/${data.summary.coverage.runtime_records} records`)}</div>
|
||||||
|
<div class="inset"><span class="inset-label">Disclosure notes</span>${data.data_quality.warnings.length ? `<ul>${data.data_quality.warnings.map((warning) => `<li class="metric-detail">${esc(warning)}</li>`).join("")}</ul>` : `<p class="success-copy">All required sources are present with no estimation warning.</p>`}</div>
|
||||||
|
</div>`, pill(data.data_quality.status)) +
|
||||||
|
disclosure(
|
||||||
|
"Technical evidence and breakdowns",
|
||||||
|
"Provider counters, canonical paths and raw tables are preserved for audit without crowding the decision view.",
|
||||||
|
`<section class="metric-grid compact">
|
||||||
|
${metric("Provider tokens", data.summary.coverage.token_records > 0 ? number(data.summary.tokens.provider_total ?? data.summary.tokens.total) : "Unavailable", `${data.summary.coverage.token_pct}% record coverage`)}
|
||||||
|
${metric("Actual cost", money(data.summary.cost_usd.provider_actual), `${data.summary.coverage.cost_pct}% record coverage`)}
|
||||||
|
${metric("Provider calls", number(data.summary.provider_calls), "Provider-attributed requests")}
|
||||||
|
${metric("Retries", number(data.summary.retries), "Observed reroute attempts")}
|
||||||
|
</section>
|
||||||
|
<div class="technical-section"><p class="eyebrow">Source integrity</p><h3>Evidence provenance</h3><div class="source-grid">${sources}</div></div>
|
||||||
|
<div class="breakdown-grid technical-section">
|
||||||
|
${panel("Runtime", "Execution profile by step", "Latency, failure and attribution by governed lifecycle step.", breakdown(data.details.by_step, "Step"))}
|
||||||
|
${panel("Provider", "Model usage and provenance", "Only provider-reported token and actual cost values are shown.", breakdown(data.details.by_provider, "Provider · model"))}
|
||||||
|
</div>`,
|
||||||
|
pill(data.freshness.status),
|
||||||
|
);
|
||||||
|
bindH6Filters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function coverage(label, value, detail) {
|
||||||
|
const width = Math.round(Math.max(0, Math.min(100, Number(value) || 0)) / 5) * 5;
|
||||||
|
return `<div><div class="coverage-head"><strong>${esc(label)}</strong><span>${esc(value)}%</span></div>
|
||||||
|
<div class="coverage-track"><div class="coverage-fill width-${width}"></div></div>
|
||||||
|
<div class="metric-detail">${esc(detail)}</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function h6Filters(data) {
|
||||||
|
const scope = data.scope || {};
|
||||||
|
const options = data.available_filters || { projects: [], runs: [] };
|
||||||
|
const date = (value) => value ? value.slice(0, 10) : "";
|
||||||
|
return `<form id="h6-filters" class="filters">
|
||||||
|
<label class="field"><span>Project</span><select name="project"><option value="">All projects</option>${options.projects.map((value) => `<option value="${esc(value)}" ${scope.project === value ? "selected" : ""}>${esc(value)}</option>`).join("")}</select></label>
|
||||||
|
<label class="field"><span>From</span><input type="date" name="from" value="${esc(date(scope.from))}"></label>
|
||||||
|
<label class="field"><span>To</span><input type="date" name="to" value="${esc(date(scope.to))}"></label>
|
||||||
|
<label class="field"><span>Run / trace</span><input name="run" list="run-options" value="${esc(scope.run || "")}" placeholder="All governed runs"><datalist id="run-options">${options.runs.map((value) => `<option value="${esc(value)}"></option>`).join("")}</datalist></label>
|
||||||
|
<div class="filter-actions"><button class="button primary" type="submit">Apply</button><button class="button light" type="button" id="clear-h6">Clear</button></div>
|
||||||
|
</form>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTrace(traceId) {
|
||||||
|
if (!traceId) return;
|
||||||
|
state.trace = traceId;
|
||||||
|
state.selectedGate = "H1-context";
|
||||||
|
$("#loading").classList.remove("hidden");
|
||||||
|
try {
|
||||||
|
state.run = await api(`/api/v1/reports/run/${encodeURIComponent(traceId)}`);
|
||||||
|
setView("runs");
|
||||||
|
const dossier = $("#run-dossier");
|
||||||
|
if (dossier) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
dossier.focus({ preventScroll: true });
|
||||||
|
dossier.scrollIntoView({
|
||||||
|
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
|
||||||
|
block: "start",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError(`Run report could not be reconstructed: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
$("#loading").classList.add("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadH6(query = {}) {
|
||||||
|
const next = new URLSearchParams(Object.fromEntries(Object.entries(query).filter(([, value]) => value)));
|
||||||
|
$("#loading").classList.remove("hidden");
|
||||||
|
try {
|
||||||
|
state.h6 = await api(`/api/v1/reports/h6${next.size ? `?${next}` : ""}`);
|
||||||
|
renderH6();
|
||||||
|
} catch (error) {
|
||||||
|
showError(`H6 report could not be generated: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
$("#loading").classList.add("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindActions() {
|
||||||
|
document.querySelectorAll("[data-view-target]").forEach((button) => button.addEventListener("click", () => setView(button.dataset.viewTarget)));
|
||||||
|
document.querySelectorAll("[data-open-trace]").forEach((button) => button.addEventListener("click", () => loadTrace(button.dataset.openTrace)));
|
||||||
|
document.querySelectorAll("[data-open-h6]").forEach((button) => button.addEventListener("click", async () => {
|
||||||
|
await loadH6({ run: button.dataset.openH6 });
|
||||||
|
setView("h6");
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindGates() {
|
||||||
|
document.querySelectorAll("[data-gate]").forEach((button) => button.addEventListener("click", () => {
|
||||||
|
state.selectedGate = button.dataset.gate;
|
||||||
|
renderRuns();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindH6Filters() {
|
||||||
|
$("#h6-filters")?.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const values = Object.fromEntries(new FormData(event.currentTarget));
|
||||||
|
await loadH6(values);
|
||||||
|
});
|
||||||
|
$("#clear-h6")?.addEventListener("click", () => loadH6());
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(message) {
|
||||||
|
$("#error").textContent = message;
|
||||||
|
$("#error").classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeNav() {
|
||||||
|
$(".sidebar").classList.remove("open");
|
||||||
|
$("#nav-scrim").classList.add("hidden");
|
||||||
|
$("#menu-button").setAttribute("aria-expanded", "false");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
$("#error").classList.add("hidden");
|
||||||
|
$("#loading").classList.remove("hidden");
|
||||||
|
try {
|
||||||
|
[state.project, state.registry, state.h6] = await Promise.all([
|
||||||
|
api("/api/v1/project"),
|
||||||
|
api("/api/v1/runs?limit=100"),
|
||||||
|
api("/api/v1/reports/h6?limit=50"),
|
||||||
|
]);
|
||||||
|
$("#freshness-pill").textContent = `${state.h6.freshness.status} evidence`;
|
||||||
|
$("#freshness-pill").className = `pill ${slug(state.h6.freshness.status)}`;
|
||||||
|
renderOverview();
|
||||||
|
renderH6();
|
||||||
|
if (state.trace) {
|
||||||
|
await loadTrace(state.trace);
|
||||||
|
} else {
|
||||||
|
setView(state.view, false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError(`Local evidence could not be loaded: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
$("#loading").classList.add("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll(".nav-item").forEach((button) => button.addEventListener("click", () => setView(button.dataset.view)));
|
||||||
|
$("#refresh-button").addEventListener("click", loadAll);
|
||||||
|
$("#menu-button").addEventListener("click", () => {
|
||||||
|
const open = !$(".sidebar").classList.contains("open");
|
||||||
|
$(".sidebar").classList.toggle("open", open);
|
||||||
|
$("#nav-scrim").classList.toggle("hidden", !open);
|
||||||
|
$("#menu-button").setAttribute("aria-expanded", String(open));
|
||||||
|
});
|
||||||
|
$("#nav-scrim").addEventListener("click", closeNav);
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Escape") closeNav();
|
||||||
|
});
|
||||||
|
window.addEventListener("popstate", () => setView(new URLSearchParams(location.search).get("view") || "overview", false));
|
||||||
|
loadAll();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<rect width="64" height="64" rx="16" fill="#101828"/>
|
||||||
|
<rect x="10" y="10" width="44" height="44" rx="12" fill="#172033" stroke="#344054"/>
|
||||||
|
<path d="M39.5 22.5a14 14 0 1 0 0 19" fill="none" stroke="#67e8f9" stroke-width="5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 322 B |
@@ -0,0 +1,62 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="light" />
|
||||||
|
<meta name="theme-color" content="#101828" />
|
||||||
|
<title>CASAN · Local Assurance Viewer</title>
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="stylesheet" href="/app.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<a class="skip-link" href="#main">Skip to report</a>
|
||||||
|
<div id="app">
|
||||||
|
<aside class="sidebar" aria-label="Primary navigation">
|
||||||
|
<a class="brand" href="#overview" aria-label="CASAN overview">
|
||||||
|
<span class="brand-mark" aria-hidden="true">C</span>
|
||||||
|
<span><strong>CASAN</strong><small>Local Assurance</small></span>
|
||||||
|
</a>
|
||||||
|
<nav>
|
||||||
|
<button class="nav-item active" data-view="overview" type="button"><span aria-hidden="true">◫</span>Overview</button>
|
||||||
|
<button class="nav-item" data-view="runs" type="button"><span aria-hidden="true">⌁</span>Run registry</button>
|
||||||
|
<button class="nav-item" data-view="h6" type="button"><span aria-hidden="true">H6</span>AgentOps</button>
|
||||||
|
</nav>
|
||||||
|
<div class="local-seal">
|
||||||
|
<span class="live-dot"></span>
|
||||||
|
<div><strong>Local Core</strong><small>Loopback · read-only</small></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="shell">
|
||||||
|
<header class="topbar">
|
||||||
|
<button id="menu-button" class="icon-button menu-button" type="button" aria-label="Open navigation" aria-expanded="false">☰</button>
|
||||||
|
<div>
|
||||||
|
<p class="top-eyebrow">Independent evidence workspace</p>
|
||||||
|
<h1 id="page-title">Assurance overview</h1>
|
||||||
|
</div>
|
||||||
|
<div class="top-actions">
|
||||||
|
<span id="freshness-pill" class="pill neutral">Evidence loading</span>
|
||||||
|
<button id="refresh-button" class="icon-button" type="button" aria-label="Refresh evidence">↻</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="main" tabindex="-1">
|
||||||
|
<div id="loading" class="loading-state" aria-live="polite">
|
||||||
|
<div class="skeleton hero-skeleton"></div>
|
||||||
|
<div class="metric-grid">
|
||||||
|
<div class="skeleton metric-skeleton"></div><div class="skeleton metric-skeleton"></div>
|
||||||
|
<div class="skeleton metric-skeleton"></div><div class="skeleton metric-skeleton"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="error" class="notice error hidden" role="alert"></div>
|
||||||
|
<section id="overview-view" class="view hidden" aria-labelledby="page-title"></section>
|
||||||
|
<section id="runs-view" class="view hidden" aria-labelledby="page-title"></section>
|
||||||
|
<section id="h6-view" class="view hidden" aria-labelledby="page-title"></section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<div id="nav-scrim" class="nav-scrim hidden"></div>
|
||||||
|
</div>
|
||||||
|
<script src="/app.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "1.0.0",
|
||||||
|
"description": "Deterministic action classes and minimum risk floors shared by CASAN runtimes.",
|
||||||
|
"risk_order": ["low", "medium", "high", "critical"],
|
||||||
|
"classes": {
|
||||||
|
"read_only": {"risk_floor": "low", "side_effect_level": "none", "requires_approval": false, "actor_required": false, "evidence_required": false},
|
||||||
|
"write": {"risk_floor": "medium", "side_effect_level": "write", "requires_approval": false, "actor_required": false, "evidence_required": true},
|
||||||
|
"delete": {"risk_floor": "high", "side_effect_level": "destructive", "requires_approval": true, "actor_required": true, "evidence_required": true},
|
||||||
|
"database_mutation": {"risk_floor": "high", "side_effect_level": "write", "requires_approval": true, "actor_required": true, "evidence_required": true},
|
||||||
|
"migration": {"risk_floor": "high", "side_effect_level": "write", "requires_approval": true, "actor_required": true, "evidence_required": true},
|
||||||
|
"deployment": {"risk_floor": "high", "side_effect_level": "external", "requires_approval": true, "actor_required": true, "evidence_required": true},
|
||||||
|
"release": {"risk_floor": "high", "side_effect_level": "external", "requires_approval": true, "actor_required": true, "evidence_required": true},
|
||||||
|
"credential_access": {"risk_floor": "high", "side_effect_level": "sensitive_read", "requires_approval": true, "actor_required": true, "evidence_required": true},
|
||||||
|
"identity_permission_modification": {"risk_floor": "high", "side_effect_level": "write", "requires_approval": true, "actor_required": true, "evidence_required": true},
|
||||||
|
"external_network_side_effect": {"risk_floor": "medium", "side_effect_level": "external", "requires_approval": true, "actor_required": true, "evidence_required": true},
|
||||||
|
"infrastructure_modification": {"risk_floor": "high", "side_effect_level": "external", "requires_approval": true, "actor_required": true, "evidence_required": true},
|
||||||
|
"unknown": {"risk_floor": "high", "side_effect_level": "unknown", "requires_approval": true, "actor_required": true, "evidence_required": true}
|
||||||
|
},
|
||||||
|
"action_aliases": {
|
||||||
|
"agent_step": "read_only",
|
||||||
|
"read": "read_only",
|
||||||
|
"search": "read_only",
|
||||||
|
"write": "write",
|
||||||
|
"write_code": "write",
|
||||||
|
"write_file": "write",
|
||||||
|
"delete": "delete",
|
||||||
|
"delete_file": "delete",
|
||||||
|
"db_write": "database_mutation",
|
||||||
|
"database_mutation": "database_mutation",
|
||||||
|
"migration": "migration",
|
||||||
|
"migrate": "migration",
|
||||||
|
"deploy": "deployment",
|
||||||
|
"deployment": "deployment",
|
||||||
|
"launch": "deployment",
|
||||||
|
"release": "release",
|
||||||
|
"credential_access": "credential_access",
|
||||||
|
"secret_access": "credential_access",
|
||||||
|
"permission_change": "identity_permission_modification",
|
||||||
|
"identity_change": "identity_permission_modification",
|
||||||
|
"external_api": "external_network_side_effect",
|
||||||
|
"infrastructure_change": "infrastructure_modification"
|
||||||
|
},
|
||||||
|
"tool_aliases": {
|
||||||
|
"read": "read_only",
|
||||||
|
"grep": "read_only",
|
||||||
|
"glob": "read_only",
|
||||||
|
"search": "read_only",
|
||||||
|
"view": "read_only",
|
||||||
|
"codegraph_search": "read_only",
|
||||||
|
"codegraph_node": "read_only",
|
||||||
|
"codegraph_context": "read_only",
|
||||||
|
"edit": "write",
|
||||||
|
"write": "write",
|
||||||
|
"multiedit": "write",
|
||||||
|
"notebookedit": "write",
|
||||||
|
"apply_patch": "write",
|
||||||
|
"str_replace_editor": "write",
|
||||||
|
"create_file": "write",
|
||||||
|
"update_file": "write",
|
||||||
|
"delete_file": "delete",
|
||||||
|
"webfetch": "external_network_side_effect",
|
||||||
|
"web_fetch": "external_network_side_effect",
|
||||||
|
"browser": "external_network_side_effect",
|
||||||
|
"sendmessage": "external_network_side_effect"
|
||||||
|
},
|
||||||
|
"command_patterns": [
|
||||||
|
{"pattern": "(^|\\s)(kubectl|helm)(\\s|$).*(apply|delete|upgrade|install)|(^|\\s)terraform\\s+(apply|destroy)", "class": "infrastructure_modification"},
|
||||||
|
{"pattern": "(^|\\s)(deploy|deployment|release)(\\s|$)|git\\s+push", "class": "deployment"},
|
||||||
|
{"pattern": "(^|\\s)(prisma|alembic|flyway|liquibase|rails)\\s+.*(migrat|upgrade)|\\bmigration\\b", "class": "migration"},
|
||||||
|
{"pattern": "\\b(delete|insert|update|alter|drop|truncate)\\s+(from|into|table|database)|\\b(db_write|database_mutation)\\b", "class": "database_mutation"},
|
||||||
|
{"pattern": "(^|\\s)(rm|rmdir|unlink)\\s|delete_file", "class": "delete"},
|
||||||
|
{"pattern": "(\\.ssh/|id_rsa|id_ed25519|\\.aws/credentials|\\.env($|\\s)|secret|credential|api[_-]?key)", "class": "credential_access"},
|
||||||
|
{"pattern": "\\b(iam|chmod|chown|setfacl|role|permission)\\b.*\\b(add|create|delete|grant|modify|remove|set|update)\\b", "class": "identity_permission_modification"},
|
||||||
|
{"pattern": "(^|\\s)(curl|wget|scp|sftp|ssh|nc|ncat)\\s", "class": "external_network_side_effect"},
|
||||||
|
{"pattern": "(^|\\s)(cp|mv|mkdir|touch|tee|sed\\s+-i)\\s|(^|\\s)(npm|pnpm|yarn|pip|cargo|go)\\s+(install|add|get)\\b|(^|\\s)(cat|printf|echo).*(>|>>)", "class": "write"},
|
||||||
|
{"pattern": "^\\s*(ls|pwd|cat|head|tail|wc|rg|grep|find|stat|git\\s+(status|diff|log|show))\\b", "class": "read_only"}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Framework-independent CASAN Assurance Kernel contracts and policy primitives."""
|
||||||
|
|
||||||
|
from .adapters import AgenticBridgeAdapter, NativeHarnessAdapter
|
||||||
|
from .contracts import CONTRACT_VERSION, validate_bundle
|
||||||
|
from .policy import (
|
||||||
|
classify_action,
|
||||||
|
evaluate_failure_policy,
|
||||||
|
evaluate_h2_gate,
|
||||||
|
evaluate_registry_configuration,
|
||||||
|
evaluate_risk,
|
||||||
|
evaluate_trust_capabilities,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AgenticBridgeAdapter",
|
||||||
|
"CONTRACT_VERSION",
|
||||||
|
"NativeHarnessAdapter",
|
||||||
|
"classify_action",
|
||||||
|
"evaluate_failure_policy",
|
||||||
|
"evaluate_h2_gate",
|
||||||
|
"evaluate_registry_configuration",
|
||||||
|
"evaluate_risk",
|
||||||
|
"evaluate_trust_capabilities",
|
||||||
|
"validate_bundle",
|
||||||
|
]
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"""Compatibility adapters from existing runtimes into the canonical contract."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from .contracts import CONTRACT_VERSION, actor, evidence_digest, outcome, utc_now
|
||||||
|
from .policy import evaluate_risk
|
||||||
|
|
||||||
|
|
||||||
|
class NativeHarnessAdapter:
|
||||||
|
runtime_type = "casan-native-harness"
|
||||||
|
|
||||||
|
def map_execution(self, event: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
run_id = str(event.get("run_id") or event.get("trace_id") or "")
|
||||||
|
actor_id = str(event.get("actor") or "unidentified")
|
||||||
|
action_name = str(event.get("action") or "agent_step")
|
||||||
|
mode = str(event.get("mode") or "enforce")
|
||||||
|
risk = evaluate_risk(
|
||||||
|
action=action_name,
|
||||||
|
tool=str(event.get("tool") or action_name),
|
||||||
|
resource=str(event.get("resource") or ""),
|
||||||
|
command=str(event.get("command") or ""),
|
||||||
|
actor=actor_id if actor_id != "unidentified" else "",
|
||||||
|
environment=str(event.get("environment") or "development"),
|
||||||
|
)
|
||||||
|
execution_status = str(event.get("execution_status") or "unknown")
|
||||||
|
assurance_status = str(event.get("assurance_status") or "unknown")
|
||||||
|
certification = str(event.get("certification_status") or "non_certified")
|
||||||
|
return _bundle(
|
||||||
|
run_id=run_id,
|
||||||
|
runtime_type=self.runtime_type,
|
||||||
|
runtime_version=str(event.get("runtime_version") or "legacy-compatible"),
|
||||||
|
mode=mode,
|
||||||
|
actor_id=actor_id,
|
||||||
|
action_name=action_name,
|
||||||
|
risk=risk,
|
||||||
|
execution_status=execution_status,
|
||||||
|
assurance_status=assurance_status,
|
||||||
|
certification=certification,
|
||||||
|
event=event,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AgenticBridgeAdapter:
|
||||||
|
runtime_type = "agentic-bridge"
|
||||||
|
|
||||||
|
def map_execution(self, event: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
run_id = str(event.get("trace_id") or event.get("run_id") or "")
|
||||||
|
actor_id = str(event.get("actor") or "unidentified")
|
||||||
|
action_name = str(event.get("action") or event.get("last_action") or "agent_step")
|
||||||
|
mode = str(event.get("mode") or event.get("hook_trust_mode") or "observe")
|
||||||
|
risk = evaluate_risk(
|
||||||
|
action=action_name,
|
||||||
|
tool=str(event.get("tool") or event.get("last_tool") or action_name),
|
||||||
|
resource=str(event.get("resource") or ""),
|
||||||
|
command=str(event.get("command") or ""),
|
||||||
|
actor=actor_id if actor_id != "unidentified" else "",
|
||||||
|
environment=str(event.get("environment") or "development"),
|
||||||
|
)
|
||||||
|
execution_status = str(event.get("execution_status") or "unknown")
|
||||||
|
assurance_status = str(event.get("assurance_status") or "unknown")
|
||||||
|
certification = "certified" if event.get("certified") else "non_certified"
|
||||||
|
return _bundle(
|
||||||
|
run_id=run_id,
|
||||||
|
runtime_type=self.runtime_type,
|
||||||
|
runtime_version=str(event.get("adapter_version") or "legacy-compatible"),
|
||||||
|
mode=mode,
|
||||||
|
actor_id=actor_id,
|
||||||
|
action_name=action_name,
|
||||||
|
risk=risk,
|
||||||
|
execution_status=execution_status,
|
||||||
|
assurance_status=assurance_status,
|
||||||
|
certification=certification,
|
||||||
|
event=event,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bundle(
|
||||||
|
*,
|
||||||
|
run_id: str,
|
||||||
|
runtime_type: str,
|
||||||
|
runtime_version: str,
|
||||||
|
mode: str,
|
||||||
|
actor_id: str,
|
||||||
|
action_name: str,
|
||||||
|
risk: Mapping[str, Any],
|
||||||
|
execution_status: str,
|
||||||
|
assurance_status: str,
|
||||||
|
certification: str,
|
||||||
|
event: Mapping[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
step_id = str(event.get("step_id") or f"{run_id}:1")
|
||||||
|
correlation_id = str(event.get("correlation_id") or run_id)
|
||||||
|
raw_evidence = list(event.get("evidence") or [])
|
||||||
|
claim_id = f"{run_id}:assurance-claim"
|
||||||
|
evidence = []
|
||||||
|
for index, item in enumerate(raw_evidence, 1):
|
||||||
|
raw = item if isinstance(item, Mapping) else {"detail": str(item)}
|
||||||
|
canonical_evidence = {
|
||||||
|
"evidence_id": str(raw.get("evidence_id") or f"{run_id}:evidence:{index}"),
|
||||||
|
"claim_refs": [claim_id],
|
||||||
|
"producer_identity": runtime_type,
|
||||||
|
"timestamp": raw.get("at") or raw.get("timestamp") or utc_now(),
|
||||||
|
"artifact_ref": str(raw.get("artifact_ref") or f"inline:{run_id}:{index}"),
|
||||||
|
"validation_status": "valid" if raw.get("decision") in {"allow", "pass", "passed", "certified"} else "unverified",
|
||||||
|
"retention_class": str(raw.get("retention_class") or "runtime_assurance"),
|
||||||
|
"category": str(raw.get("category") or "runtime_control"),
|
||||||
|
}
|
||||||
|
canonical_evidence["integrity"] = {
|
||||||
|
"algorithm": "sha256",
|
||||||
|
"digest": evidence_digest(canonical_evidence),
|
||||||
|
"source_content_hash": raw.get("content_hash"),
|
||||||
|
}
|
||||||
|
evidence.append(canonical_evidence)
|
||||||
|
policy_decisions = []
|
||||||
|
for item in list(event.get("policy_decisions") or []):
|
||||||
|
if not isinstance(item, Mapping):
|
||||||
|
continue
|
||||||
|
decision = str(item.get("decision") or "observe_only")
|
||||||
|
if decision not in {"allow", "deny", "quarantine", "require_approval", "transform", "observe_only"}:
|
||||||
|
decision = "observe_only"
|
||||||
|
policy_decisions.append({
|
||||||
|
"policy_id": str(item.get("policy_id") or "casan.compatibility.policy"),
|
||||||
|
"policy_version": str(item.get("policy_version") or item.get("schema_version") or CONTRACT_VERSION),
|
||||||
|
"decision": decision,
|
||||||
|
"reason_codes": list(item.get("reason_codes") or [item.get("reason_code") or "compatibility_mapping"]),
|
||||||
|
"effective_risk": str(item.get("effective_risk") or "high"),
|
||||||
|
"input_facts": dict(item),
|
||||||
|
"enforcement_point": str(item.get("enforcement_path") or runtime_type),
|
||||||
|
"timestamp": str(item.get("timestamp") or utc_now()),
|
||||||
|
"decision_engine_identity": "casan-assurance-kernel",
|
||||||
|
"evidence_ref": evidence[0]["evidence_id"] if evidence else None,
|
||||||
|
})
|
||||||
|
result = outcome(execution_status, assurance_status, certification)
|
||||||
|
return {
|
||||||
|
"schema_version": CONTRACT_VERSION,
|
||||||
|
"extension_namespace": f"casan.runtime.{runtime_type}",
|
||||||
|
"run": {
|
||||||
|
"run_id": run_id,
|
||||||
|
"parent_run_id": event.get("parent_run_id"),
|
||||||
|
"tenant": str(event.get("tenant") or "default"),
|
||||||
|
"project": str(event.get("project") or event.get("project_id") or "default"),
|
||||||
|
"environment": str(event.get("environment") or "development"),
|
||||||
|
"runtime": {"type": runtime_type, "version": runtime_version},
|
||||||
|
"mode": mode,
|
||||||
|
"requested_goal": str(event.get("requested_goal") or ""),
|
||||||
|
"started_at": event.get("started_at") or event.get("timestamp") or utc_now(),
|
||||||
|
"completed_at": event.get("completed_at") or utc_now(),
|
||||||
|
"status": execution_status,
|
||||||
|
"risk_summary": dict(risk),
|
||||||
|
"evidence_manifest_ref": event.get("evidence_manifest_ref"),
|
||||||
|
"correlation_id": correlation_id,
|
||||||
|
"outcome": result,
|
||||||
|
},
|
||||||
|
"actors": [actor(actor_id, "agent" if runtime_type == "casan-native-harness" else "runtime", runtime_type, "verified" if actor_id != "unidentified" else "unverified", event.get("authentication_evidence_ref"))],
|
||||||
|
"steps": [{
|
||||||
|
"step_id": step_id,
|
||||||
|
"run_id": run_id,
|
||||||
|
"parent_step_id": event.get("parent_step_id"),
|
||||||
|
"sequence": int(event.get("sequence") or 1),
|
||||||
|
"actor_ref": actor_id,
|
||||||
|
"action": {"class": risk["action_class"], "name": action_name, "tool": event.get("tool"), "side_effect_level": risk["side_effect_level"]},
|
||||||
|
"resource": event.get("resource"),
|
||||||
|
"input_context_refs": list(event.get("input_context_refs") or []),
|
||||||
|
"policy_decisions": policy_decisions,
|
||||||
|
"tool_invocation": event.get("tool_invocation"),
|
||||||
|
"verification_results": list(event.get("verification_results") or []),
|
||||||
|
"evidence_refs": [item["evidence_id"] for item in evidence],
|
||||||
|
"outcome": result,
|
||||||
|
"started_at": event.get("started_at") or event.get("timestamp") or utc_now(),
|
||||||
|
"completed_at": event.get("completed_at") or utc_now(),
|
||||||
|
}],
|
||||||
|
"runtime_capabilities": {
|
||||||
|
"canCancel": False, "canPause": False, "canResume": False,
|
||||||
|
"canRollback": False, "canReduceAuthority": False,
|
||||||
|
"canQuarantine": runtime_type == "agentic-bridge",
|
||||||
|
},
|
||||||
|
"claims": [{
|
||||||
|
"claim_id": claim_id,
|
||||||
|
"statement": "CASAN evaluated the runtime assurance outcome",
|
||||||
|
"evidence_refs": [item["evidence_id"] for item in evidence],
|
||||||
|
"validation_status": "validated" if assurance_status == "passed" else "insufficient",
|
||||||
|
}],
|
||||||
|
"evidence": evidence,
|
||||||
|
"trace_links": [
|
||||||
|
{"type": "parent_to_child_execution", "from": run_id, "to": step_id},
|
||||||
|
*[
|
||||||
|
{"type": "evidence_to_outcome", "from": item["evidence_id"], "to": claim_id}
|
||||||
|
for item in evidence
|
||||||
|
],
|
||||||
|
],
|
||||||
|
"extensions": {f"casan.runtime.{runtime_type}": dict(event.get("extensions") or {})},
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
"""Canonical CASAN wire-contract builders and lightweight validation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
CONTRACT_VERSION = "1.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def actor(actor_id: str, actor_type: str, issuer: str, trust_level: str, auth_evidence_ref: str | None = None) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"actor_id": actor_id,
|
||||||
|
"actor_type": actor_type,
|
||||||
|
"issuer": issuer,
|
||||||
|
"trust_level": trust_level,
|
||||||
|
"authentication_evidence_ref": auth_evidence_ref,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def outcome(execution: str, assurance: str, certification: str, business: str = "not_evaluated") -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"execution_result": execution,
|
||||||
|
"assurance_result": assurance,
|
||||||
|
"certification_result": certification,
|
||||||
|
"business_result": business,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def evidence_digest(evidence: Mapping[str, Any]) -> str:
|
||||||
|
"""Digest the claim-bearing evidence metadata, excluding its digest field."""
|
||||||
|
|
||||||
|
material = {
|
||||||
|
key: evidence.get(key)
|
||||||
|
for key in (
|
||||||
|
"evidence_id", "claim_refs", "producer_identity", "timestamp",
|
||||||
|
"artifact_ref", "validation_status", "retention_class", "category",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
payload = json.dumps(material, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
||||||
|
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_evidence_integrity(evidence: Mapping[str, Any]) -> bool:
|
||||||
|
integrity = evidence.get("integrity")
|
||||||
|
if not isinstance(integrity, Mapping) or integrity.get("algorithm") != "sha256":
|
||||||
|
return False
|
||||||
|
digest = integrity.get("digest")
|
||||||
|
return isinstance(digest, str) and digest == evidence_digest(evidence)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_bundle(bundle: Mapping[str, Any]) -> list[str]:
|
||||||
|
"""Return deterministic contract errors; an empty list means valid.
|
||||||
|
|
||||||
|
JSON Schema is canonical. This small validator keeps runtime adapters stdlib-
|
||||||
|
only and catches the invariants needed at enforcement boundaries.
|
||||||
|
"""
|
||||||
|
|
||||||
|
errors: list[str] = []
|
||||||
|
if bundle.get("schema_version") != CONTRACT_VERSION:
|
||||||
|
errors.append("unsupported_schema_version")
|
||||||
|
run = bundle.get("run")
|
||||||
|
if not isinstance(run, Mapping):
|
||||||
|
errors.append("missing_execution_run")
|
||||||
|
return errors
|
||||||
|
for key in ("run_id", "runtime", "mode", "status", "correlation_id", "outcome"):
|
||||||
|
if not run.get(key):
|
||||||
|
errors.append(f"run.{key}_required")
|
||||||
|
actors = bundle.get("actors")
|
||||||
|
actor_ids: set[object] = set()
|
||||||
|
if not isinstance(actors, list) or not actors:
|
||||||
|
errors.append("actors_required")
|
||||||
|
else:
|
||||||
|
actor_ids = {item.get("actor_id") for item in actors if isinstance(item, Mapping)}
|
||||||
|
if "" in actor_ids or None in actor_ids:
|
||||||
|
errors.append("actor_identity_required")
|
||||||
|
steps = bundle.get("steps")
|
||||||
|
if not isinstance(steps, list):
|
||||||
|
errors.append("steps_required")
|
||||||
|
else:
|
||||||
|
for index, step in enumerate(steps):
|
||||||
|
if not isinstance(step, Mapping):
|
||||||
|
errors.append(f"steps.{index}_invalid")
|
||||||
|
continue
|
||||||
|
for key in ("step_id", "run_id", "sequence", "actor_ref", "action", "outcome"):
|
||||||
|
if step.get(key) is None:
|
||||||
|
errors.append(f"steps.{index}.{key}_required")
|
||||||
|
if step.get("actor_ref") not in actor_ids:
|
||||||
|
errors.append(f"steps.{index}.actor_ref_unknown")
|
||||||
|
|
||||||
|
evidence = bundle.get("evidence")
|
||||||
|
evidence_ids: set[object] = set()
|
||||||
|
if not isinstance(evidence, list):
|
||||||
|
errors.append("evidence_required")
|
||||||
|
else:
|
||||||
|
for index, item in enumerate(evidence):
|
||||||
|
if not isinstance(item, Mapping):
|
||||||
|
errors.append(f"evidence.{index}_invalid")
|
||||||
|
continue
|
||||||
|
evidence_ids.add(item.get("evidence_id"))
|
||||||
|
if not verify_evidence_integrity(item):
|
||||||
|
errors.append(f"evidence.{index}.integrity_invalid")
|
||||||
|
|
||||||
|
claims = bundle.get("claims")
|
||||||
|
claim_ids = {
|
||||||
|
item.get("claim_id") for item in claims or [] if isinstance(item, Mapping)
|
||||||
|
} if isinstance(claims, list) else set()
|
||||||
|
if isinstance(claims, list):
|
||||||
|
for index, claim in enumerate(claims):
|
||||||
|
if not isinstance(claim, Mapping):
|
||||||
|
errors.append(f"claims.{index}_invalid")
|
||||||
|
continue
|
||||||
|
for evidence_ref in claim.get("evidence_refs") or []:
|
||||||
|
if evidence_ref not in evidence_ids:
|
||||||
|
errors.append(f"claims.{index}.evidence_ref_unknown")
|
||||||
|
if isinstance(evidence, list):
|
||||||
|
for index, item in enumerate(evidence):
|
||||||
|
if isinstance(item, Mapping):
|
||||||
|
for claim_ref in item.get("claim_refs") or []:
|
||||||
|
if claim_ref not in claim_ids:
|
||||||
|
errors.append(f"evidence.{index}.claim_ref_unknown")
|
||||||
|
|
||||||
|
delegations = bundle.get("delegations")
|
||||||
|
if delegations is not None:
|
||||||
|
errors.extend(_validate_delegations(delegations, actor_ids))
|
||||||
|
if run.get("mode") == "observe" and isinstance(run.get("outcome"), Mapping):
|
||||||
|
if run["outcome"].get("certification_result") == "certified":
|
||||||
|
errors.append("observed_only_cannot_be_certified")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_delegations(delegations: object, actor_ids: set[object]) -> list[str]:
|
||||||
|
if not isinstance(delegations, list):
|
||||||
|
return ["delegations_invalid"]
|
||||||
|
errors: list[str] = []
|
||||||
|
records = {
|
||||||
|
item.get("delegation_id"): item
|
||||||
|
for item in delegations if isinstance(item, Mapping) and item.get("delegation_id")
|
||||||
|
}
|
||||||
|
for index, item in enumerate(delegations):
|
||||||
|
if not isinstance(item, Mapping):
|
||||||
|
errors.append(f"delegations.{index}_invalid")
|
||||||
|
continue
|
||||||
|
for actor_key in ("delegator_ref", "delegate_ref"):
|
||||||
|
if item.get(actor_key) not in actor_ids:
|
||||||
|
errors.append(f"delegations.{index}.{actor_key}_unknown")
|
||||||
|
current: Mapping[str, Any] = item
|
||||||
|
visited: set[object] = set()
|
||||||
|
depth = 0
|
||||||
|
while current.get("parent_delegation_ref"):
|
||||||
|
parent_ref = current.get("parent_delegation_ref")
|
||||||
|
if parent_ref in visited:
|
||||||
|
errors.append(f"delegations.{index}.cycle")
|
||||||
|
break
|
||||||
|
visited.add(parent_ref)
|
||||||
|
parent = records.get(parent_ref)
|
||||||
|
if not isinstance(parent, Mapping):
|
||||||
|
errors.append(f"delegations.{index}.parent_unknown")
|
||||||
|
break
|
||||||
|
depth += 1
|
||||||
|
current = parent
|
||||||
|
maximum_depth = item.get("maximum_depth")
|
||||||
|
if isinstance(maximum_depth, int) and depth > maximum_depth:
|
||||||
|
errors.append(f"delegations.{index}.maximum_depth_exceeded")
|
||||||
|
return errors
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
"""Deterministic security policy primitives shared by every CASAN adapter.
|
||||||
|
|
||||||
|
This module is deliberately stdlib-only. It never calls a model and never treats
|
||||||
|
model text as an enforcement decision. The JSON action registry is the canonical
|
||||||
|
machine-readable source; callers receive structured decisions rather than booleans.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping, MutableMapping, Optional
|
||||||
|
|
||||||
|
|
||||||
|
POLICY_VERSION = "1.0.0"
|
||||||
|
RISK_ORDER = {"low": 0, "medium": 1, "high": 2, "critical": 3}
|
||||||
|
PRODUCTION_PROFILES = {"prod", "production", "strict"}
|
||||||
|
DEVELOPMENT_PROFILES = {"", "dev", "development", "local", "test", "testing"}
|
||||||
|
FAILURE_POLICIES = {"halt", "quarantine", "require_approval", "record_only"}
|
||||||
|
|
||||||
|
|
||||||
|
def _harness_root() -> Path:
|
||||||
|
override = os.environ.get("CASAN_HARNESS_ROOT")
|
||||||
|
return Path(override).resolve() if override else Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _action_registry_path() -> Path:
|
||||||
|
override = os.environ.get("CASAN_ACTION_CLASS_REGISTRY")
|
||||||
|
return Path(override).resolve() if override else _harness_root() / "config" / "action-classes.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_action_registry() -> dict[str, Any]:
|
||||||
|
with _action_registry_path().open(encoding="utf-8") as handle:
|
||||||
|
payload = json.load(handle)
|
||||||
|
if not isinstance(payload, dict) or not isinstance(payload.get("classes"), dict):
|
||||||
|
raise ValueError("action class registry is malformed")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _normal(value: object) -> str:
|
||||||
|
return re.sub(r"[^a-z0-9]+", "_", str(value or "").strip().lower()).strip("_")
|
||||||
|
|
||||||
|
|
||||||
|
def _higher(left: str, right: str) -> str:
|
||||||
|
return right if RISK_ORDER.get(right, 3) > RISK_ORDER.get(left, 3) else left
|
||||||
|
|
||||||
|
|
||||||
|
def _content_risk(text: str) -> str:
|
||||||
|
value = (text or "").lower()
|
||||||
|
if re.search(r"\b(drop\s+table|truncate|password|api[_-]?key|secret|credential|shutdown|rm\s+-rf)\b", value):
|
||||||
|
return "high"
|
||||||
|
if re.search(r"\b(deploy|release|migration|permission|policy|external\s+api|database)\b", value):
|
||||||
|
return "medium"
|
||||||
|
return "low"
|
||||||
|
|
||||||
|
|
||||||
|
def _resource_risk(resource: str) -> str:
|
||||||
|
value = (resource or "").lower()
|
||||||
|
if re.search(r"(^|/)(\.env|\.ssh|\.aws)(/|$)|\.(pem|key|p12|pfx)$|credential|secret", value):
|
||||||
|
return "high"
|
||||||
|
if re.search(r"\b(prod|production|customer|tenant)\b", value):
|
||||||
|
return "medium"
|
||||||
|
return "low"
|
||||||
|
|
||||||
|
|
||||||
|
def _environment_risk(environment: str) -> str:
|
||||||
|
return "medium" if _normal(environment) in PRODUCTION_PROFILES else "low"
|
||||||
|
|
||||||
|
|
||||||
|
def classify_action(
|
||||||
|
action: str = "",
|
||||||
|
tool: str = "",
|
||||||
|
resource: str = "",
|
||||||
|
command: str = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Classify an action using deterministic aliases and command patterns.
|
||||||
|
|
||||||
|
When several signals match, the class with the highest risk floor wins. An
|
||||||
|
unknown side-effect-shaped tool is therefore never downgraded by benign text.
|
||||||
|
"""
|
||||||
|
|
||||||
|
registry = _load_action_registry()
|
||||||
|
classes = registry["classes"]
|
||||||
|
candidates: list[tuple[str, str]] = []
|
||||||
|
action_key = _normal(action)
|
||||||
|
tool_key = _normal(tool)
|
||||||
|
|
||||||
|
action_class = registry.get("action_aliases", {}).get(action_key)
|
||||||
|
if action_class:
|
||||||
|
candidates.append(("action", action_class))
|
||||||
|
tool_class = registry.get("tool_aliases", {}).get(tool_key)
|
||||||
|
if tool_class:
|
||||||
|
candidates.append(("tool", tool_class))
|
||||||
|
combined = " ".join(part for part in (command, resource) if part)
|
||||||
|
for rule in registry.get("command_patterns", []):
|
||||||
|
try:
|
||||||
|
if re.search(str(rule["pattern"]), combined, re.IGNORECASE):
|
||||||
|
candidates.append(("command_or_resource", str(rule["class"])))
|
||||||
|
except (KeyError, re.error, TypeError) as exc:
|
||||||
|
raise ValueError("invalid action class pattern") from exc
|
||||||
|
|
||||||
|
shell_like = tool_key in {"bash", "shell", "exec", "run", "run_command", "terminal"}
|
||||||
|
if not candidates and shell_like:
|
||||||
|
candidates.append(("unknown_shell_command", "unknown"))
|
||||||
|
if not candidates:
|
||||||
|
candidates.append(("unclassified", "unknown"))
|
||||||
|
|
||||||
|
selected_source, selected_class = candidates[0]
|
||||||
|
selected_risk = str(classes[selected_class]["risk_floor"])
|
||||||
|
for source, candidate in candidates[1:]:
|
||||||
|
risk = str(classes[candidate]["risk_floor"])
|
||||||
|
if RISK_ORDER.get(risk, 3) > RISK_ORDER.get(selected_risk, 3):
|
||||||
|
selected_source, selected_class, selected_risk = source, candidate, risk
|
||||||
|
|
||||||
|
metadata = classes[selected_class]
|
||||||
|
return {
|
||||||
|
"schema_version": registry.get("schema_version", POLICY_VERSION),
|
||||||
|
"action_class": selected_class,
|
||||||
|
"classification_source": selected_source,
|
||||||
|
"risk_floor": selected_risk,
|
||||||
|
"side_effect_level": metadata["side_effect_level"],
|
||||||
|
"side_effecting": metadata["side_effect_level"] != "none",
|
||||||
|
"requires_approval": bool(metadata["requires_approval"]),
|
||||||
|
"actor_required": bool(metadata["actor_required"]),
|
||||||
|
"evidence_required": bool(metadata["evidence_required"]),
|
||||||
|
"matched_classes": [candidate for _source, candidate in candidates],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_risk(
|
||||||
|
action: str = "",
|
||||||
|
tool: str = "",
|
||||||
|
resource: str = "",
|
||||||
|
command: str = "",
|
||||||
|
content: str = "",
|
||||||
|
actor: str = "",
|
||||||
|
environment: str = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
classification = classify_action(action, tool, resource, command)
|
||||||
|
actor_present = bool(str(actor or "").strip())
|
||||||
|
identity_risk = "high" if classification["actor_required"] and not actor_present else "low"
|
||||||
|
factors = {
|
||||||
|
"content_risk": _content_risk(content),
|
||||||
|
"action_risk": classification["risk_floor"],
|
||||||
|
"resource_risk": _resource_risk(resource),
|
||||||
|
"identity_risk": identity_risk,
|
||||||
|
"environment_risk": _environment_risk(environment),
|
||||||
|
}
|
||||||
|
effective = "low"
|
||||||
|
for factor in factors.values():
|
||||||
|
effective = _higher(effective, factor)
|
||||||
|
|
||||||
|
reason_codes: list[str] = ["action_risk_floor_applied"]
|
||||||
|
if classification["actor_required"] and not actor_present:
|
||||||
|
decision = "deny"
|
||||||
|
reason_codes.append("actor_identity_required")
|
||||||
|
elif classification["requires_approval"] or effective in {"high", "critical"}:
|
||||||
|
decision = "require_approval"
|
||||||
|
reason_codes.append("explicit_approval_required")
|
||||||
|
else:
|
||||||
|
decision = "allow"
|
||||||
|
reason_codes.append("risk_within_auto_approval_policy")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"schema_version": POLICY_VERSION,
|
||||||
|
"policy_id": "casan.action-risk-floor",
|
||||||
|
"policy_version": POLICY_VERSION,
|
||||||
|
"decision": decision,
|
||||||
|
"reason_codes": reason_codes,
|
||||||
|
"effective_risk": effective,
|
||||||
|
"risk_factors": factors,
|
||||||
|
"actor_identity_present": actor_present,
|
||||||
|
"evidence_requirement": "required" if classification["evidence_required"] else "standard",
|
||||||
|
**classification,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_registry_configuration(
|
||||||
|
mode: str,
|
||||||
|
profile: str,
|
||||||
|
explicit_value: Optional[str],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
normalized_mode = _normal(mode)
|
||||||
|
normalized_profile = _normal(profile)
|
||||||
|
explicit = None if explicit_value is None else _normal(explicit_value) in {"1", "true", "yes", "on", "enabled"}
|
||||||
|
production = normalized_profile in PRODUCTION_PROFILES
|
||||||
|
|
||||||
|
if explicit is False and production:
|
||||||
|
return {
|
||||||
|
"enabled": True,
|
||||||
|
"valid": False,
|
||||||
|
"unsafe_bypass": False,
|
||||||
|
"severity": "critical",
|
||||||
|
"reason_code": "h2_registry_bypass_forbidden",
|
||||||
|
}
|
||||||
|
if explicit is False:
|
||||||
|
return {
|
||||||
|
"enabled": False,
|
||||||
|
"valid": normalized_profile in DEVELOPMENT_PROFILES,
|
||||||
|
"unsafe_bypass": True,
|
||||||
|
"severity": "high",
|
||||||
|
"reason_code": "h2_registry_unsafe_development_bypass",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"enabled": True if explicit is None else explicit,
|
||||||
|
"valid": True,
|
||||||
|
"unsafe_bypass": False,
|
||||||
|
"severity": "info",
|
||||||
|
"reason_code": "h2_registry_default_enabled" if explicit is None and normalized_mode == "enforce" else "h2_registry_enabled",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_failure_policy(
|
||||||
|
*,
|
||||||
|
side_effecting: bool,
|
||||||
|
mode: str,
|
||||||
|
profile: str,
|
||||||
|
configured: Optional[str] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Select single-step failure handling without conflating telemetry success.
|
||||||
|
|
||||||
|
Invalid configuration fails closed. Production side effects may be made
|
||||||
|
stricter, but they may never be configured as record-only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
normalized = _normal(configured or "")
|
||||||
|
production = _normal(profile) in PRODUCTION_PROFILES
|
||||||
|
enforce = _normal(mode) == "enforce"
|
||||||
|
if normalized and normalized not in FAILURE_POLICIES:
|
||||||
|
policy, source, valid = "halt", "invalid_policy_fail_closed", False
|
||||||
|
elif normalized == "record_only" and side_effecting and production:
|
||||||
|
policy, source, valid = "halt", "production_record_only_forbidden", False
|
||||||
|
elif normalized:
|
||||||
|
policy, source, valid = normalized, "configured", True
|
||||||
|
elif side_effecting and (enforce or production):
|
||||||
|
policy, source, valid = "halt", "safe_enforce_default", True
|
||||||
|
else:
|
||||||
|
policy, source, valid = "record_only", "observe_or_read_only_default", True
|
||||||
|
return {
|
||||||
|
"schema_version": POLICY_VERSION,
|
||||||
|
"policy_id": "casan.single-step-failure",
|
||||||
|
"policy_version": POLICY_VERSION,
|
||||||
|
"failure_policy": policy,
|
||||||
|
"source": source,
|
||||||
|
"valid": valid,
|
||||||
|
"side_effecting": side_effecting,
|
||||||
|
"mode": _normal(mode) or "observe",
|
||||||
|
"profile": _normal(profile) or "development",
|
||||||
|
"execution_result": "failed",
|
||||||
|
"telemetry_result": "recorded",
|
||||||
|
"assurance_may_continue": policy == "record_only",
|
||||||
|
"reason_codes": [source],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class H2GateContext:
|
||||||
|
mode: str
|
||||||
|
actor: str
|
||||||
|
action: str
|
||||||
|
tool: str
|
||||||
|
execution_id: str
|
||||||
|
enforcement_path: str
|
||||||
|
idempotency_key: str
|
||||||
|
timeout_seconds: float = 8.0
|
||||||
|
|
||||||
|
|
||||||
|
def _h2_result(context: H2GateContext, underlying: str, reason_code: str, detail: str) -> dict[str, Any]:
|
||||||
|
enforce = _normal(context.mode) == "enforce"
|
||||||
|
actual_deny = underlying == "deny"
|
||||||
|
dependency_failure = underlying == "error"
|
||||||
|
execution_allowed = not enforce or (not actual_deny and not dependency_failure)
|
||||||
|
if enforce:
|
||||||
|
decision = "allow" if execution_allowed else "deny"
|
||||||
|
else:
|
||||||
|
decision = "observe_only"
|
||||||
|
return {
|
||||||
|
"schema_version": POLICY_VERSION,
|
||||||
|
"policy_id": "casan.h2.tool-registry",
|
||||||
|
"policy_version": POLICY_VERSION,
|
||||||
|
"decision": decision,
|
||||||
|
"underlying_decision": underlying,
|
||||||
|
"reason_codes": [reason_code],
|
||||||
|
"reason_code": reason_code,
|
||||||
|
"mode": _normal(context.mode) or "observe",
|
||||||
|
"actor": context.actor or "unidentified",
|
||||||
|
"action": context.action,
|
||||||
|
"tool": context.tool,
|
||||||
|
"execution_id": context.execution_id,
|
||||||
|
"enforcement_path": context.enforcement_path,
|
||||||
|
"execution_allowed": execution_allowed,
|
||||||
|
"certifiable": enforce and execution_allowed,
|
||||||
|
"assurance_status": "enforced" if enforce and execution_allowed else "denied" if enforce else "degraded",
|
||||||
|
"severity": "high" if reason_code != "h2_ok" else "info",
|
||||||
|
"detail": detail[:400],
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_h2_gate(
|
||||||
|
gate_path: str,
|
||||||
|
context: H2GateContext,
|
||||||
|
env: Optional[Mapping[str, str]] = None,
|
||||||
|
bash_path: str = "bash",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Execute the H2 dependency and convert every outcome into one policy decision."""
|
||||||
|
|
||||||
|
path = Path(gate_path)
|
||||||
|
if not path.is_file():
|
||||||
|
return _h2_result(context, "error", "h2_gate_unavailable", "gate file is missing")
|
||||||
|
try:
|
||||||
|
mode_bits = stat.S_IMODE(path.stat().st_mode)
|
||||||
|
except OSError as exc:
|
||||||
|
return _h2_result(context, "error", "h2_gate_unavailable", f"gate stat failed: {type(exc).__name__}")
|
||||||
|
if mode_bits & 0o444 == 0:
|
||||||
|
return _h2_result(context, "error", "h2_gate_permission_denied", "gate file is not readable")
|
||||||
|
resolved_bash = shutil.which(bash_path) if not os.path.isabs(bash_path) else bash_path
|
||||||
|
if not resolved_bash or not Path(resolved_bash).is_file():
|
||||||
|
return _h2_result(context, "error", "h2_gate_unavailable", "bash runtime is unavailable")
|
||||||
|
|
||||||
|
child_env: MutableMapping[str, str] = dict(os.environ)
|
||||||
|
if env:
|
||||||
|
child_env.update({str(key): str(value) for key, value in env.items()})
|
||||||
|
child_env["CASAN_IDEMPOTENCY_KEY"] = context.idempotency_key
|
||||||
|
if context.actor and not child_env.get("CASAN_AGENT"):
|
||||||
|
child_env["CASAN_AGENT"] = context.actor
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
[resolved_bash, str(path), context.action],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
timeout=max(0.001, context.timeout_seconds),
|
||||||
|
env=child_env,
|
||||||
|
check=False,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return _h2_result(context, "error", "h2_gate_timeout", "gate exceeded internal timeout")
|
||||||
|
except (OSError, ValueError, subprocess.SubprocessError) as exc:
|
||||||
|
return _h2_result(context, "error", "h2_gate_internal_error", type(exc).__name__)
|
||||||
|
|
||||||
|
stdout = proc.stdout.strip()[:4096]
|
||||||
|
stderr = proc.stderr.strip()[:4096]
|
||||||
|
if proc.returncode == 0 and re.search(r"\bTOOL_APPROVED\b", stdout):
|
||||||
|
return _h2_result(context, "allow", "h2_ok", stdout)
|
||||||
|
if re.search(r"\bTOOL_DENIED\b", stdout + "\n" + stderr):
|
||||||
|
reason_match = re.search(r"reason=([^\s]+)", stdout + "\n" + stderr)
|
||||||
|
reason = "h2_denied:%s" % (reason_match.group(1) if reason_match else "policy")
|
||||||
|
return _h2_result(context, "deny", reason, stderr or stdout)
|
||||||
|
if proc.returncode in {126, 127}:
|
||||||
|
return _h2_result(context, "error", "h2_gate_unavailable", stderr or "gate runtime unavailable")
|
||||||
|
if proc.returncode != 0:
|
||||||
|
return _h2_result(context, "error", "h2_gate_internal_error", stderr or stdout or f"exit={proc.returncode}")
|
||||||
|
return _h2_result(context, "error", "h2_gate_malformed_response", stdout or "empty response")
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_trust_capabilities(env: Optional[Mapping[str, str]] = None) -> dict[str, Any]:
|
||||||
|
values = dict(os.environ if env is None else env)
|
||||||
|
profile = _normal(values.get("CASAN_PROFILE", ""))
|
||||||
|
production = profile in PRODUCTION_PROFILES
|
||||||
|
signing_provider = _normal(values.get("CASAN_SIGNING_PROVIDER", "local_openssl"))
|
||||||
|
anchor_provider = _normal(values.get("CASAN_IMMUTABLE_ANCHOR_PROVIDER", "local_hash_chain"))
|
||||||
|
emergency = _normal(values.get("CASAN_TRUST_EMERGENCY_OVERRIDE", "")) in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
signing_external = signing_provider in {"vault", "vault_kms", "kms", "hsm"}
|
||||||
|
anchor_external = anchor_provider in {"s3_object_lock", "qldb", "external_worm"}
|
||||||
|
vault_addr = str(values.get("VAULT_ADDR") or "")
|
||||||
|
signing_configured = signing_external and vault_addr.startswith("https://") and bool(values.get("VAULT_TOKEN"))
|
||||||
|
anchor_configured = anchor_external and bool(values.get("CASAN_S3_BUCKET")) and bool(values.get("CASAN_S3_KMS_KEY_ID"))
|
||||||
|
ready = (not production) or (signing_configured and anchor_configured)
|
||||||
|
certifiable = ready and not emergency
|
||||||
|
reasons: list[str] = []
|
||||||
|
if production and not signing_configured:
|
||||||
|
reasons.append("external_signing_trust_root_required")
|
||||||
|
if production and not anchor_configured:
|
||||||
|
reasons.append("external_immutable_anchor_required")
|
||||||
|
if emergency:
|
||||||
|
reasons.append("emergency_local_trust_override_active")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"schema_version": POLICY_VERSION,
|
||||||
|
"profile": profile or "development",
|
||||||
|
"production": production,
|
||||||
|
"signing_provider": signing_provider or "local_openssl",
|
||||||
|
"signing_capability": "external" if signing_configured else "local_or_unavailable",
|
||||||
|
"immutable_anchor_provider": anchor_provider or "local_hash_chain",
|
||||||
|
"immutable_anchor_capability": "external" if anchor_configured else "local_or_unavailable",
|
||||||
|
"ready": ready,
|
||||||
|
"certifiable": certifiable,
|
||||||
|
"emergency_override": emergency,
|
||||||
|
"severity": "critical" if emergency or not ready else "info",
|
||||||
|
"reason_codes": reasons or ["trust_capabilities_satisfied"],
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Runtime supervision capability negotiation without pretending support."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RuntimeCapabilities:
|
||||||
|
canCancel: bool = False
|
||||||
|
canPause: bool = False
|
||||||
|
canResume: bool = False
|
||||||
|
canRollback: bool = False
|
||||||
|
canReduceAuthority: bool = False
|
||||||
|
canQuarantine: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_FOR_INTERVENTION = {
|
||||||
|
"cancel": "canCancel",
|
||||||
|
"pause": "canPause",
|
||||||
|
"resume": "canResume",
|
||||||
|
"roll_back": "canRollback",
|
||||||
|
"reduce_authority": "canReduceAuthority",
|
||||||
|
"quarantine": "canQuarantine",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def negotiate_intervention(intervention_id: str, intervention: str, requested_by: str, capabilities: RuntimeCapabilities) -> dict[str, Any]:
|
||||||
|
capability = CAPABILITY_FOR_INTERVENTION.get(intervention)
|
||||||
|
supported = bool(capability and getattr(capabilities, capability))
|
||||||
|
return {
|
||||||
|
"intervention_id": intervention_id,
|
||||||
|
"type": intervention,
|
||||||
|
"requested_by": requested_by,
|
||||||
|
"status": "pending" if supported else "unsupported",
|
||||||
|
"reason_code": "runtime_intervention_supported_pending_adapter" if supported else "runtime_intervention_unsupported",
|
||||||
|
"required_capability": capability,
|
||||||
|
"runtime_capabilities": asdict(capabilities),
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Typed H1-H7 namespaces. Legacy `Hn` labels remain display aliases only."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class AssuranceCategory(str, Enum):
|
||||||
|
RUNTIME_CONTROL = "runtime_control"
|
||||||
|
READINESS_CHECK = "readiness_check"
|
||||||
|
REPORT_DIMENSION = "report_dimension"
|
||||||
|
CERTIFICATION_CLAIM = "certification_claim"
|
||||||
|
|
||||||
|
|
||||||
|
class _HNamespace(str, Enum):
|
||||||
|
@property
|
||||||
|
def legacy_id(self) -> str:
|
||||||
|
return self.value.rsplit(".", 1)[-1]
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeControl(_HNamespace):
|
||||||
|
H1 = "RuntimeControl.H1"; H2 = "RuntimeControl.H2"; H3 = "RuntimeControl.H3"
|
||||||
|
H4 = "RuntimeControl.H4"; H5 = "RuntimeControl.H5"; H6 = "RuntimeControl.H6"; H7 = "RuntimeControl.H7"
|
||||||
|
|
||||||
|
|
||||||
|
class ReadinessCheck(_HNamespace):
|
||||||
|
H1 = "ReadinessCheck.H1"; H2 = "ReadinessCheck.H2"; H3 = "ReadinessCheck.H3"
|
||||||
|
H4 = "ReadinessCheck.H4"; H5 = "ReadinessCheck.H5"; H6 = "ReadinessCheck.H6"; H7 = "ReadinessCheck.H7"
|
||||||
|
|
||||||
|
|
||||||
|
class ReportDimension(_HNamespace):
|
||||||
|
H1 = "ReportDimension.H1"; H2 = "ReportDimension.H2"; H3 = "ReportDimension.H3"
|
||||||
|
H4 = "ReportDimension.H4"; H5 = "ReportDimension.H5"; H6 = "ReportDimension.H6"; H7 = "ReportDimension.H7"
|
||||||
|
|
||||||
|
|
||||||
|
class CertificationClaim(_HNamespace):
|
||||||
|
H1 = "CertificationClaim.H1"; H2 = "CertificationClaim.H2"; H3 = "CertificationClaim.H3"
|
||||||
|
H4 = "CertificationClaim.H4"; H5 = "CertificationClaim.H5"; H6 = "CertificationClaim.H6"; H7 = "CertificationClaim.H7"
|
||||||
|
|
||||||
|
|
||||||
|
def same_legacy_label(left: _HNamespace, right: _HNamespace) -> bool:
|
||||||
|
"""Compatibility display helper; never authorizes cross-category interpretation."""
|
||||||
|
return left.legacy_id == right.legacy_id
|
||||||
@@ -58,6 +58,10 @@
|
|||||||
"type": ["string", "null"],
|
"type": ["string", "null"],
|
||||||
"description": "Client-native session id. Hashed by the bridge, never stored raw."
|
"description": "Client-native session id. Hashed by the bridge, never stored raw."
|
||||||
},
|
},
|
||||||
|
"actor": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"description": "Stable actor identity asserted by the authenticated runtime boundary."
|
||||||
|
},
|
||||||
"integration_mode": {
|
"integration_mode": {
|
||||||
"type": ["string", "null"],
|
"type": ["string", "null"],
|
||||||
"enum": ["casan_owned", "managed_hook", "project_hook", "observed_only", null],
|
"enum": ["casan_owned", "managed_hook", "project_hook", "observed_only", null],
|
||||||
@@ -167,7 +171,7 @@
|
|||||||
"schema_version": { "type": "string" },
|
"schema_version": { "type": "string" },
|
||||||
"decision": {
|
"decision": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["allow", "block", "deny", "recorded", "certified", "non_certified", "error"]
|
"enum": ["allow", "block", "deny", "require_approval", "halt", "quarantine", "recorded", "certified", "non_certified", "error"]
|
||||||
},
|
},
|
||||||
"admission_id": { "type": ["string", "null"] },
|
"admission_id": { "type": ["string", "null"] },
|
||||||
"trace_id": { "type": ["string", "null"] },
|
"trace_id": { "type": ["string", "null"] },
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://casan.dev/schemas/assurance-kernel/1.0.0",
|
||||||
|
"title": "CASAN Assurance Kernel Execution Envelope",
|
||||||
|
"description": "Framework-independent canonical wire contract. Runtime-specific fields belong under a namespaced extensions object.",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["schema_version", "extension_namespace", "run", "actors", "steps", "evidence", "trace_links", "extensions"],
|
||||||
|
"properties": {
|
||||||
|
"schema_version": {"const": "1.0.0"},
|
||||||
|
"extension_namespace": {"type": "string", "pattern": "^[a-z][a-z0-9.-]+$"},
|
||||||
|
"run": {"$ref": "#/$defs/ExecutionRun"},
|
||||||
|
"actors": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/Actor"}},
|
||||||
|
"steps": {"type": "array", "items": {"$ref": "#/$defs/ExecutionStep"}},
|
||||||
|
"delegations": {"type": "array", "items": {"$ref": "#/$defs/Delegation"}},
|
||||||
|
"context_items": {"type": "array", "items": {"$ref": "#/$defs/ContextItem"}},
|
||||||
|
"runtime_constraints": {"type": "array", "items": {"$ref": "#/$defs/RuntimeConstraint"}},
|
||||||
|
"runtime_capabilities": {"$ref": "#/$defs/RuntimeCapabilities"},
|
||||||
|
"approvals": {"type": "array", "items": {"$ref": "#/$defs/Approval"}},
|
||||||
|
"interventions": {"type": "array", "items": {"$ref": "#/$defs/Intervention"}},
|
||||||
|
"claims": {"type": "array", "items": {"$ref": "#/$defs/Claim"}},
|
||||||
|
"evidence": {"type": "array", "items": {"$ref": "#/$defs/EvidenceItem"}},
|
||||||
|
"trace_links": {"type": "array", "items": {"$ref": "#/$defs/TraceLink"}},
|
||||||
|
"extensions": {"type": "object", "additionalProperties": {"type": "object"}}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"$defs": {
|
||||||
|
"Timestamp": {"type": "string", "format": "date-time"},
|
||||||
|
"Outcome": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["execution_result", "assurance_result", "certification_result", "business_result"],
|
||||||
|
"properties": {
|
||||||
|
"execution_result": {"enum": ["pending", "success", "failed", "cancelled", "quarantined", "unknown"]},
|
||||||
|
"assurance_result": {"enum": ["pending", "passed", "failed", "degraded", "not_evaluated", "unknown"]},
|
||||||
|
"certification_result": {"enum": ["pending", "certified", "non_certified", "ineligible"]},
|
||||||
|
"business_result": {"enum": ["achieved", "not_achieved", "partial", "not_evaluated", "unknown"]}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"ExecutionRun": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["run_id", "tenant", "project", "environment", "runtime", "mode", "requested_goal", "started_at", "status", "risk_summary", "correlation_id", "outcome"],
|
||||||
|
"properties": {
|
||||||
|
"run_id": {"type": "string", "minLength": 1},
|
||||||
|
"parent_run_id": {"type": ["string", "null"]},
|
||||||
|
"tenant": {"type": "string"},
|
||||||
|
"project": {"type": "string"},
|
||||||
|
"environment": {"type": "string"},
|
||||||
|
"runtime": {"type": "object", "required": ["type", "version"], "properties": {"type": {"type": "string"}, "version": {"type": "string"}}, "additionalProperties": false},
|
||||||
|
"mode": {"enum": ["observe", "enforce"]},
|
||||||
|
"requested_goal": {"type": "string"},
|
||||||
|
"started_at": {"$ref": "#/$defs/Timestamp"},
|
||||||
|
"completed_at": {"anyOf": [{"$ref": "#/$defs/Timestamp"}, {"type": "null"}]},
|
||||||
|
"status": {"enum": ["pending", "running", "success", "failed", "cancelled", "quarantined", "unknown"]},
|
||||||
|
"risk_summary": {"type": "object"},
|
||||||
|
"evidence_manifest_ref": {"type": ["string", "null"]},
|
||||||
|
"correlation_id": {"type": "string", "minLength": 1},
|
||||||
|
"outcome": {"$ref": "#/$defs/Outcome"}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"ExecutionStep": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["step_id", "run_id", "sequence", "actor_ref", "action", "input_context_refs", "policy_decisions", "verification_results", "evidence_refs", "outcome", "started_at", "completed_at"],
|
||||||
|
"properties": {
|
||||||
|
"step_id": {"type": "string"}, "run_id": {"type": "string"}, "parent_step_id": {"type": ["string", "null"]}, "sequence": {"type": "integer", "minimum": 1},
|
||||||
|
"actor_ref": {"type": "string"}, "action": {"$ref": "#/$defs/Action"}, "resource": {"type": ["object", "string", "null"]},
|
||||||
|
"input_context_refs": {"type": "array", "items": {"type": "string"}}, "policy_decisions": {"type": "array", "items": {"$ref": "#/$defs/PolicyDecision"}},
|
||||||
|
"tool_invocation": {"type": ["object", "null"]}, "verification_results": {"type": "array", "items": {"$ref": "#/$defs/VerificationResult"}},
|
||||||
|
"evidence_refs": {"type": "array", "items": {"type": "string"}}, "outcome": {"$ref": "#/$defs/Outcome"},
|
||||||
|
"started_at": {"$ref": "#/$defs/Timestamp"}, "completed_at": {"anyOf": [{"$ref": "#/$defs/Timestamp"}, {"type": "null"}]}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"Actor": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["actor_id", "actor_type", "issuer", "trust_level"],
|
||||||
|
"properties": {
|
||||||
|
"actor_id": {"type": "string"}, "actor_type": {"enum": ["human", "agent", "child_agent", "model", "tool", "service_account", "runtime", "approver", "policy_engine"]},
|
||||||
|
"issuer": {"type": "string"}, "trust_level": {"enum": ["unverified", "asserted", "verified", "hardware_backed"]}, "authentication_evidence_ref": {"type": ["string", "null"]}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"Delegation": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["delegation_id", "delegator_ref", "delegate_ref", "allowed_authority", "resource_scope", "expiry", "maximum_depth", "revoked"],
|
||||||
|
"properties": {
|
||||||
|
"delegation_id": {"type": "string"}, "delegator_ref": {"type": "string"}, "delegate_ref": {"type": "string"}, "allowed_authority": {"type": "array", "items": {"type": "string"}},
|
||||||
|
"resource_scope": {"type": "array", "items": {"type": "string"}}, "expiry": {"$ref": "#/$defs/Timestamp"}, "maximum_depth": {"type": "integer", "minimum": 0},
|
||||||
|
"parent_delegation_ref": {"type": ["string", "null"]}, "revoked": {"type": "boolean"}, "approval_ref": {"type": ["string", "null"]}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"ContextItem": {
|
||||||
|
"type": "object", "required": ["context_id", "source", "trust_classification", "content_hash", "transform_history", "compression_lineage", "classification", "injection_scan_result"],
|
||||||
|
"properties": {
|
||||||
|
"context_id": {"type": "string"}, "source": {"type": "string"}, "trust_classification": {"enum": ["trusted", "untrusted", "mixed", "unknown"]}, "content_hash": {"type": "string"},
|
||||||
|
"transform_history": {"type": "array", "items": {"type": "object"}}, "compression_lineage": {"type": "array", "items": {"type": "string"}},
|
||||||
|
"origin_ref": {"type": ["string", "null"]}, "classification": {"enum": ["instruction", "data", "mixed", "unknown"]}, "injection_scan_result": {"type": "object"}
|
||||||
|
}, "additionalProperties": false
|
||||||
|
},
|
||||||
|
"Action": {
|
||||||
|
"type": "object", "required": ["class", "name", "side_effect_level"],
|
||||||
|
"properties": {"class": {"enum": ["read_only", "write", "delete", "database_mutation", "migration", "deployment", "release", "credential_access", "identity_permission_modification", "external_network_side_effect", "infrastructure_modification", "unknown"]}, "name": {"type": "string"}, "tool": {"type": ["string", "null"]}, "side_effect_level": {"enum": ["none", "sensitive_read", "write", "destructive", "external", "unknown"]}, "required_authority": {"type": ["string", "null"]}, "requested_operation": {"type": ["string", "null"]}, "environment": {"type": ["string", "null"]}},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"ResourceAccess": {
|
||||||
|
"type": "object", "required": ["resource", "operation", "side_effect_level", "environment"],
|
||||||
|
"properties": {"resource": {"type": "string"}, "operation": {"type": "string"}, "side_effect_level": {"type": "string"}, "required_authority": {"type": ["string", "null"]}, "environment": {"type": "string"}}, "additionalProperties": false
|
||||||
|
},
|
||||||
|
"PolicyDecision": {
|
||||||
|
"type": "object", "required": ["policy_id", "policy_version", "decision", "reason_codes", "effective_risk", "input_facts", "enforcement_point", "timestamp", "decision_engine_identity"],
|
||||||
|
"properties": {"policy_id": {"type": "string"}, "policy_version": {"type": "string"}, "decision": {"enum": ["allow", "deny", "quarantine", "require_approval", "transform", "observe_only"]}, "reason_codes": {"type": "array", "items": {"type": "string"}}, "effective_risk": {"enum": ["low", "medium", "high", "critical"]}, "input_facts": {"type": "object"}, "enforcement_point": {"type": "string"}, "timestamp": {"$ref": "#/$defs/Timestamp"}, "decision_engine_identity": {"type": "string"}, "evidence_ref": {"type": ["string", "null"]}}, "additionalProperties": false
|
||||||
|
},
|
||||||
|
"RuntimeConstraint": {
|
||||||
|
"type": "object", "required": ["constraint_id", "kind", "limit", "enforcement"],
|
||||||
|
"properties": {"constraint_id": {"type": "string"}, "kind": {"enum": ["iteration", "time", "cost", "token", "child_agent_count", "graph_depth", "retry", "repetition", "network", "filesystem", "tool", "environment"]}, "limit": {}, "enforcement": {"enum": ["hard", "soft", "observe"]}}, "additionalProperties": false
|
||||||
|
},
|
||||||
|
"RuntimeCapabilities": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["canCancel", "canPause", "canResume", "canRollback", "canReduceAuthority", "canQuarantine"],
|
||||||
|
"properties": {"canCancel": {"type": "boolean"}, "canPause": {"type": "boolean"}, "canResume": {"type": "boolean"}, "canRollback": {"type": "boolean"}, "canReduceAuthority": {"type": "boolean"}, "canQuarantine": {"type": "boolean"}},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"VerificationRequirement": {
|
||||||
|
"type": "object", "required": ["requirement_id", "validator", "expected_result", "independence_level", "failure_severity"],
|
||||||
|
"properties": {"requirement_id": {"type": "string"}, "validator": {"type": "string"}, "expected_result": {}, "independence_level": {"enum": ["same_runtime", "independent_process", "independent_service", "external_authority"]}, "failure_severity": {"enum": ["info", "warning", "error", "critical"]}}, "additionalProperties": false
|
||||||
|
},
|
||||||
|
"VerificationResult": {
|
||||||
|
"type": "object", "required": ["requirement_id", "validator", "expected_result", "actual_result", "status", "evidence_refs", "independence_level", "failure_severity"],
|
||||||
|
"properties": {"requirement_id": {"type": "string"}, "validator": {"type": "string"}, "expected_result": {}, "actual_result": {}, "status": {"enum": ["passed", "failed", "degraded", "not_run"]}, "evidence_refs": {"type": "array", "items": {"type": "string"}}, "independence_level": {"type": "string"}, "failure_severity": {"type": "string"}}, "additionalProperties": false
|
||||||
|
},
|
||||||
|
"Claim": {
|
||||||
|
"type": "object", "required": ["claim_id", "statement", "evidence_refs", "validation_status"],
|
||||||
|
"properties": {"claim_id": {"type": "string"}, "statement": {"type": "string"}, "evidence_refs": {"type": "array", "items": {"type": "string"}}, "validation_status": {"enum": ["validated", "rejected", "insufficient", "not_validated"]}}, "additionalProperties": false
|
||||||
|
},
|
||||||
|
"EvidenceItem": {
|
||||||
|
"type": "object", "required": ["evidence_id", "claim_refs", "integrity", "producer_identity", "timestamp", "artifact_ref", "validation_status", "retention_class"],
|
||||||
|
"properties": {"evidence_id": {"type": "string"}, "claim_refs": {"type": "array", "items": {"type": "string"}}, "integrity": {"type": "object", "required": ["algorithm", "digest"], "properties": {"algorithm": {"const": "sha256"}, "digest": {"type": "string", "pattern": "^[a-f0-9]{64}$"}, "source_content_hash": {"type": ["string", "null"]}}, "additionalProperties": false}, "producer_identity": {"type": "string"}, "timestamp": {"$ref": "#/$defs/Timestamp"}, "artifact_ref": {"type": "string"}, "validation_status": {"enum": ["valid", "invalid", "unverified", "unavailable"]}, "retention_class": {"type": "string"}, "category": {"enum": ["runtime_control", "readiness_check", "report_dimension", "certification_claim"]}}, "additionalProperties": false
|
||||||
|
},
|
||||||
|
"TraceLink": {
|
||||||
|
"type": "object", "required": ["type", "from", "to"],
|
||||||
|
"properties": {"type": {"enum": ["requirement_to_policy", "policy_to_decision", "decision_to_action", "action_to_artifact", "artifact_to_verification", "verification_to_evidence", "evidence_to_outcome", "parent_to_child_execution"]}, "from": {"type": "string"}, "to": {"type": "string"}}, "additionalProperties": false
|
||||||
|
},
|
||||||
|
"Approval": {
|
||||||
|
"type": "object", "required": ["approval_id", "decision", "approver_ref", "timestamp"],
|
||||||
|
"properties": {"approval_id": {"type": "string"}, "decision": {"enum": ["approve", "reject"]}, "approver_ref": {"type": "string"}, "reason": {"type": ["string", "null"]}, "timestamp": {"$ref": "#/$defs/Timestamp"}}, "additionalProperties": false
|
||||||
|
},
|
||||||
|
"Intervention": {
|
||||||
|
"type": "object", "required": ["intervention_id", "type", "requested_by", "status", "timestamp"],
|
||||||
|
"properties": {"intervention_id": {"type": "string"}, "type": {"enum": ["pause", "resume", "cancel", "quarantine", "reduce_authority", "redirect", "roll_back"]}, "requested_by": {"type": "string"}, "status": {"enum": ["applied", "rejected", "unsupported", "pending"]}, "timestamp": {"$ref": "#/$defs/Timestamp"}}, "additionalProperties": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -110,6 +110,10 @@ LATENCY_MS=$((END_MS - START_MS))
|
|||||||
if [[ ! -f "$OUTPUT_FILE" ]]; then
|
if [[ ! -f "$OUTPUT_FILE" ]]; then
|
||||||
STATUS="failed"
|
STATUS="failed"
|
||||||
ERROR_MSG="${ERROR_MSG:-output file not produced}"
|
ERROR_MSG="${ERROR_MSG:-output file not produced}"
|
||||||
|
# A zero command exit does not make the step successful when the runtime
|
||||||
|
# contract requires an output artifact and none was produced. Telemetry can
|
||||||
|
# record this failure successfully, but must propagate a failed step outcome.
|
||||||
|
[[ "$EXIT_CODE" -eq 0 ]] && EXIT_CODE=1
|
||||||
: > "$OUTPUT_FILE"
|
: > "$OUTPUT_FILE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,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"
|
||||||
|
KERNEL_CLI="$CASAN_HARNESS_ROOT/scripts/python/kernel_cli.py"
|
||||||
# SEC-23 (MT-01): make state (control-plane settings, telemetry, audit) tenant-scoped
|
# SEC-23 (MT-01): make state (control-plane settings, telemetry, audit) tenant-scoped
|
||||||
# when CASAN_TENANT_ID is set, so a run for tenant A never touches tenant B's state.
|
# when CASAN_TENANT_ID is set, so a run for tenant A never touches tenant B's state.
|
||||||
# No-op when no tenant is set (baseline unchanged); invalid tenant fails closed.
|
# No-op when no tenant is set (baseline unchanged); invalid tenant fails closed.
|
||||||
@@ -56,12 +57,52 @@ write_phase_report() {
|
|||||||
"$ACTION_NAME" "$CACHE_STATUS" "$PHASE_LOG" > "$PHASE_REPORT" 2>/dev/null || true
|
"$ACTION_NAME" "$CACHE_STATUS" "$PHASE_LOG" > "$PHASE_REPORT" 2>/dev/null || true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
emit_failed_kernel() { # phase rc — best effort, never masks the original failure
|
||||||
|
local failed_phase="$1" failed_rc="$2"
|
||||||
|
[[ -f "$KERNEL_CLI" ]] || return 0
|
||||||
|
local run_id="${EXECUTION_ID:-native-failed-${TRACE_SUFFIX:-$$}}"
|
||||||
|
local mode="${NATIVE_MODE:-observe}"
|
||||||
|
local event bundle path
|
||||||
|
event="$(CASAN_NATIVE_RISK="${ACTION_RISK_JSON:-}" python3 - "$run_id" "$ACTION_NAME" "$mode" "$failed_phase" "$failed_rc" "${CASAN_ACTOR:-${CASAN_AGENT:-}}" <<'PY'
|
||||||
|
import json, os, sys
|
||||||
|
run_id, action, mode, phase, rc, actor = sys.argv[1:]
|
||||||
|
try: risk = json.loads(os.environ.get("CASAN_NATIVE_RISK") or "{}")
|
||||||
|
except ValueError: risk = {}
|
||||||
|
print(json.dumps({
|
||||||
|
"run_id": run_id, "correlation_id": run_id, "action": action,
|
||||||
|
"tool": action, "actor": actor, "mode": mode,
|
||||||
|
"environment": os.environ.get("CASAN_PROFILE", "development"),
|
||||||
|
"execution_status": "failed", "assurance_status": "failed",
|
||||||
|
"certification_status": "non_certified", "policy_decisions": [risk] if risk else [],
|
||||||
|
"extensions": {"failed_phase": phase, "exit_code": int(rc)},
|
||||||
|
}, separators=(",", ":")))
|
||||||
|
PY
|
||||||
|
)" || return 0
|
||||||
|
bundle="$(printf '%s' "$event" | python3 "$KERNEL_CLI" adapt-native - 2>/dev/null)" || return 0
|
||||||
|
path="$CASAN_STATE_ROOT/logs/kernel/$run_id.json"
|
||||||
|
CASAN_KERNEL_BUNDLE="$bundle" python3 - "$path" <<'PY' || return 0
|
||||||
|
import json, os, sys, tempfile
|
||||||
|
path = sys.argv[1]; payload = json.loads(os.environ["CASAN_KERNEL_BUNDLE"])
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
fd, tmp = tempfile.mkstemp(prefix=".kernel-", dir=os.path.dirname(path))
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||||
|
json.dump(payload, handle, sort_keys=True, separators=(",", ":")); handle.write("\n")
|
||||||
|
handle.flush(); os.fsync(handle.fileno())
|
||||||
|
os.replace(tmp, path)
|
||||||
|
finally:
|
||||||
|
try: os.unlink(tmp)
|
||||||
|
except OSError: pass
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
run_phase() { # <phase-name> <command...> — preserves the failing rc exactly
|
run_phase() { # <phase-name> <command...> — preserves the failing rc exactly
|
||||||
local phase="$1"; shift
|
local phase="$1"; shift
|
||||||
local rc=0
|
local rc=0
|
||||||
"$@" || rc=$?
|
"$@" || rc=$?
|
||||||
record_phase "$phase" "$rc"
|
record_phase "$phase" "$rc"
|
||||||
if [[ "$rc" -ne 0 ]]; then
|
if [[ "$rc" -ne 0 ]]; then
|
||||||
|
emit_failed_kernel "$phase" "$rc"
|
||||||
write_phase_report
|
write_phase_report
|
||||||
exit "$rc"
|
exit "$rc"
|
||||||
fi
|
fi
|
||||||
@@ -76,9 +117,16 @@ hash_text() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
CMD_STR="${*:-no_cmd}"
|
CMD_STR="${*:-no_cmd}"
|
||||||
|
NATIVE_MODE="${CASAN_ENFORCEMENT_MODE:-}"
|
||||||
|
if [[ -z "$NATIVE_MODE" ]]; then
|
||||||
|
if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" ]]; then NATIVE_MODE="enforce"; else NATIVE_MODE="observe"; fi
|
||||||
|
fi
|
||||||
|
case "$NATIVE_MODE" in observe|enforce) : ;; *) NATIVE_MODE="enforce"; casan_log error harness "INVALID_ENFORCEMENT_MODE fail_closed=enforce" ;; esac
|
||||||
INPUT_HASH="$(cat "$INPUT_FILE" | hash_text)"
|
INPUT_HASH="$(cat "$INPUT_FILE" | hash_text)"
|
||||||
CMD_HASH="$(printf '%s' "$CMD_STR" | hash_text)"
|
CMD_HASH="$(printf '%s' "$CMD_STR" | hash_text)"
|
||||||
IDEMPOTENCY_KEY="$(printf '%s|%s|%s' "$INPUT_HASH" "$CMD_HASH" "$ACTION_NAME" | hash_text)"
|
IDEMPOTENCY_KEY="$(printf '%s|%s|%s' "$INPUT_HASH" "$CMD_HASH" "$ACTION_NAME" | hash_text)"
|
||||||
|
EXECUTION_ID="${CASAN_EXECUTION_ID:-native-${IDEMPOTENCY_KEY:0:24}}"
|
||||||
|
export CASAN_EXECUTION_ID="$EXECUTION_ID"
|
||||||
CACHE_META="$CACHE_DIR/$IDEMPOTENCY_KEY.json"
|
CACHE_META="$CACHE_DIR/$IDEMPOTENCY_KEY.json"
|
||||||
CACHE_OUT="$CACHE_DIR/$IDEMPOTENCY_KEY.output"
|
CACHE_OUT="$CACHE_DIR/$IDEMPOTENCY_KEY.output"
|
||||||
|
|
||||||
@@ -92,7 +140,7 @@ casan_log debug harness "action=$ACTION_NAME input=$INPUT_FILE output=$FINAL_OUT
|
|||||||
# C7: honor an engaged kill-switch before doing any work (incident containment).
|
# C7: honor an engaged kill-switch before doing any work (incident containment).
|
||||||
# Opt-in (default off) so the baseline is unchanged. SEC-17 (ARCH-03): under
|
# Opt-in (default off) so the baseline is unchanged. SEC-17 (ARCH-03): under
|
||||||
# CASAN_PROFILE=prod it defaults ON (secure-by-default); an explicit =0 still wins.
|
# CASAN_PROFILE=prod it defaults ON (secure-by-default); an explicit =0 still wins.
|
||||||
if [[ "${CASAN_KILLSWITCH_ENFORCE:-0}" == "1" || ( -z "${CASAN_KILLSWITCH_ENFORCE+x}" && "${CASAN_PROFILE:-}" == "prod" ) ]]; then
|
if [[ "${CASAN_KILLSWITCH_ENFORCE:-0}" == "1" || ( -z "${CASAN_KILLSWITCH_ENFORCE+x}" && ( "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" ) ) ]]; then
|
||||||
KS_SCOPE="${CASAN_KILLSWITCH_SCOPE:-project}"
|
KS_SCOPE="${CASAN_KILLSWITCH_SCOPE:-project}"
|
||||||
KS_ID="${CASAN_KILLSWITCH_ID:-${CASAN_PROJECT:-current}}"
|
KS_ID="${CASAN_KILLSWITCH_ID:-${CASAN_PROJECT:-current}}"
|
||||||
if ! bash "$SCRIPT_DIR/kill-switch.sh" check "$KS_SCOPE" "$KS_ID" >/dev/null 2>&1; then
|
if ! bash "$SCRIPT_DIR/kill-switch.sh" check "$KS_SCOPE" "$KS_ID" >/dev/null 2>&1; then
|
||||||
@@ -116,7 +164,7 @@ fi
|
|||||||
# signed manifest and REFUSE to run on any drift — editing a gate/policy is a bypass
|
# signed manifest and REFUSE to run on any drift — editing a gate/policy is a bypass
|
||||||
# that leaves no input trace. Only active when a manifest is provisioned (so dev and
|
# that leaves no input trace. Only active when a manifest is provisioned (so dev and
|
||||||
# prod-without-a-manifest are unaffected); a present-but-drifted bundle fails closed.
|
# prod-without-a-manifest are unaffected); a present-but-drifted bundle fails closed.
|
||||||
if [[ ( "${CASAN_PROFILE:-}" == "prod" || "${CASAN_VERIFY_STRICT:-}" == "1" ) \
|
if [[ ( "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" || "${CASAN_VERIFY_STRICT:-}" == "1" ) \
|
||||||
&& -f "$SCRIPT_DIR/bundle-integrity.py" ]]; then
|
&& -f "$SCRIPT_DIR/bundle-integrity.py" ]]; then
|
||||||
BUNDLE_MANIFEST="${CASAN_BUNDLE_MANIFEST:-$CASAN_GOVERNANCE_ROOT/harness-bundle-manifest.json}"
|
BUNDLE_MANIFEST="${CASAN_BUNDLE_MANIFEST:-$CASAN_GOVERNANCE_ROOT/harness-bundle-manifest.json}"
|
||||||
if [[ -f "$BUNDLE_MANIFEST" ]]; then
|
if [[ -f "$BUNDLE_MANIFEST" ]]; then
|
||||||
@@ -132,15 +180,62 @@ fi
|
|||||||
run_phase "H4-in" "$SCRIPT_DIR/security-check.sh" "$INPUT_FILE" "$SAFE_INPUT" input
|
run_phase "H4-in" "$SCRIPT_DIR/security-check.sh" "$INPUT_FILE" "$SAFE_INPUT" input
|
||||||
run_phase "H5" "$SCRIPT_DIR/governance-check.sh" "$SAFE_INPUT" "$APPROVED_INPUT" "$ACTION_NAME"
|
run_phase "H5" "$SCRIPT_DIR/governance-check.sh" "$SAFE_INPUT" "$APPROVED_INPUT" "$ACTION_NAME"
|
||||||
|
|
||||||
# H2 tool registry gate is in the line of fire for side-effecting actions:
|
# Canonical action classification decides whether the H2 registry and isolated
|
||||||
# it enforces idempotency key, per-agent permission, and rollback strategy
|
# executor are required. A classifier failure is treated as unknown/high-risk.
|
||||||
# before the command is allowed to execute. The wrapper already derived a
|
ACTION_RISK_JSON=""
|
||||||
# content-addressed idempotency key above.
|
ACTION_RISK_RC=0
|
||||||
case "$ACTION_NAME" in
|
ACTION_RISK_JSON="$(python3 "$KERNEL_CLI" risk --action "$ACTION_NAME" --tool "$ACTION_NAME" \
|
||||||
write_code|migration|deploy|db_write|external_api|write_file)
|
--command "$CMD_STR" --content-file "$SAFE_INPUT" --actor "${CASAN_ACTOR:-${CASAN_AGENT:-}}" \
|
||||||
run_phase "H2-gate" env CASAN_IDEMPOTENCY_KEY="$IDEMPOTENCY_KEY" "$SCRIPT_DIR/tool-registry-gate.sh" "$ACTION_NAME"
|
--environment "${CASAN_PROFILE:-development}" 2>/dev/null)" || ACTION_RISK_RC=$?
|
||||||
;;
|
if [[ "$ACTION_RISK_RC" -eq 0 && -n "$ACTION_RISK_JSON" ]]; then
|
||||||
esac
|
ACTION_FIELDS="$(python3 - "$ACTION_RISK_JSON" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
p=json.loads(sys.argv[1])
|
||||||
|
print("%s\t%s" % ("1" if p["side_effecting"] else "0", p["action_class"]))
|
||||||
|
PY
|
||||||
|
)" || ACTION_RISK_RC=$?
|
||||||
|
fi
|
||||||
|
if [[ "$ACTION_RISK_RC" -eq 0 && -n "${ACTION_FIELDS:-}" ]]; then
|
||||||
|
IFS=$'\t' read -r SIDE_EFFECTING ACTION_CLASS <<< "$ACTION_FIELDS"
|
||||||
|
else
|
||||||
|
SIDE_EFFECTING=1
|
||||||
|
ACTION_CLASS="unknown"
|
||||||
|
casan_log error harness "ACTION_CLASSIFIER_FAILED_CLOSED action=$ACTION_NAME"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$SIDE_EFFECTING" == "1" ]]; then
|
||||||
|
REGISTRY_ARGS=(registry-config --mode "$NATIVE_MODE" --profile "${CASAN_PROFILE:-development}" \
|
||||||
|
--evidence-log "$CASAN_STATE_ROOT/logs/policy/h2-registry-config.jsonl")
|
||||||
|
[[ -n "${CASAN_H2_REGISTRY+x}" ]] && REGISTRY_ARGS+=(--explicit "$CASAN_H2_REGISTRY")
|
||||||
|
REGISTRY_RC=0
|
||||||
|
REGISTRY_JSON="$(python3 "$KERNEL_CLI" "${REGISTRY_ARGS[@]}")" || REGISTRY_RC=$?
|
||||||
|
if [[ "$REGISTRY_RC" -ne 0 ]]; then
|
||||||
|
record_phase "H2-config" "$REGISTRY_RC"
|
||||||
|
echo "H2_REGISTRY_CONFIGURATION_DENIED $REGISTRY_JSON" >&2
|
||||||
|
write_phase_report
|
||||||
|
exit "$REGISTRY_RC"
|
||||||
|
fi
|
||||||
|
REGISTRY_ENABLED="$(python3 -c 'import json,sys; print("1" if json.load(sys.stdin)["enabled"] else "0")' <<< "$REGISTRY_JSON")"
|
||||||
|
if [[ "$REGISTRY_ENABLED" == "1" ]]; then
|
||||||
|
H2_ACTION="$ACTION_NAME"
|
||||||
|
case "$ACTION_CLASS" in
|
||||||
|
write) H2_ACTION="write_file" ;;
|
||||||
|
delete) H2_ACTION="delete_file" ;;
|
||||||
|
database_mutation) H2_ACTION="db_write" ;;
|
||||||
|
deployment|release|infrastructure_modification) H2_ACTION="deploy" ;;
|
||||||
|
external_network_side_effect) H2_ACTION="external_api" ;;
|
||||||
|
unknown) H2_ACTION="unknown_tool" ;;
|
||||||
|
esac
|
||||||
|
run_phase "H2-gate" python3 "$KERNEL_CLI" h2-gate \
|
||||||
|
--gate "${CASAN_H2_GATE_PATH:-$SCRIPT_DIR/tool-registry-gate.sh}" --mode "$NATIVE_MODE" \
|
||||||
|
--actor "${CASAN_ACTOR:-${CASAN_AGENT:-}}" --action "$H2_ACTION" --tool "$ACTION_NAME" \
|
||||||
|
--execution-id "$EXECUTION_ID" --enforcement-path "native_harness.pre_execution.h2_registry" \
|
||||||
|
--idempotency-key "$IDEMPOTENCY_KEY" --timeout "${CASAN_H2_GATE_TIMEOUT_SECONDS:-8}" \
|
||||||
|
--evidence-log "$CASAN_STATE_ROOT/logs/policy/h2-decisions.jsonl"
|
||||||
|
else
|
||||||
|
casan_log warn harness "HIGH H2 registry unsafe development/test bypass active; run is non-certifiable"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# T4: propagate step name so any nested model calls (model-call.py) log against the same step
|
# T4: propagate step name so any nested model calls (model-call.py) log against the same step
|
||||||
# name, enabling provider-cost-lookup.py to match real Ollama token counts in agent-metrics.sh.
|
# name, enabling provider-cost-lookup.py to match real Ollama token counts in agent-metrics.sh.
|
||||||
@@ -155,13 +250,34 @@ if [[ -f "$CACHE_META" && -f "$CACHE_OUT" ]]; then
|
|||||||
run_phase "H6-exec" "$SCRIPT_DIR/agent-metrics.sh" "$APPROVED_INPUT" "$RAW_OUTPUT" -- bash -c 'cp "$1" "$CASAN_OUTPUT"' _ "$CACHE_OUT"
|
run_phase "H6-exec" "$SCRIPT_DIR/agent-metrics.sh" "$APPROVED_INPUT" "$RAW_OUTPUT" -- bash -c 'cp "$1" "$CASAN_OUTPUT"' _ "$CACHE_OUT"
|
||||||
elif [[ "$#" -gt 0 ]]; then
|
elif [[ "$#" -gt 0 ]]; then
|
||||||
CACHE_STATUS="stored"
|
CACHE_STATUS="stored"
|
||||||
run_phase "H6-exec" "$SCRIPT_DIR/agent-metrics.sh" "$APPROVED_INPUT" "$RAW_OUTPUT" -- \
|
if [[ "$SIDE_EFFECTING" == "1" && "$NATIVE_MODE" == "enforce" ]]; then
|
||||||
"$SCRIPT_DIR/tool-exec.sh" "$TOOL_TIMEOUT" -- "$@"
|
run_phase "H6-exec" env CASAN_ENFORCEMENT_MODE="$NATIVE_MODE" "$SCRIPT_DIR/agent-metrics.sh" "$APPROVED_INPUT" "$RAW_OUTPUT" -- \
|
||||||
|
"$SCRIPT_DIR/sandbox-run.sh" --workspace "$PROJECT_ROOT" --timeout "$TOOL_TIMEOUT" -- "$@"
|
||||||
|
else
|
||||||
|
run_phase "H6-exec" "$SCRIPT_DIR/agent-metrics.sh" "$APPROVED_INPUT" "$RAW_OUTPUT" -- \
|
||||||
|
"$SCRIPT_DIR/tool-exec.sh" "$TOOL_TIMEOUT" -- "$@"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
CACHE_STATUS="stored"
|
CACHE_STATUS="stored"
|
||||||
run_phase "H6-exec" "$SCRIPT_DIR/agent-metrics.sh" "$APPROVED_INPUT" "$RAW_OUTPUT"
|
run_phase "H6-exec" "$SCRIPT_DIR/agent-metrics.sh" "$APPROVED_INPUT" "$RAW_OUTPUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
TOOL_OUTPUT_MAX_BYTES="${CASAN_TOOL_OUTPUT_MAX_BYTES:-1048576}"
|
||||||
|
if [[ ! "$TOOL_OUTPUT_MAX_BYTES" =~ ^[1-9][0-9]*$ ]]; then
|
||||||
|
casan_log error harness "TOOL_OUTPUT_LIMIT_INVALID value=$TOOL_OUTPUT_MAX_BYTES"
|
||||||
|
: > "$FINAL_OUTPUT"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
RAW_OUTPUT_BYTES="$(wc -c < "$RAW_OUTPUT" | tr -d ' ')"
|
||||||
|
if [[ "$RAW_OUTPUT_BYTES" -gt "$TOOL_OUTPUT_MAX_BYTES" ]]; then
|
||||||
|
record_phase "H4-output-size" 2
|
||||||
|
casan_log error harness "TOOL_OUTPUT_QUARANTINED bytes=$RAW_OUTPUT_BYTES limit=$TOOL_OUTPUT_MAX_BYTES"
|
||||||
|
: > "$FINAL_OUTPUT"
|
||||||
|
write_phase_report
|
||||||
|
echo "TOOL_OUTPUT_QUARANTINED reason=output_size_limit bytes=$RAW_OUTPUT_BYTES limit=$TOOL_OUTPUT_MAX_BYTES" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
# V7: tool output can carry indirect injection that would re-enter a downstream
|
# V7: tool output can carry indirect injection that would re-enter a downstream
|
||||||
# model's context. Scan RAW_OUTPUT for injection/secret patterns before it is
|
# model's context. Scan RAW_OUTPUT for injection/secret patterns before it is
|
||||||
# reused. Mode: off | warn (default) | block. Strict mode upgrades to block.
|
# reused. Mode: off | warn (default) | block. Strict mode upgrades to block.
|
||||||
@@ -170,7 +286,7 @@ fi
|
|||||||
TOOL_OUTPUT_SCAN_MODE="${CASAN_TOOL_OUTPUT_SCAN:-}"
|
TOOL_OUTPUT_SCAN_MODE="${CASAN_TOOL_OUTPUT_SCAN:-}"
|
||||||
if [[ -z "$TOOL_OUTPUT_SCAN_MODE" ]]; then
|
if [[ -z "$TOOL_OUTPUT_SCAN_MODE" ]]; then
|
||||||
# SEC-17/M-02: prod profile defaults tool-output scanning to block (fail-closed).
|
# SEC-17/M-02: prod profile defaults tool-output scanning to block (fail-closed).
|
||||||
if [[ "${CASAN_SECURITY_STRICT:-0}" == "1" || ( -z "${CASAN_SECURITY_STRICT+x}" && "${CASAN_PROFILE:-}" == "prod" ) ]]; then TOOL_OUTPUT_SCAN_MODE="block"; else TOOL_OUTPUT_SCAN_MODE="warn"; fi
|
if [[ "${CASAN_SECURITY_STRICT:-0}" == "1" || ( -z "${CASAN_SECURITY_STRICT+x}" && ( "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" ) ) ]]; then TOOL_OUTPUT_SCAN_MODE="block"; else TOOL_OUTPUT_SCAN_MODE="warn"; fi
|
||||||
fi
|
fi
|
||||||
if [[ "$TOOL_OUTPUT_SCAN_MODE" != "off" ]]; then
|
if [[ "$TOOL_OUTPUT_SCAN_MODE" != "off" ]]; then
|
||||||
TOS_RC=0
|
TOS_RC=0
|
||||||
@@ -204,6 +320,65 @@ EOF
|
|||||||
cp "$FINAL_OUTPUT" "$CACHE_OUT"
|
cp "$FINAL_OUTPUT" "$CACHE_OUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Dual-emit the framework-independent kernel envelope. Legacy phase reports and
|
||||||
|
# outputs remain unchanged; the canonical contract is an additive artifact.
|
||||||
|
TRUST_JSON="$(python3 "$KERNEL_CLI" trust-capabilities 2>/dev/null || true)"
|
||||||
|
RUN_CERTIFICATION="non_certified"
|
||||||
|
RUN_ASSURANCE="passed"
|
||||||
|
[[ "$NATIVE_MODE" == "observe" ]] && RUN_ASSURANCE="degraded"
|
||||||
|
if [[ "$NATIVE_MODE" == "enforce" && -n "$TRUST_JSON" ]]; then
|
||||||
|
TRUST_CERTIFIABLE="$(python3 -c 'import json,sys; print("1" if json.load(sys.stdin).get("certifiable") else "0")' <<< "$TRUST_JSON" 2>/dev/null || echo 0)"
|
||||||
|
REGISTRY_BYPASS="$(printf '%s' "${REGISTRY_JSON:-{}}" | python3 -c 'import json,sys; print("1" if json.load(sys.stdin).get("unsafe_bypass") else "0")' 2>/dev/null || echo 0)"
|
||||||
|
[[ "$REGISTRY_BYPASS" == "1" ]] && RUN_ASSURANCE="degraded"
|
||||||
|
[[ "$TRUST_CERTIFIABLE" == "1" && "$REGISTRY_BYPASS" == "0" ]] && RUN_CERTIFICATION="certified"
|
||||||
|
fi
|
||||||
|
NATIVE_EVENT="$(CASAN_NATIVE_RISK="$ACTION_RISK_JSON" python3 - "$EXECUTION_ID" "$ACTION_NAME" "$ACTION_CLASS" "$NATIVE_MODE" "$RUN_ASSURANCE" "$RUN_CERTIFICATION" "${CASAN_ACTOR:-${CASAN_AGENT:-}}" "$INPUT_HASH" "$CMD_HASH" <<'PY'
|
||||||
|
import json, os, sys
|
||||||
|
run_id, action, action_class, mode, assurance, certification, actor, input_hash, command_hash = sys.argv[1:]
|
||||||
|
try:
|
||||||
|
risk = json.loads(os.environ.get("CASAN_NATIVE_RISK") or "{}")
|
||||||
|
except ValueError:
|
||||||
|
risk = {}
|
||||||
|
print(json.dumps({
|
||||||
|
"run_id": run_id, "correlation_id": run_id, "action": action,
|
||||||
|
"tool": action, "actor": actor, "mode": mode,
|
||||||
|
"environment": os.environ.get("CASAN_PROFILE", "development"),
|
||||||
|
"execution_status": "success", "assurance_status": assurance,
|
||||||
|
"certification_status": certification,
|
||||||
|
"policy_decisions": [risk] if risk else [],
|
||||||
|
"extensions": {"input_hash": input_hash, "command_hash": command_hash, "action_class": action_class},
|
||||||
|
}, separators=(",", ":")))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
KERNEL_RC=0
|
||||||
|
KERNEL_BUNDLE="$(printf '%s' "$NATIVE_EVENT" | python3 "$KERNEL_CLI" adapt-native -)" || KERNEL_RC=$?
|
||||||
|
if [[ "$KERNEL_RC" -ne 0 ]]; then
|
||||||
|
casan_log error harness "KERNEL_CONTRACT_EMISSION_FAILED rc=$KERNEL_RC"
|
||||||
|
if [[ "$NATIVE_MODE" == "enforce" ]]; then
|
||||||
|
: > "$FINAL_OUTPUT"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
RUN_CERTIFICATION="non_certified"
|
||||||
|
else
|
||||||
|
KERNEL_PATH="$CASAN_STATE_ROOT/logs/kernel/$EXECUTION_ID.json"
|
||||||
|
python3 - "$KERNEL_PATH" "$KERNEL_BUNDLE" <<'PY'
|
||||||
|
import json, os, sys, tempfile
|
||||||
|
path, raw = sys.argv[1:]
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
payload = json.loads(raw)
|
||||||
|
fd, tmp = tempfile.mkstemp(prefix=".kernel-", dir=os.path.dirname(path))
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||||
|
json.dump(payload, handle, sort_keys=True, separators=(",", ":"))
|
||||||
|
handle.write("\n")
|
||||||
|
handle.flush(); os.fsync(handle.fileno())
|
||||||
|
os.replace(tmp, path)
|
||||||
|
finally:
|
||||||
|
try: os.unlink(tmp)
|
||||||
|
except OSError: pass
|
||||||
|
PY
|
||||||
|
fi
|
||||||
|
|
||||||
write_phase_report
|
write_phase_report
|
||||||
casan_log debug harness "action=$ACTION_NAME complete cache=$CACHE_STATUS"
|
casan_log debug harness "action=$ACTION_NAME complete cache=$CACHE_STATUS"
|
||||||
echo "CASAN_HARNESS_COMPLETE cache=$CACHE_STATUS key=$IDEMPOTENCY_KEY output=$FINAL_OUTPUT"
|
echo "CASAN_HARNESS_COMPLETE cache=$CACHE_STATUS key=$IDEMPOTENCY_KEY execution=success assurance=$RUN_ASSURANCE certification=$RUN_CERTIFICATION output=$FINAL_OUTPUT"
|
||||||
|
|||||||
@@ -703,7 +703,13 @@ def submit_escalation(args, router, binding):
|
|||||||
|
|
||||||
|
|
||||||
def ask(args) -> int:
|
def ask(args) -> int:
|
||||||
os.environ["CASAN_PROJECT_ID"] = args.project or "default"
|
# "default" is the Control Panel's logical fallback, not a manifest project.
|
||||||
|
# Export only explicit project bindings so generic chat turns can use the
|
||||||
|
# repository domain fallback without weakening manifest validation.
|
||||||
|
if args.project and args.project != "default":
|
||||||
|
os.environ["CASAN_PROJECT_ID"] = args.project
|
||||||
|
else:
|
||||||
|
os.environ.pop("CASAN_PROJECT_ID", None)
|
||||||
guard_rc = tenant_runtime_guard(args)
|
guard_rc = tenant_runtime_guard(args)
|
||||||
if guard_rc != 0:
|
if guard_rc != 0:
|
||||||
return guard_rc
|
return guard_rc
|
||||||
|
|||||||
@@ -1,45 +1,62 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""CASAN-native token-killer (Plan-08 Track 3).
|
"""CASAN-native deterministic context compressor (Plan-08 Track 3).
|
||||||
|
|
||||||
A deterministic tool-output compressor written for CASAN — NOT a wrapper around
|
The compressor reduces long tool output before it enters model context while
|
||||||
RTK. It reduces token count of long command/tool output before it enters model
|
preserving operationally significant lines. It is deliberately not a tokenizer
|
||||||
context, while (a) always preserving must-keep lines, (b) never compressing on
|
or a billing source: token figures are whitespace-based estimates and are
|
||||||
failure (raw passthrough for debugging, RTK-style tee), and (c) reporting the
|
labelled as such.
|
||||||
token savings for H6 telemetry.
|
|
||||||
|
|
||||||
Modes:
|
Safety contract:
|
||||||
dedup collapse consecutive duplicate lines with an (xN) counter
|
* preserve severity-tagged errors/warnings, failure/skip signals, summaries,
|
||||||
extractive keep only important lines (errors/failures/warnings) + must-keep
|
operational result lines, and project-supplied must-keep patterns;
|
||||||
structural dedup + keep summary/important/must-keep lines (for test/log output)
|
* verify preservation after compression, including match multiplicity;
|
||||||
|
* on a preservation failure, return non-zero and emit raw input by default so
|
||||||
|
a caller that ignores the exit code still cannot consume a lossy view;
|
||||||
|
* support halt-with-no-output for callers whose policy forbids raw fallback;
|
||||||
|
* optionally emit a hash-bound JSON evidence report;
|
||||||
|
* never compress a failed command when ``--failed`` is supplied.
|
||||||
|
|
||||||
Governance note: this runs AFTER `H4 scan raw` + `H5 hash raw` and BEFORE
|
Governance ordering remains the caller's responsibility:
|
||||||
`H4 scan compressed` in the Plan-08 pipeline; it is deterministic and needs no
|
H4 scan raw -> H5 hash raw -> compress -> H3 preservation/faithfulness
|
||||||
model, so it cannot be used as a path to evade H4.
|
-> H4 scan compressed -> H5 bind raw/compressed hashes -> model.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
from collections import Counter
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
|
||||||
def _casan_app_root():
|
|
||||||
# Plan-01: walk UP for the `.specify` state marker (harness code lives in
|
|
||||||
# packages/casan-harness/, so a fixed __file__ depth would mis-root).
|
|
||||||
_d = os.path.abspath(os.path.dirname(__file__))
|
|
||||||
_p = _d
|
|
||||||
while _p != os.path.dirname(_p):
|
|
||||||
if os.path.isdir(os.path.join(_p, ".specify")) or os.path.isdir(os.path.join(_p, "packages/casan-harness")):
|
|
||||||
return _p
|
|
||||||
_p = os.path.dirname(_p)
|
|
||||||
return os.path.abspath(os.path.join(_d, "..", "..", ".."))
|
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from typing import Dict, Iterable, List, Pattern, Sequence, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_MAX_BYTES = 2 * 1024 * 1024
|
||||||
|
PatternRule = Tuple[str, Pattern[str]]
|
||||||
|
|
||||||
|
|
||||||
|
def _casan_app_root() -> str:
|
||||||
|
"""Find the project/source root without relying on a fixed file depth."""
|
||||||
|
directory = os.path.abspath(os.path.dirname(__file__))
|
||||||
|
current = directory
|
||||||
|
while current != os.path.dirname(current):
|
||||||
|
if (
|
||||||
|
os.path.isdir(os.path.join(current, ".specify"))
|
||||||
|
or os.path.isdir(os.path.join(current, "packages/casan-harness"))
|
||||||
|
):
|
||||||
|
return current
|
||||||
|
current = os.path.dirname(current)
|
||||||
|
return os.path.abspath(os.path.join(directory, "..", "..", ".."))
|
||||||
|
|
||||||
|
|
||||||
def compression_enabled() -> bool:
|
def compression_enabled() -> bool:
|
||||||
"""Read the effective `compression.enabled` from the control-plane settings
|
"""Return the governed ``compression.enabled`` setting.
|
||||||
store (the harness-owned governed settings). Absent/invalid ⇒ enabled (default).
|
|
||||||
This is how a Control Plane setting change actually governs the harness."""
|
An absent or invalid store retains the historical enabled-by-default
|
||||||
|
behavior. A malformed project-supplied must-keep policy is handled
|
||||||
|
separately and fails closed.
|
||||||
|
"""
|
||||||
store_file = os.environ.get(
|
store_file = os.environ.get(
|
||||||
"CASAN_CP_STORE_FILE",
|
"CASAN_CP_STORE_FILE",
|
||||||
os.path.join(
|
os.path.join(
|
||||||
@@ -50,145 +67,544 @@ def compression_enabled() -> bool:
|
|||||||
if not os.path.isfile(store_file):
|
if not os.path.isfile(store_file):
|
||||||
return True
|
return True
|
||||||
try:
|
try:
|
||||||
data = json.load(open(store_file, encoding="utf-8"))
|
with open(store_file, encoding="utf-8") as handle:
|
||||||
|
data = json.load(handle)
|
||||||
setting = data.get("settings", {}).get("compression.enabled")
|
setting = data.get("settings", {}).get("compression.enabled")
|
||||||
return True if setting is None else bool(setting["value"])
|
return True if setting is None else bool(setting["value"])
|
||||||
except (OSError, ValueError, KeyError, TypeError):
|
except (OSError, ValueError, KeyError, TypeError):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
IMPORTANT_RE = re.compile(
|
# Serilog/log4net-style severity tags need explicit recognition. Word-boundary
|
||||||
r"\b(error|errors|fail|failed|failure|failing|exception|panic|denied|blocked|warn|warning)\b",
|
# matching alone does not classify abbreviations such as ``[WRN]``.
|
||||||
|
SEVERITY_TAG_RE = re.compile(
|
||||||
|
r"\[(ERR(?:OR)?|FTL|FATAL|CRIT(?:ICAL)?|WRN|WARN(?:ING)?)\]",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
SUMMARY_RE = re.compile(r"\b(\d+)\s+(pass|passed|fail|failed|tests?|errors?|warnings?)\b", re.IGNORECASE)
|
IMPORTANT_RE = re.compile(
|
||||||
|
r"\b("
|
||||||
|
r"error|errors|fail|failed|failure|failing|exception|panic|"
|
||||||
|
r"denied|blocked|warn|warning|timeout|timed out|"
|
||||||
|
r"abort|aborted|cancel|cancelled|skip|skipped|incomplete|"
|
||||||
|
r"partially completed|partial (?:result|output|analysis|run|failure|success)"
|
||||||
|
r")\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
SUMMARY_RE = re.compile(
|
||||||
|
r"\b(\d+)\s+(pass|passed|fail|failed|tests?|errors?|warnings?|files?|artifacts?)\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
OPERATIONAL_RE = re.compile(
|
||||||
|
r"\b("
|
||||||
|
r"exit\s*code|return\s*code|"
|
||||||
|
r"output\s*(?:path|directory)|"
|
||||||
|
r"generated\s+\d+\s+(?:files?|artifacts?)"
|
||||||
|
r")\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
DEDUP_SUFFIX_RE = re.compile(r"^(.*) \(x([1-9][0-9]*)\)$")
|
||||||
|
|
||||||
|
|
||||||
def estimate_tokens(text: str) -> int:
|
def configured_max_bytes() -> int:
|
||||||
return len(text.split())
|
raw = os.environ.get("CASAN_MAX_INPUT_BYTES", str(DEFAULT_MAX_BYTES))
|
||||||
|
try:
|
||||||
|
value = int(raw)
|
||||||
|
except ValueError:
|
||||||
|
print(
|
||||||
|
"COMPRESS_FAIL invalid_CASAN_MAX_INPUT_BYTES fail-closed",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(1)
|
||||||
|
if value <= 0:
|
||||||
|
print(
|
||||||
|
"COMPRESS_FAIL non_positive_CASAN_MAX_INPUT_BYTES fail-closed",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(1)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
# SEC-09 (M-10): bound input size (DoS) and read fail-closed. Non-UTF8 degrades via
|
def read_capped(src: str, max_bytes: int) -> Tuple[str, int]:
|
||||||
# errors="replace" instead of crashing; oversize/unreadable input exits non-zero and
|
"""Read at most ``max_bytes`` and return text plus replacement count."""
|
||||||
# emits nothing (never a crash traceback, never silent truncation).
|
|
||||||
MAX_BYTES = int(os.environ.get("CASAN_MAX_INPUT_BYTES", str(2 * 1024 * 1024)))
|
|
||||||
|
|
||||||
|
|
||||||
def read_capped(src: str) -> str:
|
|
||||||
try:
|
try:
|
||||||
if src == "-":
|
if src == "-":
|
||||||
data = sys.stdin.buffer.read(MAX_BYTES + 1)
|
data = sys.stdin.buffer.read(max_bytes + 1)
|
||||||
else:
|
else:
|
||||||
with open(src, "rb") as fh:
|
with open(src, "rb") as handle:
|
||||||
data = fh.read(MAX_BYTES + 1)
|
data = handle.read(max_bytes + 1)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
print(f"COMPRESS_FAIL unreadable_input: {exc}", file=sys.stderr)
|
print(f"COMPRESS_FAIL unreadable_input: {exc}", file=sys.stderr)
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
if len(data) > MAX_BYTES:
|
if len(data) > max_bytes:
|
||||||
print(f"COMPRESS_FAIL input_exceeds_cap({MAX_BYTES}B) fail-closed", file=sys.stderr)
|
print(
|
||||||
|
f"COMPRESS_FAIL input_exceeds_cap({max_bytes}B) fail-closed",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
return data.decode("utf-8", errors="replace")
|
text = data.decode("utf-8", errors="replace")
|
||||||
|
return text, text.count("\ufffd")
|
||||||
|
|
||||||
|
|
||||||
def load_patterns(path: str):
|
def load_patterns(path: str) -> List[PatternRule]:
|
||||||
|
"""Load and compile one regex per line, ignoring blank/comment lines."""
|
||||||
if not path:
|
if not path:
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
with open(path, encoding="utf-8", errors="replace") as fh:
|
with open(path, encoding="utf-8", errors="replace") as handle:
|
||||||
return [line.strip() for line in fh if line.strip()]
|
raw_rules = [
|
||||||
|
(line_number, line.strip())
|
||||||
|
for line_number, line in enumerate(handle, start=1)
|
||||||
|
if line.strip() and not line.lstrip().startswith("#")
|
||||||
|
]
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
print(f"COMPRESS_FAIL must_keep_file_unreadable: {exc}", file=sys.stderr)
|
print(f"COMPRESS_FAIL must_keep_file_unreadable: {exc}", file=sys.stderr)
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
rules: List[PatternRule] = []
|
||||||
def is_must_keep(line: str, patterns) -> bool:
|
for line_number, expression in raw_rules:
|
||||||
return any(re.search(p, line) for p in patterns)
|
try:
|
||||||
|
rules.append((expression, re.compile(expression)))
|
||||||
|
except re.error as exc:
|
||||||
|
print(
|
||||||
|
"COMPRESS_FAIL invalid_must_keep_regex "
|
||||||
|
f"file={path} line={line_number}: {exc}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(1)
|
||||||
|
return rules
|
||||||
|
|
||||||
|
|
||||||
def dedup(lines):
|
def is_must_keep(line: str, patterns: Sequence[PatternRule]) -> bool:
|
||||||
out = []
|
return any(pattern.search(line) for _, pattern in patterns)
|
||||||
i = 0
|
|
||||||
n = len(lines)
|
|
||||||
while i < n:
|
|
||||||
j = i
|
|
||||||
while j + 1 < n and lines[j + 1] == lines[i]:
|
|
||||||
j += 1
|
|
||||||
count = j - i + 1
|
|
||||||
out.append(lines[i] if count == 1 else f"{lines[i]} (x{count})")
|
|
||||||
i = j + 1
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def compress(text: str, mode: str, must):
|
def is_protected(line: str, patterns: Sequence[PatternRule]) -> bool:
|
||||||
lines = text.split("\n")
|
"""Return whether a line is forbidden from disappearing."""
|
||||||
if mode == "dedup":
|
return bool(
|
||||||
|
SEVERITY_TAG_RE.search(line)
|
||||||
|
or IMPORTANT_RE.search(line)
|
||||||
|
or SUMMARY_RE.search(line)
|
||||||
|
or OPERATIONAL_RE.search(line)
|
||||||
|
or is_must_keep(line, patterns)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_tokens(text: str) -> int:
|
||||||
|
"""Return a whitespace estimate, never provider/billed token usage."""
|
||||||
|
return len(text.split())
|
||||||
|
|
||||||
|
|
||||||
|
def dedup(lines: Sequence[str]) -> List[str]:
|
||||||
|
"""Collapse consecutive duplicate lines while retaining multiplicity."""
|
||||||
|
output: List[str] = []
|
||||||
|
index = 0
|
||||||
|
count_lines = len(lines)
|
||||||
|
while index < count_lines:
|
||||||
|
end = index
|
||||||
|
while end + 1 < count_lines and lines[end + 1] == lines[index]:
|
||||||
|
end += 1
|
||||||
|
occurrences = end - index + 1
|
||||||
|
output.append(
|
||||||
|
lines[index]
|
||||||
|
if occurrences == 1
|
||||||
|
else f"{lines[index]} (x{occurrences})"
|
||||||
|
)
|
||||||
|
index = end + 1
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def compress(
|
||||||
|
text: str,
|
||||||
|
mode: str,
|
||||||
|
must_keep: Sequence[PatternRule],
|
||||||
|
) -> List[str]:
|
||||||
|
"""Create a candidate compressed view.
|
||||||
|
|
||||||
|
Preservation is verified independently after this function returns.
|
||||||
|
"""
|
||||||
|
lines = compression_source_lines(text.split("\n"), mode, must_keep)
|
||||||
|
if mode in {"dedup", "structural"}:
|
||||||
return dedup(lines)
|
return dedup(lines)
|
||||||
if mode == "extractive":
|
if mode == "extractive":
|
||||||
return [ln for ln in lines if IMPORTANT_RE.search(ln) or is_must_keep(ln, must)]
|
return lines
|
||||||
if mode == "structural":
|
|
||||||
kept = [
|
|
||||||
ln
|
|
||||||
for ln in lines
|
|
||||||
if IMPORTANT_RE.search(ln) or SUMMARY_RE.search(ln) or is_must_keep(ln, must)
|
|
||||||
]
|
|
||||||
return dedup(kept)
|
|
||||||
raise ValueError(f"unknown mode: {mode}")
|
raise ValueError(f"unknown mode: {mode}")
|
||||||
|
|
||||||
|
|
||||||
|
def compression_source_lines(
|
||||||
|
raw_lines: Sequence[str],
|
||||||
|
mode: str,
|
||||||
|
must_keep: Sequence[PatternRule],
|
||||||
|
) -> List[str]:
|
||||||
|
"""Return the exact raw lines from which a candidate may be built."""
|
||||||
|
if mode == "dedup":
|
||||||
|
return list(raw_lines)
|
||||||
|
return [line for line in raw_lines if is_protected(line, must_keep)]
|
||||||
|
|
||||||
|
|
||||||
|
def expanded_line_counts(
|
||||||
|
lines: Iterable[str],
|
||||||
|
dedup_encoded: bool,
|
||||||
|
source_lines: Sequence[str],
|
||||||
|
) -> Counter:
|
||||||
|
"""Decode ``(xN)`` markers produced by ``dedup`` into weighted counts."""
|
||||||
|
candidate_lines = list(lines)
|
||||||
|
# This is the normal path and resolves the otherwise ambiguous case where
|
||||||
|
# a real log line itself ends in ``(xN)``.
|
||||||
|
if dedup_encoded and candidate_lines == dedup(source_lines):
|
||||||
|
return Counter(source_lines)
|
||||||
|
|
||||||
|
counts: Counter = Counter()
|
||||||
|
for line in candidate_lines:
|
||||||
|
match = DEDUP_SUFFIX_RE.match(line) if dedup_encoded else None
|
||||||
|
if match:
|
||||||
|
counts[match.group(1)] += int(match.group(2))
|
||||||
|
else:
|
||||||
|
counts[line] += 1
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def protected_line_deficits(
|
||||||
|
source_lines: Sequence[str],
|
||||||
|
candidate_lines: Sequence[str],
|
||||||
|
dedup_encoded: bool,
|
||||||
|
) -> Dict[str, int]:
|
||||||
|
"""Return exact protected-line multiplicity missing from the candidate."""
|
||||||
|
expected = Counter(source_lines)
|
||||||
|
actual = expanded_line_counts(
|
||||||
|
candidate_lines,
|
||||||
|
dedup_encoded=dedup_encoded,
|
||||||
|
source_lines=source_lines,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
line: expected_count - actual.get(line, 0)
|
||||||
|
for line, expected_count in expected.items()
|
||||||
|
if actual.get(line, 0) < expected_count
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def required_pattern_deficits(
|
||||||
|
raw_lines: Sequence[str],
|
||||||
|
source_lines: Sequence[str],
|
||||||
|
candidate_lines: Sequence[str],
|
||||||
|
required: Sequence[PatternRule],
|
||||||
|
dedup_encoded: bool,
|
||||||
|
) -> List[Dict[str, object]]:
|
||||||
|
"""Verify every required match present in raw remains in the candidate.
|
||||||
|
|
||||||
|
A pattern absent from raw is not a failure: the invariant is preservation,
|
||||||
|
not fabrication. Match multiplicity prevents one surviving warning from
|
||||||
|
hiding the loss of a second warning matched by the same rule.
|
||||||
|
"""
|
||||||
|
deficits: List[Dict[str, object]] = []
|
||||||
|
actual_lines = expanded_line_counts(
|
||||||
|
candidate_lines,
|
||||||
|
dedup_encoded=dedup_encoded,
|
||||||
|
source_lines=source_lines,
|
||||||
|
)
|
||||||
|
for expression, pattern in required:
|
||||||
|
expected = sum(1 for line in raw_lines if pattern.search(line))
|
||||||
|
actual = sum(
|
||||||
|
count
|
||||||
|
for line, count in actual_lines.items()
|
||||||
|
if pattern.search(line)
|
||||||
|
)
|
||||||
|
if actual < expected:
|
||||||
|
deficits.append(
|
||||||
|
{
|
||||||
|
"pattern": expression,
|
||||||
|
"expected": expected,
|
||||||
|
"actual": actual,
|
||||||
|
"missing": expected - actual,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return deficits
|
||||||
|
|
||||||
|
|
||||||
|
def severity_counts(text: str) -> Dict[str, int]:
|
||||||
|
counts = {"error": 0, "warning": 0, "critical": 0}
|
||||||
|
for line in text.splitlines():
|
||||||
|
match = SEVERITY_TAG_RE.search(line)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
level = match.group(1).upper()
|
||||||
|
if level.startswith(("ERR",)):
|
||||||
|
counts["error"] += 1
|
||||||
|
elif level.startswith(("WRN", "WARN")):
|
||||||
|
counts["warning"] += 1
|
||||||
|
else:
|
||||||
|
counts["critical"] += 1
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_text(text: str) -> str:
|
||||||
|
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def deficit_fingerprints(deficits: Dict[str, int]) -> List[Dict[str, object]]:
|
||||||
|
"""Report hashes, not potentially sensitive raw lines."""
|
||||||
|
return [
|
||||||
|
{"line_sha256": sha256_text(line), "missing": missing}
|
||||||
|
for line, missing in sorted(deficits.items())
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def write_json_report(path: str, report: Dict[str, object]) -> None:
|
||||||
|
"""Atomically write a private evidence report."""
|
||||||
|
if path == "-":
|
||||||
|
print(
|
||||||
|
"COMPRESS_FAIL report_json_stdout_conflicts_with_compressed_output",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(1)
|
||||||
|
absolute = os.path.abspath(path)
|
||||||
|
directory = os.path.dirname(absolute)
|
||||||
|
try:
|
||||||
|
os.makedirs(directory, exist_ok=True)
|
||||||
|
descriptor, temporary_path = tempfile.mkstemp(
|
||||||
|
prefix=".context-compress-",
|
||||||
|
suffix=".json.tmp",
|
||||||
|
dir=directory,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
||||||
|
json.dump(
|
||||||
|
report,
|
||||||
|
handle,
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
handle.write("\n")
|
||||||
|
os.chmod(temporary_path, 0o600)
|
||||||
|
os.replace(temporary_path, absolute)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
os.unlink(temporary_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"COMPRESS_FAIL report_write_failed: {exc}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
ap = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
ap.add_argument("--mode", choices=["dedup", "extractive", "structural"], default="structural")
|
parser.add_argument(
|
||||||
ap.add_argument("--input", default="-", help="input file or - for stdin")
|
"--mode",
|
||||||
ap.add_argument("--must-keep-file", default="", help="file with one must-keep regex per line")
|
choices=["dedup", "extractive", "structural"],
|
||||||
ap.add_argument("--failed", action="store_true", help="raw passthrough (tee) when the command failed")
|
default="structural",
|
||||||
ap.add_argument(
|
)
|
||||||
|
parser.add_argument("--input", default="-", help="input file or - for stdin")
|
||||||
|
parser.add_argument(
|
||||||
|
"--must-keep-file",
|
||||||
|
default="",
|
||||||
|
help="file with one must-keep regex per line",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--failed",
|
||||||
|
action="store_true",
|
||||||
|
help="raw passthrough when the producing command failed",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
"--respect-policy",
|
"--respect-policy",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="honor control-plane `compression.enabled`; if disabled, pass raw through",
|
help="honor control-plane compression.enabled",
|
||||||
)
|
)
|
||||||
ap.add_argument(
|
parser.add_argument(
|
||||||
"--require-must-keep-file",
|
"--require-must-keep-file",
|
||||||
default="",
|
default="",
|
||||||
help="verify every pattern in this file still appears; exit 1 (gate) if any is missing",
|
help=(
|
||||||
|
"verify every match present in raw remains in the candidate; "
|
||||||
|
"match multiplicity is enforced"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
args = ap.parse_args()
|
parser.add_argument(
|
||||||
|
"--on-preservation-failure",
|
||||||
|
choices=["raw", "halt"],
|
||||||
|
default="raw",
|
||||||
|
help=(
|
||||||
|
"raw: emit raw input and return 1; "
|
||||||
|
"halt: emit nothing and return 1"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--report-json",
|
||||||
|
default="",
|
||||||
|
help="optional atomic JSON evidence report path",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
raw = read_capped(args.input)
|
raw, decode_replacements = read_capped(
|
||||||
must = load_patterns(args.must_keep_file)
|
args.input,
|
||||||
|
configured_max_bytes(),
|
||||||
|
)
|
||||||
|
must_keep = load_patterns(args.must_keep_file)
|
||||||
|
required = load_patterns(args.require_must_keep_file)
|
||||||
|
raw_lines = raw.split("\n")
|
||||||
|
|
||||||
|
exit_code = 0
|
||||||
|
preservation_status = "not_applicable"
|
||||||
|
pattern_deficits: List[Dict[str, object]] = []
|
||||||
|
line_deficits: Dict[str, int] = {}
|
||||||
|
|
||||||
if args.failed:
|
if args.failed:
|
||||||
# RTK-style tee: never compress failing output; keep raw for debugging.
|
candidate_text = raw
|
||||||
out_text = raw
|
output_text = raw
|
||||||
mode_used = "passthrough"
|
mode_used = "passthrough"
|
||||||
|
decision = "failed-command-raw-passthrough"
|
||||||
|
saving_status = "not_compressed"
|
||||||
elif args.respect_policy and not compression_enabled():
|
elif args.respect_policy and not compression_enabled():
|
||||||
# Control-plane setting governs the harness: compression disabled ⇒ raw.
|
candidate_text = raw
|
||||||
out_text = raw
|
output_text = raw
|
||||||
mode_used = "policy-disabled"
|
mode_used = "policy-disabled"
|
||||||
|
decision = "policy-disabled-raw-passthrough"
|
||||||
|
saving_status = "not_compressed"
|
||||||
else:
|
else:
|
||||||
out_lines = compress(raw, args.mode, must)
|
source_lines = compression_source_lines(
|
||||||
out_text = "\n".join(out_lines)
|
raw_lines,
|
||||||
mode_used = args.mode
|
args.mode,
|
||||||
|
must_keep,
|
||||||
|
)
|
||||||
|
candidate_lines = compress(raw, args.mode, must_keep)
|
||||||
|
candidate_text = "\n".join(candidate_lines)
|
||||||
|
line_deficits = protected_line_deficits(
|
||||||
|
source_lines,
|
||||||
|
candidate_lines,
|
||||||
|
dedup_encoded=args.mode in {"dedup", "structural"},
|
||||||
|
)
|
||||||
|
pattern_deficits = required_pattern_deficits(
|
||||||
|
raw_lines,
|
||||||
|
source_lines,
|
||||||
|
candidate_lines,
|
||||||
|
required,
|
||||||
|
dedup_encoded=args.mode in {"dedup", "structural"},
|
||||||
|
)
|
||||||
|
empty_loss = bool(raw.strip()) and not candidate_text.strip()
|
||||||
|
preservation_failed = bool(
|
||||||
|
line_deficits or pattern_deficits or empty_loss
|
||||||
|
)
|
||||||
|
|
||||||
in_tokens = estimate_tokens(raw)
|
if preservation_failed:
|
||||||
out_tokens = estimate_tokens(out_text)
|
preservation_status = "failed"
|
||||||
saved = in_tokens - out_tokens
|
exit_code = 1
|
||||||
ratio = round(out_tokens / in_tokens, 4) if in_tokens else 1.0
|
mode_used = (
|
||||||
|
"fallback-raw"
|
||||||
|
if args.on_preservation_failure == "raw"
|
||||||
|
else "halt"
|
||||||
|
)
|
||||||
|
decision = "compression-rejected"
|
||||||
|
saving_status = "rejected"
|
||||||
|
output_text = (
|
||||||
|
raw if args.on_preservation_failure == "raw" else ""
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
preservation_status = "passed"
|
||||||
|
mode_used = args.mode
|
||||||
|
decision = "compressed"
|
||||||
|
saving_status = "estimate_only_quality_gate_required"
|
||||||
|
output_text = candidate_text
|
||||||
|
|
||||||
verify_patterns = load_patterns(args.require_must_keep_file)
|
input_estimate = estimate_tokens(raw)
|
||||||
missing = [p for p in verify_patterns if not re.search(p, out_text)]
|
candidate_estimate = estimate_tokens(candidate_text)
|
||||||
|
output_estimate = estimate_tokens(output_text)
|
||||||
|
if saving_status == "rejected":
|
||||||
|
# A rejected candidate has no claimable saving even when halt policy
|
||||||
|
# intentionally emits zero bytes.
|
||||||
|
estimated_saved = 0
|
||||||
|
estimated_ratio = 1.0
|
||||||
|
else:
|
||||||
|
estimated_saved = input_estimate - output_estimate
|
||||||
|
estimated_ratio = (
|
||||||
|
round(output_estimate / input_estimate, 4)
|
||||||
|
if input_estimate
|
||||||
|
else 1.0
|
||||||
|
)
|
||||||
|
emitted_text = output_text
|
||||||
|
if emitted_text and not emitted_text.endswith("\n"):
|
||||||
|
emitted_text += "\n"
|
||||||
|
|
||||||
sys.stdout.write(out_text)
|
report: Dict[str, object] = {
|
||||||
if not out_text.endswith("\n"):
|
"schema_version": 1,
|
||||||
sys.stdout.write("\n")
|
"measurement_source": "whitespace_estimate",
|
||||||
|
"provider_telemetry": None,
|
||||||
|
"mode_requested": args.mode,
|
||||||
|
"mode_used": mode_used,
|
||||||
|
"decision": decision,
|
||||||
|
"exit_code": exit_code,
|
||||||
|
"saving_status": saving_status,
|
||||||
|
"preservation": {
|
||||||
|
"status": preservation_status,
|
||||||
|
"protected_line_missing": sum(line_deficits.values()),
|
||||||
|
"protected_line_deficits": deficit_fingerprints(line_deficits),
|
||||||
|
"required_pattern_deficits": pattern_deficits,
|
||||||
|
},
|
||||||
|
"estimated_tokens": {
|
||||||
|
"input": input_estimate,
|
||||||
|
"candidate": candidate_estimate,
|
||||||
|
"output": output_estimate,
|
||||||
|
"saved": estimated_saved,
|
||||||
|
"ratio": estimated_ratio,
|
||||||
|
},
|
||||||
|
"severity": {
|
||||||
|
"raw": severity_counts(raw),
|
||||||
|
"candidate": severity_counts(candidate_text),
|
||||||
|
"output": severity_counts(output_text),
|
||||||
|
},
|
||||||
|
"hashes": {
|
||||||
|
"raw_sha256": sha256_text(raw),
|
||||||
|
"candidate_sha256": sha256_text(candidate_text),
|
||||||
|
"output_sha256": sha256_text(emitted_text),
|
||||||
|
},
|
||||||
|
"bytes": {
|
||||||
|
"raw": len(raw.encode("utf-8")),
|
||||||
|
"candidate": len(candidate_text.encode("utf-8")),
|
||||||
|
"output": len(emitted_text.encode("utf-8")),
|
||||||
|
},
|
||||||
|
"decode_replacement_count": decode_replacements,
|
||||||
|
"requires_downstream_h3_quality_gate": decision == "compressed",
|
||||||
|
"requires_downstream_h4_compressed_scan": decision == "compressed",
|
||||||
|
"requires_downstream_h5_audit_binding": decision == "compressed",
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.report_json:
|
||||||
|
write_json_report(args.report_json, report)
|
||||||
|
|
||||||
|
sys.stdout.write(emitted_text)
|
||||||
print(
|
print(
|
||||||
f"COMPRESS mode={mode_used} in_tokens={in_tokens} out_tokens={out_tokens} "
|
"COMPRESS "
|
||||||
f"saved={saved} ratio={ratio} must_keep_missing={len(missing)}",
|
f"mode={mode_used} "
|
||||||
|
f"measurement_source=whitespace_estimate "
|
||||||
|
f"in_tokens={input_estimate} "
|
||||||
|
f"candidate_tokens={candidate_estimate} "
|
||||||
|
f"out_tokens={output_estimate} "
|
||||||
|
f"saved={estimated_saved} "
|
||||||
|
f"ratio={estimated_ratio} "
|
||||||
|
f"preservation={preservation_status} "
|
||||||
|
f"saving_status={saving_status} "
|
||||||
|
f"must_keep_missing={sum(int(item['missing']) for item in pattern_deficits)} "
|
||||||
|
f"protected_missing={sum(line_deficits.values())}",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
if missing:
|
if pattern_deficits:
|
||||||
print(f"COMPRESS_MUST_KEEP_DROPPED {','.join(missing)}", file=sys.stderr)
|
details = ",".join(
|
||||||
return 1
|
f"{item['pattern']}({item['actual']}/{item['expected']})"
|
||||||
return 0
|
for item in pattern_deficits
|
||||||
|
)
|
||||||
|
print(f"COMPRESS_MUST_KEEP_DROPPED {details}", file=sys.stderr)
|
||||||
|
if line_deficits:
|
||||||
|
print(
|
||||||
|
"COMPRESS_PROTECTED_CONTENT_DROPPED "
|
||||||
|
f"occurrences={sum(line_deficits.values())}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
if preservation_status == "failed":
|
||||||
|
print(
|
||||||
|
"COMPRESS_REJECTED "
|
||||||
|
f"fallback={args.on_preservation_failure}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return exit_code
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -58,27 +58,48 @@ TRACE_ID="$(new_trace_id)"
|
|||||||
TIMESTAMP="$(timestamp)"
|
TIMESTAMP="$(timestamp)"
|
||||||
INPUT="$(cat "$INPUT_FILE")"
|
INPUT="$(cat "$INPUT_FILE")"
|
||||||
LOWER_INPUT="$(printf '%s' "$INPUT" | tr '[:upper:]' '[:lower:]')"
|
LOWER_INPUT="$(printf '%s' "$INPUT" | tr '[:upper:]' '[:lower:]')"
|
||||||
ACTOR="${CASAN_ACTOR:-developer}"
|
ACTOR="${CASAN_ACTOR:-${CASAN_AGENT:-}}"
|
||||||
APPROVER="${CASAN_APPROVER:-}"
|
APPROVER="${CASAN_APPROVER:-}"
|
||||||
APPROVAL_DECISION="${CASAN_APPROVAL_DECISION:-auto}"
|
APPROVAL_DECISION="${CASAN_APPROVAL_DECISION:-auto}"
|
||||||
AUDIT_LOG="$AUDIT_DIR/audit.jsonl"
|
AUDIT_LOG="$AUDIT_DIR/audit.jsonl"
|
||||||
|
|
||||||
RISK_LEVEL="low"
|
RISK_LEVEL="low"
|
||||||
REASONS=()
|
REASONS=()
|
||||||
|
ACTION_CLASS="unknown"
|
||||||
case "$ACTION_NAME" in
|
RISK_FACTORS_JSON='{"action_risk":"high","content_risk":"high","environment_risk":"low","identity_risk":"low","resource_risk":"low"}'
|
||||||
deploy|launch|write_code|write_file|migration|db_write|external_api|tool_call)
|
EVIDENCE_REQUIREMENT="required"
|
||||||
RISK_LEVEL="medium"
|
RISK_POLICY_DECISION="require_approval"
|
||||||
REASONS+=("sensitive-action:$ACTION_NAME")
|
KERNEL_CLI="$CASAN_HARNESS_ROOT/scripts/python/kernel_cli.py"
|
||||||
;;
|
RISK_JSON=""
|
||||||
esac
|
RISK_RC=0
|
||||||
|
if [[ -f "$KERNEL_CLI" ]]; then
|
||||||
if printf '%s' "$LOWER_INPUT" | grep -Eq "(delete|drop table|password|api[_-]?key|secret|token|credential|migration|deploy|external api|shutdown|dump database)"; then
|
RISK_JSON="$(python3 "$KERNEL_CLI" risk --action "$ACTION_NAME" --tool "$ACTION_NAME" \
|
||||||
|
--content-file "$INPUT_FILE" --actor "$ACTOR" --environment "${CASAN_PROFILE:-development}" 2>/dev/null)" || RISK_RC=$?
|
||||||
|
else
|
||||||
|
RISK_RC=127
|
||||||
|
fi
|
||||||
|
if [[ "$RISK_RC" -eq 0 && -n "$RISK_JSON" ]]; then
|
||||||
|
RISK_FIELDS="$(python3 - "$RISK_JSON" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
payload = json.loads(sys.argv[1])
|
||||||
|
print("\t".join([
|
||||||
|
str(payload["action_class"]),
|
||||||
|
str(payload["effective_risk"]),
|
||||||
|
json.dumps(payload["risk_factors"], sort_keys=True, separators=(",", ":")),
|
||||||
|
str(payload["evidence_requirement"]),
|
||||||
|
str(payload["decision"]),
|
||||||
|
]))
|
||||||
|
PY
|
||||||
|
)" || RISK_RC=$?
|
||||||
|
fi
|
||||||
|
if [[ "$RISK_RC" -eq 0 && -n "${RISK_FIELDS:-}" ]]; then
|
||||||
|
IFS=$'\t' read -r ACTION_CLASS RISK_LEVEL RISK_FACTORS_JSON EVIDENCE_REQUIREMENT RISK_POLICY_DECISION <<< "$RISK_FIELDS"
|
||||||
|
REASONS+=("action-risk-floor:$ACTION_CLASS")
|
||||||
|
else
|
||||||
RISK_LEVEL="high"
|
RISK_LEVEL="high"
|
||||||
REASONS+=("high-risk-content")
|
ACTION_CLASS="unknown"
|
||||||
elif printf '%s' "$LOWER_INPUT" | grep -Eq "(internal|config|system|policy|permission)"; then
|
RISK_POLICY_DECISION="require_approval"
|
||||||
[[ "$RISK_LEVEL" == "low" ]] && RISK_LEVEL="medium"
|
REASONS+=("action-risk-classifier-failed-closed")
|
||||||
REASONS+=("medium-risk-content")
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
APPROVAL_STATUS="auto_approved"
|
APPROVAL_STATUS="auto_approved"
|
||||||
@@ -88,8 +109,14 @@ if [[ "$RISK_LEVEL" == "medium" ]]; then
|
|||||||
APPROVAL_STATUS="policy_auto_approved_with_audit"
|
APPROVAL_STATUS="policy_auto_approved_with_audit"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$RISK_LEVEL" == "high" ]]; then
|
if [[ "$RISK_POLICY_DECISION" == "deny" ]]; then
|
||||||
if [[ "${CASAN_APPROVAL_STRICT:-0}" == "1" ]]; then
|
APPROVAL_STATUS="actor_identity_required"
|
||||||
|
DECISION="denied"
|
||||||
|
REASONS+=("actor-identity-required")
|
||||||
|
elif [[ "$RISK_LEVEL" == "high" || "$RISK_LEVEL" == "critical" || "$RISK_POLICY_DECISION" == "require_approval" ]]; then
|
||||||
|
APPROVAL_STRICT_EFFECTIVE="${CASAN_APPROVAL_STRICT:-0}"
|
||||||
|
[[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" ]] && APPROVAL_STRICT_EFFECTIVE="1"
|
||||||
|
if [[ "$APPROVAL_STRICT_EFFECTIVE" == "1" ]]; then
|
||||||
# Approval-identity mode (V20): an env-var approver is NOT enough — the
|
# Approval-identity mode (V20): an env-var approver is NOT enough — the
|
||||||
# reviewer must cryptographically SIGN this exact request and their role must
|
# reviewer must cryptographically SIGN this exact request and their role must
|
||||||
# be authorized for the action. SoD (actor != approver) still enforced.
|
# be authorized for the action. SoD (actor != approver) still enforced.
|
||||||
@@ -147,7 +174,7 @@ fi
|
|||||||
REASONS_JSON="$(printf '%s\n' "${REASONS[@]:-}" | python -c 'import json,sys; print(json.dumps([x for x in sys.stdin.read().splitlines() if x]))')"
|
REASONS_JSON="$(printf '%s\n' "${REASONS[@]:-}" | python -c 'import json,sys; print(json.dumps([x for x in sys.stdin.read().splitlines() if x]))')"
|
||||||
# approver and output_hash are part of the hashed core so they cannot be
|
# approver and output_hash are part of the hashed core so they cannot be
|
||||||
# silently mutated after the fact.
|
# silently mutated after the fact.
|
||||||
RECORD_CORE="$(printf '%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s' "$TIMESTAMP" "$TRACE_ID" "$ACTION_NAME" "$ACTOR" "$RISK_LEVEL" "$DECISION" "$APPROVAL_STATUS" "$APPROVER" "$INPUT_HASH" "$OUTPUT_HASH" "$PREV_HASH")"
|
RECORD_CORE="$(printf '%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s' "$TIMESTAMP" "$TRACE_ID" "$ACTION_NAME" "$ACTION_CLASS" "$ACTOR" "$RISK_LEVEL" "$RISK_FACTORS_JSON" "$EVIDENCE_REQUIREMENT" "$DECISION" "$APPROVAL_STATUS" "$APPROVER" "$INPUT_HASH" "$OUTPUT_HASH" "$PREV_HASH")"
|
||||||
RECORD_HASH="$(printf '%s' "$RECORD_CORE" | hash_text)"
|
RECORD_HASH="$(printf '%s' "$RECORD_CORE" | hash_text)"
|
||||||
|
|
||||||
TRACE_FILE="$TRACE_DIR/governance-$TRACE_ID.json"
|
TRACE_FILE="$TRACE_DIR/governance-$TRACE_ID.json"
|
||||||
@@ -160,18 +187,24 @@ TRACE_FILE="$TRACE_DIR/governance-$TRACE_ID.json"
|
|||||||
# written (disk full, read-only, quota), there must be NO governed action without
|
# written (disk full, read-only, quota), there must be NO governed action without
|
||||||
# its accountability record — deny and empty the output rather than proceed.
|
# its accountability record — deny and empty the output rather than proceed.
|
||||||
if ! CASAN_GC_REASONS="$REASONS_JSON" python - "$TRACE_FILE" "$AUDIT_LOG" \
|
if ! CASAN_GC_REASONS="$REASONS_JSON" python - "$TRACE_FILE" "$AUDIT_LOG" \
|
||||||
"$TIMESTAMP" "$TRACE_ID" "$ACTION_NAME" "$ACTOR" "$RISK_LEVEL" "$DECISION" \
|
"$TIMESTAMP" "$TRACE_ID" "$ACTION_NAME" "$ACTION_CLASS" "$ACTOR" "$RISK_LEVEL" \
|
||||||
"$APPROVAL_STATUS" "$APPROVER" "$INPUT_HASH" "$OUTPUT_HASH" "$PREV_HASH" "$RECORD_HASH" <<'PY'
|
"$RISK_FACTORS_JSON" "$EVIDENCE_REQUIREMENT" "$DECISION" "$APPROVAL_STATUS" \
|
||||||
|
"$APPROVER" "$INPUT_HASH" "$OUTPUT_HASH" "$PREV_HASH" "$RECORD_HASH" <<'PY'
|
||||||
import json, os, sys
|
import json, os, sys
|
||||||
(trace_file, audit_log, ts, trace_id, action, actor, risk, decision,
|
(trace_file, audit_log, ts, trace_id, action, action_class, actor, risk,
|
||||||
approval_status, approver, input_hash, output_hash, prev_hash, record_hash) = sys.argv[1:]
|
risk_factors_json, evidence_requirement, decision, approval_status, approver,
|
||||||
|
input_hash, output_hash, prev_hash, record_hash) = sys.argv[1:]
|
||||||
try:
|
try:
|
||||||
reasons = json.loads(os.environ.get("CASAN_GC_REASONS") or "[]")
|
reasons = json.loads(os.environ.get("CASAN_GC_REASONS") or "[]")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
reasons = []
|
reasons = []
|
||||||
rec = {
|
rec = {
|
||||||
|
"schema_version": 2, "category": "runtime_control",
|
||||||
"timestamp": ts, "trace_id": trace_id, "harness": "H5-governance",
|
"timestamp": ts, "trace_id": trace_id, "harness": "H5-governance",
|
||||||
"action": action, "actor": actor, "risk_level": risk, "decision": decision,
|
"action": action, "action_class": action_class, "actor": actor,
|
||||||
|
"risk_level": risk, "effective_risk": risk,
|
||||||
|
"risk_factors": json.loads(risk_factors_json),
|
||||||
|
"evidence_requirement": evidence_requirement, "decision": decision,
|
||||||
"approval_status": approval_status, "approver": approver,
|
"approval_status": approval_status, "approver": approver,
|
||||||
"input_hash": input_hash, "output_hash": output_hash,
|
"input_hash": input_hash, "output_hash": output_hash,
|
||||||
"previous_record_hash": prev_hash, "record_hash": record_hash,
|
"previous_record_hash": prev_hash, "record_hash": record_hash,
|
||||||
@@ -196,8 +229,43 @@ fi
|
|||||||
# --- External anchor: cryptographically sign the new chain head ---
|
# --- External anchor: cryptographically sign the new chain head ---
|
||||||
# A re-forged chain (recomputed hashes) changes the head; without the private
|
# A re-forged chain (recomputed hashes) changes the head; without the private
|
||||||
# key the attacker cannot produce a matching signature, so verification fails.
|
# key the attacker cannot produce a matching signature, so verification fails.
|
||||||
# Production note: the private key must live off-repo (KMS/HSM). It is local
|
# Development may use a local key for self-contained demonstration. Production
|
||||||
# here only for self-contained demonstration.
|
# refuses that path unless an explicit emergency override is visible in evidence.
|
||||||
|
PRODUCTION_PROFILE=0
|
||||||
|
[[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" ]] && PRODUCTION_PROFILE=1
|
||||||
|
EMERGENCY_TRUST_OVERRIDE="${CASAN_TRUST_EMERGENCY_OVERRIDE:-0}"
|
||||||
|
TRUST_LOG="$CASAN_STATE_ROOT/logs/readiness/trust-capabilities.jsonl"
|
||||||
|
mkdir -p "$(dirname "$TRUST_LOG")"
|
||||||
|
|
||||||
|
if [[ "$PRODUCTION_PROFILE" == "1" && "$EMERGENCY_TRUST_OVERRIDE" != "1" ]]; then
|
||||||
|
TRUST_RC=0
|
||||||
|
TRUST_JSON="$(python3 "$KERNEL_CLI" trust-capabilities 2>/dev/null)" || TRUST_RC=$?
|
||||||
|
if [[ "$TRUST_RC" -ne 0 ]]; then
|
||||||
|
if [[ -n "$TRUST_JSON" ]]; then
|
||||||
|
printf '%s\n' "$TRUST_JSON" >> "$TRUST_LOG"
|
||||||
|
else
|
||||||
|
printf '{"ready":false,"severity":"critical","reason_codes":["production_trust_configuration_invalid"]}\n' >> "$TRUST_LOG"
|
||||||
|
fi
|
||||||
|
: > "$OUTPUT_FILE"
|
||||||
|
echo "GOVERNANCE_DENIED trace_id=$TRACE_ID reason=production_trust_root_unavailable" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$TRUST_JSON" >> "$TRUST_LOG"
|
||||||
|
if ! bash "$SCRIPT_DIR/sign-audit-head.sh" "$AUDIT_LOG" >/dev/null 2>&1; then
|
||||||
|
: > "$OUTPUT_FILE"
|
||||||
|
echo "GOVERNANCE_DENIED trace_id=$TRACE_ID reason=external_signing_failed" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if ! bash "$SCRIPT_DIR/audit-ship-s3.sh" "$AUDIT_DIR/audit-head.txt" >/dev/null 2>&1; then
|
||||||
|
: > "$OUTPUT_FILE"
|
||||||
|
echo "GOVERNANCE_DENIED trace_id=$TRACE_ID reason=external_immutable_anchor_failed" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if [[ "$PRODUCTION_PROFILE" == "1" ]]; then
|
||||||
|
printf '{"schema_version":"1.0.0","profile":"production","ready":false,"certifiable":false,"emergency_override":true,"severity":"critical","reason_codes":["emergency_local_trust_override_active"]}\n' >> "$TRUST_LOG"
|
||||||
|
echo "CRITICAL: emergency local trust override active; execution cannot be production-certified" >&2
|
||||||
|
fi
|
||||||
if command -v openssl >/dev/null 2>&1; then
|
if command -v openssl >/dev/null 2>&1; then
|
||||||
# Private signing key lives OFF-REPO (default ~/.casan/audit-keys); only the
|
# Private signing key lives OFF-REPO (default ~/.casan/audit-keys); only the
|
||||||
# public key is committed. Production: replace with KMS/HSM.
|
# public key is committed. Production: replace with KMS/HSM.
|
||||||
@@ -207,7 +275,7 @@ if command -v openssl >/dev/null 2>&1; then
|
|||||||
AUDIT_PUB="$PUB_DIR/audit-public.pem"
|
AUDIT_PUB="$PUB_DIR/audit-public.pem"
|
||||||
mkdir -p "$PUB_DIR" "$PRIV_DIR"
|
mkdir -p "$PUB_DIR" "$PRIV_DIR"
|
||||||
if [[ ! -f "$AUDIT_PRIV" ]]; then
|
if [[ ! -f "$AUDIT_PRIV" ]]; then
|
||||||
if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_VERIFY_STRICT:-}" == "1" ]]; then
|
if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" || "${CASAN_VERIFY_STRICT:-}" == "1" ]]; then
|
||||||
# SEC-02 (H-02): in enforced mode NEVER auto-generate a local signing key.
|
# SEC-02 (H-02): in enforced mode NEVER auto-generate a local signing key.
|
||||||
# A freshly-minted key next to the data lets any file-writer re-sign a forged
|
# A freshly-minted key next to the data lets any file-writer re-sign a forged
|
||||||
# head. Prod must provision the key out-of-band (KMS/HSM — see sign-audit-head.sh
|
# head. Prod must provision the key out-of-band (KMS/HSM — see sign-audit-head.sh
|
||||||
@@ -229,6 +297,7 @@ if command -v openssl >/dev/null 2>&1; then
|
|||||||
openssl dgst -sha256 -sign "$AUDIT_PRIV" -out "$AUDIT_DIR/audit-head.sig" "$AUDIT_DIR/audit-head.txt" 2>/dev/null || true
|
openssl dgst -sha256 -sign "$AUDIT_PRIV" -out "$AUDIT_DIR/audit-head.sig" "$AUDIT_DIR/audit-head.txt" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ "$DECISION" != "approved" ]]; then
|
if [[ "$DECISION" != "approved" ]]; then
|
||||||
: > "$OUTPUT_FILE"
|
: > "$OUTPUT_FILE"
|
||||||
|
|||||||
@@ -21,22 +21,22 @@ while IFS= read -r raw || [[ -n "$raw" ]]; do
|
|||||||
[[ "$line" =~ ^([A-Z0-9_]+)=(.*)$ ]] || fail "invalid_env_syntax"
|
[[ "$line" =~ ^([A-Z0-9_]+)=(.*)$ ]] || fail "invalid_env_syntax"
|
||||||
key="${BASH_REMATCH[1]}"; value="${BASH_REMATCH[2]}"
|
key="${BASH_REMATCH[1]}"; value="${BASH_REMATCH[2]}"
|
||||||
case "$key" in
|
case "$key" in
|
||||||
CASAN_PUBLIC_FQDN|CASAN_CP_HTTPS_PORT|CASAN_CP_TLS_DIR|CASAN_CP_OAUTH_ENV|CASAN_CP_RUNTIME_ENV|CASAN_CP_VAULT_ENV|CASAN_CP_STATE_DIR|CASAN_CP_OUTPUT_DIR|CASAN_CP_API_IMAGE|CASAN_CP_UI_IMAGE|CASAN_S3_BUCKET|CASAN_S3_PREFIX|CASAN_S3_REGION|CASAN_S3_RETENTION_DAYS|CASAN_S3_KMS_KEY_ID) export "$key=$value" ;;
|
CASAN_PUBLIC_FQDN|CASAN_CP_HTTPS_PORT|CASAN_CP_TLS_DIR|CASAN_CP_OAUTH_ENV|CASAN_CP_RUNTIME_ENV|CASAN_CP_VAULT_ENV|CASAN_CP_STATE_DIR|CASAN_CP_OUTPUT_DIR|CASAN_CP_IDP_PUBLIC_KEY|CASAN_CP_API_IMAGE|CASAN_CP_UI_IMAGE|CASAN_CP_OAUTH2_PROXY_IMAGE|CASAN_S3_BUCKET|CASAN_S3_PREFIX|CASAN_S3_REGION|CASAN_S3_RETENTION_DAYS|CASAN_S3_KMS_KEY_ID) export "$key=$value" ;;
|
||||||
*) fail "unexpected_env_key key=$key" ;;
|
*) fail "unexpected_env_key key=$key" ;;
|
||||||
esac
|
esac
|
||||||
done < "$ENV_FILE"
|
done < "$ENV_FILE"
|
||||||
|
|
||||||
required=(CASAN_PUBLIC_FQDN CASAN_CP_TLS_DIR CASAN_CP_OAUTH_ENV CASAN_CP_RUNTIME_ENV CASAN_CP_VAULT_ENV CASAN_CP_STATE_DIR CASAN_CP_OUTPUT_DIR CASAN_CP_API_IMAGE CASAN_CP_UI_IMAGE CASAN_S3_BUCKET CASAN_S3_REGION CASAN_S3_KMS_KEY_ID)
|
required=(CASAN_PUBLIC_FQDN CASAN_CP_TLS_DIR CASAN_CP_OAUTH_ENV CASAN_CP_RUNTIME_ENV CASAN_CP_VAULT_ENV CASAN_CP_STATE_DIR CASAN_CP_OUTPUT_DIR CASAN_CP_IDP_PUBLIC_KEY CASAN_CP_API_IMAGE CASAN_CP_UI_IMAGE CASAN_CP_OAUTH2_PROXY_IMAGE CASAN_S3_BUCKET CASAN_S3_REGION CASAN_S3_KMS_KEY_ID)
|
||||||
for key in "${required[@]}"; do [[ -n "${!key:-}" ]] || fail "missing_env key=$key"; done
|
for key in "${required[@]}"; do [[ -n "${!key:-}" ]] || fail "missing_env key=$key"; done
|
||||||
|
|
||||||
case "$CASAN_PUBLIC_FQDN" in *localhost*|*127.0.0.1*|*example.com*|*replace-with*|*/*|[0-9]* ) fail "invalid_fqdn";; esac
|
case "$CASAN_PUBLIC_FQDN" in *localhost*|*127.0.0.1*|*example.com*|*replace-with*|*/*|[0-9]* ) fail "invalid_fqdn";; esac
|
||||||
[[ "$CASAN_PUBLIC_FQDN" == *.* ]] || fail "fqdn_required"
|
[[ "$CASAN_PUBLIC_FQDN" == *.* ]] || fail "fqdn_required"
|
||||||
for image in "$CASAN_CP_API_IMAGE" "$CASAN_CP_UI_IMAGE"; do
|
for image in "$CASAN_CP_API_IMAGE" "$CASAN_CP_UI_IMAGE" "$CASAN_CP_OAUTH2_PROXY_IMAGE"; do
|
||||||
[[ "$image" =~ @sha256:[a-f0-9]{64}$ ]] || fail "image_must_be_digest_pinned image=$image"
|
[[ "$image" =~ @sha256:[a-f0-9]{64}$ ]] || fail "image_must_be_digest_pinned image=$image"
|
||||||
done
|
done
|
||||||
pass "public FQDN and images are production-safe"
|
pass "public FQDN and images are production-safe"
|
||||||
|
|
||||||
for file in "$CASAN_CP_TLS_DIR/tls.crt" "$CASAN_CP_TLS_DIR/tls.key" "$CASAN_CP_OAUTH_ENV" "$CASAN_CP_RUNTIME_ENV" "$CASAN_CP_VAULT_ENV"; do
|
for file in "$CASAN_CP_TLS_DIR/tls.crt" "$CASAN_CP_TLS_DIR/tls.key" "$CASAN_CP_IDP_PUBLIC_KEY" "$CASAN_CP_OAUTH_ENV" "$CASAN_CP_RUNTIME_ENV" "$CASAN_CP_VAULT_ENV"; do
|
||||||
[[ -s "$file" ]] || fail "missing_or_empty path=$file"
|
[[ -s "$file" ]] || fail "missing_or_empty path=$file"
|
||||||
done
|
done
|
||||||
openssl x509 -in "$CASAN_CP_TLS_DIR/tls.crt" -noout >/dev/null || fail "invalid_tls_certificate"
|
openssl x509 -in "$CASAN_CP_TLS_DIR/tls.crt" -noout >/dev/null || fail "invalid_tls_certificate"
|
||||||
@@ -45,6 +45,7 @@ openssl x509 -in "$CASAN_CP_TLS_DIR/tls.crt" -noout -checkhost "$CASAN_PUBLIC_FQ
|
|||||||
cert_pub="$(openssl x509 -in "$CASAN_CP_TLS_DIR/tls.crt" -pubkey -noout | openssl pkey -pubin -outform DER | openssl dgst -sha256 | awk '{print $NF}')"
|
cert_pub="$(openssl x509 -in "$CASAN_CP_TLS_DIR/tls.crt" -pubkey -noout | openssl pkey -pubin -outform DER | openssl dgst -sha256 | awk '{print $NF}')"
|
||||||
key_pub="$(openssl pkey -in "$CASAN_CP_TLS_DIR/tls.key" -pubout -outform DER | openssl dgst -sha256 | awk '{print $NF}')"
|
key_pub="$(openssl pkey -in "$CASAN_CP_TLS_DIR/tls.key" -pubout -outform DER | openssl dgst -sha256 | awk '{print $NF}')"
|
||||||
[[ "$cert_pub" == "$key_pub" ]] || fail "tls_key_does_not_match_certificate"
|
[[ "$cert_pub" == "$key_pub" ]] || fail "tls_key_does_not_match_certificate"
|
||||||
|
openssl rsa -pubin -in "$CASAN_CP_IDP_PUBLIC_KEY" -noout -modulus >/dev/null 2>&1 || fail "invalid_idp_rsa_public_key"
|
||||||
pass "TLS certificate is valid for at least 30 days"
|
pass "TLS certificate is valid for at least 30 days"
|
||||||
|
|
||||||
value_of() { sed -n -E "s/^${1}=//p" "$CASAN_CP_OAUTH_ENV" | tail -1; }
|
value_of() { sed -n -E "s/^${1}=//p" "$CASAN_CP_OAUTH_ENV" | tail -1; }
|
||||||
@@ -56,8 +57,24 @@ done
|
|||||||
[[ "$(value_of OAUTH2_PROXY_OIDC_ISSUER_URL)" == https://* ]] || fail "oidc_issuer_https_required"
|
[[ "$(value_of OAUTH2_PROXY_OIDC_ISSUER_URL)" == https://* ]] || fail "oidc_issuer_https_required"
|
||||||
[[ "$(value_of OAUTH2_PROXY_REDIRECT_URL)" == "https://$CASAN_PUBLIC_FQDN/oauth2/callback" ]] || fail "oidc_redirect_mismatch"
|
[[ "$(value_of OAUTH2_PROXY_REDIRECT_URL)" == "https://$CASAN_PUBLIC_FQDN/oauth2/callback" ]] || fail "oidc_redirect_mismatch"
|
||||||
[[ "$(value_of OAUTH2_PROXY_COOKIE_SECURE)" == true ]] || fail "oidc_secure_cookie_required"
|
[[ "$(value_of OAUTH2_PROXY_COOKIE_SECURE)" == true ]] || fail "oidc_secure_cookie_required"
|
||||||
|
[[ "$(value_of OAUTH2_PROXY_SET_XAUTHREQUEST)" == true ]] || fail "oidc_xauthrequest_required"
|
||||||
|
[[ "$(value_of OAUTH2_PROXY_PASS_ACCESS_TOKEN)" == true ]] || fail "oidc_access_token_forwarding_required"
|
||||||
|
[[ "$(value_of OAUTH2_PROXY_PASS_AUTHORIZATION_HEADER)" == true ]] || fail "oidc_authorization_header_forwarding_required"
|
||||||
pass "enterprise OIDC configuration"
|
pass "enterprise OIDC configuration"
|
||||||
|
|
||||||
|
runtime_value_of() { sed -n -E "s/^${1}=//p" "$CASAN_CP_RUNTIME_ENV" | tail -1; }
|
||||||
|
[[ "$(runtime_value_of CASAN_PROFILE)" == prod ]] || fail "runtime_profile_must_be_prod"
|
||||||
|
[[ "$(runtime_value_of CASAN_CP_AUTH_MODE)" == jwt ]] || fail "runtime_jwt_auth_required"
|
||||||
|
[[ "$(runtime_value_of CASAN_CP_JWT_ISSUER)" == "$(value_of OAUTH2_PROXY_OIDC_ISSUER_URL)" ]] || fail "runtime_oidc_issuer_mismatch"
|
||||||
|
[[ "$(runtime_value_of CASAN_CP_JWT_AUDIENCE)" == "$(value_of OAUTH2_PROXY_CLIENT_ID)" ]] || fail "runtime_oidc_audience_mismatch"
|
||||||
|
[[ "$(runtime_value_of CASAN_CP_JWT_PUBLIC_KEY_FILE)" == /run/casan-idp/idp-public.pem ]] || fail "runtime_idp_public_key_path_invalid"
|
||||||
|
[[ "$(runtime_value_of CASAN_SIGNING_PROVIDER)" == vault_kms ]] || fail "runtime_external_signing_required"
|
||||||
|
[[ "$(runtime_value_of CASAN_IMMUTABLE_ANCHOR_PROVIDER)" == s3_object_lock ]] || fail "runtime_immutable_anchor_required"
|
||||||
|
[[ -z "$(runtime_value_of CASAN_CP_TRUST_AUTH_PROXY)" ]] || fail "legacy_trusted_header_auth_forbidden"
|
||||||
|
clock_skew="$(runtime_value_of CASAN_CP_JWT_CLOCK_SKEW_SECONDS)"
|
||||||
|
[[ "$clock_skew" =~ ^[0-9]+$ && "$clock_skew" -le 300 ]] || fail "runtime_jwt_clock_skew_invalid"
|
||||||
|
pass "Control Plane verifies OIDC token identity cryptographically"
|
||||||
|
|
||||||
vault_addr="$(sed -n -E 's/^VAULT_ADDR=//p' "$CASAN_CP_VAULT_ENV" | tail -1)"
|
vault_addr="$(sed -n -E 's/^VAULT_ADDR=//p' "$CASAN_CP_VAULT_ENV" | tail -1)"
|
||||||
vault_token="$(sed -n -E 's/^VAULT_TOKEN=//p' "$CASAN_CP_VAULT_ENV" | tail -1)"
|
vault_token="$(sed -n -E 's/^VAULT_TOKEN=//p' "$CASAN_CP_VAULT_ENV" | tail -1)"
|
||||||
vault_cacert="$(sed -n -E 's/^VAULT_CACERT=//p' "$CASAN_CP_VAULT_ENV" | tail -1)"
|
vault_cacert="$(sed -n -E 's/^VAULT_CACERT=//p' "$CASAN_CP_VAULT_ENV" | tail -1)"
|
||||||
@@ -88,7 +105,8 @@ COMPOSE="$ROOT/docker-compose.control-panel.yml"
|
|||||||
CASAN_CP_TLS_DIR="$CASAN_CP_TLS_DIR" CASAN_CP_OAUTH_ENV="$CASAN_CP_OAUTH_ENV" \
|
CASAN_CP_TLS_DIR="$CASAN_CP_TLS_DIR" CASAN_CP_OAUTH_ENV="$CASAN_CP_OAUTH_ENV" \
|
||||||
CASAN_CP_RUNTIME_ENV="$CASAN_CP_RUNTIME_ENV" CASAN_CP_VAULT_ENV="$CASAN_CP_VAULT_ENV" \
|
CASAN_CP_RUNTIME_ENV="$CASAN_CP_RUNTIME_ENV" CASAN_CP_VAULT_ENV="$CASAN_CP_VAULT_ENV" \
|
||||||
CASAN_CP_STATE_DIR="$CASAN_CP_STATE_DIR" CASAN_CP_OUTPUT_DIR="$CASAN_CP_OUTPUT_DIR" \
|
CASAN_CP_STATE_DIR="$CASAN_CP_STATE_DIR" CASAN_CP_OUTPUT_DIR="$CASAN_CP_OUTPUT_DIR" \
|
||||||
CASAN_CP_API_IMAGE="$CASAN_CP_API_IMAGE" CASAN_CP_UI_IMAGE="$CASAN_CP_UI_IMAGE" \
|
CASAN_CP_IDP_PUBLIC_KEY="$CASAN_CP_IDP_PUBLIC_KEY" CASAN_CP_API_IMAGE="$CASAN_CP_API_IMAGE" \
|
||||||
|
CASAN_CP_UI_IMAGE="$CASAN_CP_UI_IMAGE" CASAN_CP_OAUTH2_PROXY_IMAGE="$CASAN_CP_OAUTH2_PROXY_IMAGE" \
|
||||||
docker compose -f "$COMPOSE" config >/dev/null || fail "compose_config_invalid"
|
docker compose -f "$COMPOSE" config >/dev/null || fail "compose_config_invalid"
|
||||||
pass "production compose config"
|
pass "production compose config"
|
||||||
|
|
||||||
|
|||||||
@@ -47,10 +47,15 @@ docker info >/dev/null 2>&1 || { echo "SANDBOX_CONTAINER_DOCKER_DOWN" >&2; exit
|
|||||||
# rootful Docker daemon because a compromised daemon socket defeats container
|
# rootful Docker daemon because a compromised daemon socket defeats container
|
||||||
# isolation. Local developer/test profiles may use a rootful daemon, but cannot
|
# isolation. Local developer/test profiles may use a rootful daemon, but cannot
|
||||||
# claim that configuration as a hardened production runner.
|
# claim that configuration as a hardened production runner.
|
||||||
if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_SANDBOX_REQUIRE_ROOTLESS:-0}" == "1" ]]; then
|
if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" \
|
||||||
|
|| "${CASAN_SANDBOX_REQUIRE_ROOTLESS:-0}" == "1" ]]; then
|
||||||
docker info --format '{{json .SecurityOptions}}' 2>/dev/null | grep -q 'rootless' \
|
docker info --format '{{json .SecurityOptions}}' 2>/dev/null | grep -q 'rootless' \
|
||||||
|| { echo "SANDBOX_CONTAINER_ROOTLESS_REQUIRED" >&2; exit 2; }
|
|| { echo "SANDBOX_CONTAINER_ROOTLESS_REQUIRED" >&2; exit 2; }
|
||||||
fi
|
fi
|
||||||
|
if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" ]]; then
|
||||||
|
[[ "$IMAGE" =~ @sha256:[a-f0-9]{64}$ ]] \
|
||||||
|
|| { echo "SANDBOX_CONTAINER_IMAGE_DIGEST_REQUIRED image=$IMAGE" >&2; exit 2; }
|
||||||
|
fi
|
||||||
|
|
||||||
WS_ABS="$(cd "$WORKSPACE" 2>/dev/null && pwd)" || { echo "SANDBOX_CONTAINER_BAD_WORKSPACE" >&2; exit 2; }
|
WS_ABS="$(cd "$WORKSPACE" 2>/dev/null && pwd)" || { echo "SANDBOX_CONTAINER_BAD_WORKSPACE" >&2; exit 2; }
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,37 @@ CPU_SECONDS="${CASAN_SANDBOX_CPU_SECONDS:-30}"
|
|||||||
# check is the real gate; a container --pids-limit is the production backstop.
|
# check is the real gate; a container --pids-limit is the production backstop.
|
||||||
MAX_PROCS="${CASAN_SANDBOX_MAX_PROCS:-}"
|
MAX_PROCS="${CASAN_SANDBOX_MAX_PROCS:-}"
|
||||||
TIMEOUT="${CASAN_SANDBOX_TIMEOUT:-30}"
|
TIMEOUT="${CASAN_SANDBOX_TIMEOUT:-30}"
|
||||||
|
SANDBOX_MODE="${CASAN_SANDBOX_MODE:-}"
|
||||||
|
STRICT_SANDBOX=0
|
||||||
|
if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" \
|
||||||
|
|| "${CASAN_ENFORCEMENT_MODE:-}" == "enforce" || "${CASAN_SANDBOX_STRICT:-0}" == "1" ]]; then
|
||||||
|
STRICT_SANDBOX=1
|
||||||
|
fi
|
||||||
|
[[ -n "$SANDBOX_MODE" ]] || { if [[ "$STRICT_SANDBOX" == "1" ]]; then SANDBOX_MODE="container"; else SANDBOX_MODE="static"; fi; }
|
||||||
|
|
||||||
|
record_sandbox() { # decision reason backend capability-json
|
||||||
|
local decision="$1" reason="$2" backend="$3" capabilities="$4"
|
||||||
|
local log="$CASAN_STATE_ROOT/logs/sandbox/decisions.jsonl"
|
||||||
|
mkdir -p "$(dirname "$log")"
|
||||||
|
CASAN_SANDBOX_CAPABILITIES="$capabilities" python3 - "$log" "$decision" "$reason" "$backend" "${CASAN_EXECUTION_ID:-sandbox-$$}" <<'PY'
|
||||||
|
import json, os, sys
|
||||||
|
path, decision, reason, backend, execution_id = sys.argv[1:]
|
||||||
|
try:
|
||||||
|
capabilities = json.loads(os.environ.get("CASAN_SANDBOX_CAPABILITIES", "{}"))
|
||||||
|
except ValueError:
|
||||||
|
capabilities = {}
|
||||||
|
record = {
|
||||||
|
"schema_version": "1.0.0", "category": "runtime_control",
|
||||||
|
"policy_id": "casan.sandbox.backend", "decision": decision,
|
||||||
|
"reason_code": reason, "backend": backend, "execution_id": execution_id,
|
||||||
|
"capabilities": capabilities,
|
||||||
|
}
|
||||||
|
with open(path, "a", encoding="utf-8") as handle:
|
||||||
|
handle.write(json.dumps(record, sort_keys=True, separators=(",", ":")) + "\n")
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
while [[ "$#" -gt 0 ]]; do
|
while [[ "$#" -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
@@ -54,12 +85,35 @@ if [[ "$#" -eq 0 ]]; then
|
|||||||
exit 64
|
exit 64
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# C6 production form: CASAN_SANDBOX_MODE=container runs under TRUE kernel
|
# C6 production form: CASAN_SANDBOX_MODE=container runs under kernel-backed
|
||||||
# isolation (sandbox-container.sh: --network=none --read-only --pids-limit …).
|
# isolation (sandbox-container.sh: --network=none --read-only --pids-limit …).
|
||||||
# Default stays the static-policy + ulimit scaffold so existing behaviour is
|
# A requested/required container backend never silently falls back.
|
||||||
# unchanged. Falls back to the scaffold if Docker is unavailable.
|
if [[ "$SANDBOX_MODE" == "container" ]]; then
|
||||||
if [[ "${CASAN_SANDBOX_MODE:-static}" == "container" ]] && command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
|
if [[ "${CASAN_SANDBOX_TEST_FORCE_UNAVAILABLE:-0}" != "1" ]] \
|
||||||
exec "$SCRIPT_DIR/sandbox-container.sh" --workspace "$WORKSPACE" --timeout "$TIMEOUT" -- "$@"
|
&& command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
|
||||||
|
record_sandbox "allow" "sandbox_container_selected" "docker" '{"network_disabled":true,"read_only_root":true,"workspace_write_restricted":true,"environment_filtered":true,"non_root":true,"resource_limits":true}'
|
||||||
|
exec "$SCRIPT_DIR/sandbox-container.sh" --workspace "$WORKSPACE" --timeout "$TIMEOUT" -- "$@"
|
||||||
|
fi
|
||||||
|
if [[ "$STRICT_SANDBOX" == "1" ]]; then
|
||||||
|
record_sandbox "deny" "sandbox_isolation_backend_unavailable" "none" '{"timeout_only":false}'
|
||||||
|
echo "SANDBOX_ISOLATION_REQUIRED backend=container reason=unavailable" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ "${CASAN_SANDBOX_ALLOW_STATIC_FALLBACK:-0}" != "1" ]]; then
|
||||||
|
record_sandbox "deny" "sandbox_fallback_not_approved" "none" '{}'
|
||||||
|
echo "SANDBOX_FALLBACK_REQUIRES_EXPLICIT_DEVELOPMENT_APPROVAL" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
record_sandbox "observe_only" "sandbox_static_fallback_development_only" "static_rlimit" '{"network_disabled":false,"read_only_root":false,"workspace_write_restricted":false,"environment_filtered":false,"non_root":false,"resource_limits":true}'
|
||||||
|
echo "HIGH: container sandbox unavailable; explicit development static fallback is not production isolation" >&2
|
||||||
|
elif [[ "$SANDBOX_MODE" != "static" ]]; then
|
||||||
|
record_sandbox "deny" "sandbox_backend_unknown" "$SANDBOX_MODE" '{}'
|
||||||
|
echo "SANDBOX_BACKEND_UNKNOWN mode=$SANDBOX_MODE" >&2
|
||||||
|
exit 2
|
||||||
|
elif [[ "$STRICT_SANDBOX" == "1" ]]; then
|
||||||
|
record_sandbox "deny" "sandbox_static_forbidden_in_enforce_mode" "static_rlimit" '{"network_disabled":false,"read_only_root":false}'
|
||||||
|
echo "SANDBOX_STATIC_FORBIDDEN_IN_ENFORCE_MODE" >&2
|
||||||
|
exit 2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
CMD_STR="$*"
|
CMD_STR="$*"
|
||||||
@@ -96,6 +150,7 @@ done < <(printf '%s\n' "$CMD_STR" | grep -oE '>>?[[:space:]]*[^[:space:];|&]+' |
|
|||||||
|
|
||||||
# ── 2. Runtime rlimits + wall-clock timeout ─────────────────────────────────
|
# ── 2. Runtime rlimits + wall-clock timeout ─────────────────────────────────
|
||||||
casan_log debug sandbox "SANDBOX_RUN workspace=$WS_ABS file_kb=$MAX_FILE_KB cpu=$CPU_SECONDS procs=$MAX_PROCS timeout=$TIMEOUT"
|
casan_log debug sandbox "SANDBOX_RUN workspace=$WS_ABS file_kb=$MAX_FILE_KB cpu=$CPU_SECONDS procs=$MAX_PROCS timeout=$TIMEOUT"
|
||||||
|
record_sandbox "allow" "sandbox_static_policy_selected" "static_rlimit" '{"network_disabled":false,"read_only_root":false,"workspace_write_restricted":false,"environment_filtered":false,"non_root":false,"resource_limits":true}'
|
||||||
(
|
(
|
||||||
ulimit -f "$((MAX_FILE_KB * 2))" 2>/dev/null || true # ulimit -f is in 512-byte blocks
|
ulimit -f "$((MAX_FILE_KB * 2))" 2>/dev/null || true # ulimit -f is in 512-byte blocks
|
||||||
ulimit -t "$CPU_SECONDS" 2>/dev/null || true
|
ulimit -t "$CPU_SECONDS" 2>/dev/null || true
|
||||||
|
|||||||
@@ -45,14 +45,24 @@ with open(path, encoding="utf-8") as f:
|
|||||||
if not line.strip():
|
if not line.strip():
|
||||||
continue
|
continue
|
||||||
record = json.loads(line)
|
record = json.loads(line)
|
||||||
core = "|".join([
|
if int(record.get("schema_version", 1)) >= 2:
|
||||||
record.get("timestamp",""), record.get("trace_id",""),
|
core = "|".join([
|
||||||
record.get("action",""), record.get("actor",""),
|
record.get("timestamp",""), record.get("trace_id",""),
|
||||||
record.get("risk_level",""), record.get("decision",""),
|
record.get("action",""), record.get("action_class",""),
|
||||||
record.get("approval_status",""), record.get("approver",""),
|
record.get("actor",""), record.get("risk_level",""),
|
||||||
record.get("input_hash",""), record.get("output_hash",""),
|
json.dumps(record.get("risk_factors", {}), sort_keys=True, separators=(",", ":")),
|
||||||
previous,
|
record.get("evidence_requirement",""), record.get("decision",""),
|
||||||
])
|
record.get("approval_status",""), record.get("approver",""),
|
||||||
|
record.get("input_hash",""), record.get("output_hash",""), previous,
|
||||||
|
])
|
||||||
|
else:
|
||||||
|
core = "|".join([
|
||||||
|
record.get("timestamp",""), record.get("trace_id",""),
|
||||||
|
record.get("action",""), record.get("actor",""),
|
||||||
|
record.get("risk_level",""), record.get("decision",""),
|
||||||
|
record.get("approval_status",""), record.get("approver",""),
|
||||||
|
record.get("input_hash",""), record.get("output_hash",""), previous,
|
||||||
|
])
|
||||||
previous = hashlib.sha256(core.encode()).hexdigest()
|
previous = hashlib.sha256(core.encode()).hexdigest()
|
||||||
print(previous)
|
print(previous)
|
||||||
PY
|
PY
|
||||||
@@ -68,6 +78,10 @@ printf '%s' "$HEAD_HASH" > "$HEAD_FILE"
|
|||||||
# ── Sign the head file ────────────────────────────────────────────────────
|
# ── Sign the head file ────────────────────────────────────────────────────
|
||||||
VAULT_KMS="$SCRIPT_DIR/vault-kms.sh"
|
VAULT_KMS="$SCRIPT_DIR/vault-kms.sh"
|
||||||
|
|
||||||
|
if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" ]]; then
|
||||||
|
[[ "${VAULT_ADDR:-}" == https://* ]] || { echo "SIGN_AUDIT_HEAD_FAIL reason=vault_https_required_in_prod" >&2; exit 1; }
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ -n "${VAULT_ADDR:-}" && -n "${VAULT_TOKEN:-}" ]] && \
|
if [[ -n "${VAULT_ADDR:-}" && -n "${VAULT_TOKEN:-}" ]] && \
|
||||||
curl -sf "$VAULT_ADDR/v1/sys/health" >/dev/null 2>&1; then
|
curl -sf "$VAULT_ADDR/v1/sys/health" >/dev/null 2>&1; then
|
||||||
# KMS path — sign via Vault Transit, export public key
|
# KMS path — sign via Vault Transit, export public key
|
||||||
@@ -103,7 +117,7 @@ PY
|
|||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
# Fallback — local key (dev environment without Vault)
|
# Fallback — local key (dev environment without Vault)
|
||||||
if [[ "${CASAN_PROFILE:-}" == "prod" ]]; then
|
if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" ]]; then
|
||||||
echo "SIGN_AUDIT_HEAD_FAIL reason=vault_kms_required_in_prod" >&2
|
echo "SIGN_AUDIT_HEAD_FAIL reason=vault_kms_required_in_prod" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ FR_RE = re.compile(r"\|\s*(FR-\d+)\s*\|\s*([^|]+?)\s*\|")
|
|||||||
|
|
||||||
|
|
||||||
def project_root() -> str:
|
def project_root() -> str:
|
||||||
|
explicit = os.environ.get("CASAN_APP_ROOT")
|
||||||
|
if explicit:
|
||||||
|
return os.path.abspath(explicit)
|
||||||
# Plan-01: harness code lives in packages/casan-harness/; a fixed __file__ parent
|
# Plan-01: harness code lives in packages/casan-harness/; a fixed __file__ parent
|
||||||
# depth lands on the package, not the app. Walk UP for the `.specify` state marker
|
# depth lands on the package, not the app. Walk UP for the `.specify` state marker
|
||||||
# so this resolves the app root whether invoked via packages/... or the .specify facade.
|
# so this resolves the app root whether invoked via packages/... or the .specify facade.
|
||||||
|
|||||||
@@ -35,21 +35,28 @@ with open(path, encoding="utf-8") as f:
|
|||||||
f"AUDIT_CHAIN_BROKEN line={line_no} expected_previous={previous} actual_previous={expected_previous}"
|
f"AUDIT_CHAIN_BROKEN line={line_no} expected_previous={previous} actual_previous={expected_previous}"
|
||||||
)
|
)
|
||||||
|
|
||||||
core = "|".join(
|
if int(record.get("schema_version", 1)) >= 2:
|
||||||
[
|
core = "|".join([
|
||||||
record.get("timestamp", ""),
|
record.get("timestamp", ""), record.get("trace_id", ""),
|
||||||
record.get("trace_id", ""),
|
record.get("action", ""), record.get("action_class", ""),
|
||||||
record.get("action", ""),
|
record.get("actor", ""), record.get("risk_level", ""),
|
||||||
record.get("actor", ""),
|
json.dumps(record.get("risk_factors", {}), sort_keys=True, separators=(",", ":")),
|
||||||
record.get("risk_level", ""),
|
record.get("evidence_requirement", ""), record.get("decision", ""),
|
||||||
record.get("decision", ""),
|
record.get("approval_status", ""), record.get("approver", ""),
|
||||||
record.get("approval_status", ""),
|
record.get("input_hash", ""), record.get("output_hash", ""),
|
||||||
record.get("approver", ""),
|
|
||||||
record.get("input_hash", ""),
|
|
||||||
record.get("output_hash", ""),
|
|
||||||
expected_previous,
|
expected_previous,
|
||||||
]
|
])
|
||||||
)
|
else:
|
||||||
|
core = "|".join(
|
||||||
|
[
|
||||||
|
record.get("timestamp", ""), record.get("trace_id", ""),
|
||||||
|
record.get("action", ""), record.get("actor", ""),
|
||||||
|
record.get("risk_level", ""), record.get("decision", ""),
|
||||||
|
record.get("approval_status", ""), record.get("approver", ""),
|
||||||
|
record.get("input_hash", ""), record.get("output_hash", ""),
|
||||||
|
expected_previous,
|
||||||
|
]
|
||||||
|
)
|
||||||
expected_hash = hashlib.sha256(core.encode()).hexdigest()
|
expected_hash = hashlib.sha256(core.encode()).hexdigest()
|
||||||
actual_hash = record.get("record_hash", "")
|
actual_hash = record.get("record_hash", "")
|
||||||
if expected_hash != actual_hash:
|
if expected_hash != actual_hash:
|
||||||
|
|||||||
@@ -40,8 +40,23 @@ import sys
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
SCHEMA_VERSION = "20.1"
|
HARNESS_PACKAGE_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
ADAPTER_DEFAULT_VERSION = "20.1.0"
|
if HARNESS_PACKAGE_ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, HARNESS_PACKAGE_ROOT)
|
||||||
|
|
||||||
|
from kernel.adapters import AgenticBridgeAdapter
|
||||||
|
from kernel.contracts import validate_bundle
|
||||||
|
from kernel.policy import (
|
||||||
|
H2GateContext,
|
||||||
|
evaluate_failure_policy,
|
||||||
|
evaluate_h2_gate,
|
||||||
|
evaluate_registry_configuration,
|
||||||
|
evaluate_risk,
|
||||||
|
evaluate_trust_capabilities,
|
||||||
|
)
|
||||||
|
|
||||||
|
SCHEMA_VERSION = "20.2"
|
||||||
|
ADAPTER_DEFAULT_VERSION = "20.2.0"
|
||||||
|
|
||||||
# ── Certification strength ranking (higher == stronger) ──────────────────────
|
# ── Certification strength ranking (higher == stronger) ──────────────────────
|
||||||
STRENGTH_RANK = {
|
STRENGTH_RANK = {
|
||||||
@@ -77,6 +92,20 @@ TOOL_REGISTRY_ACTION = {
|
|||||||
"str_replace_editor": "write_file",
|
"str_replace_editor": "write_file",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
REGISTRY_ACTION_BY_CLASS = {
|
||||||
|
"write": "write_file",
|
||||||
|
"delete": "delete_file",
|
||||||
|
"database_mutation": "db_write",
|
||||||
|
"migration": "migration",
|
||||||
|
"deployment": "deploy",
|
||||||
|
"release": "deploy",
|
||||||
|
"credential_access": "credential_access",
|
||||||
|
"identity_permission_modification": "identity_change",
|
||||||
|
"external_network_side_effect": "external_api",
|
||||||
|
"infrastructure_modification": "deploy",
|
||||||
|
"unknown": "unknown_tool",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Path resolution (mirrors scripts/bash/casan-paths.sh so state lands in the
|
# Path resolution (mirrors scripts/bash/casan-paths.sh so state lands in the
|
||||||
@@ -145,6 +174,12 @@ def trace_event_dir():
|
|||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def kernel_trace_dir():
|
||||||
|
d = os.path.join(state_root(), "logs", "kernel")
|
||||||
|
os.makedirs(d, exist_ok=True)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
def metrics_log():
|
def metrics_log():
|
||||||
override = os.environ.get("CASAN_TELEMETRY_METRICS_LOG") or os.environ.get(
|
override = os.environ.get("CASAN_TELEMETRY_METRICS_LOG") or os.environ.get(
|
||||||
"CASAN_METRICS_LOG"
|
"CASAN_METRICS_LOG"
|
||||||
@@ -612,28 +647,52 @@ def h4_scan(text, mode="input"):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def h2_registry_gate(action, idempotency_key):
|
def h2_registry_gate(action, tool, actor, execution_id, idempotency_key):
|
||||||
"""H2 tool-registry gate for a mapped side-effect action."""
|
"""Return a structured H2 policy decision for every dependency outcome."""
|
||||||
script = os.path.join(gates_dir(), "tool-registry-gate.sh")
|
script = os.environ.get("CASAN_H2_GATE_PATH") or os.path.join(
|
||||||
if not os.path.exists(script) or not bash_available():
|
gates_dir(), "tool-registry-gate.sh")
|
||||||
return True, "h2_gate_missing"
|
context = H2GateContext(
|
||||||
env = dict(os.environ)
|
mode=enforcement_mode(),
|
||||||
env["CASAN_IDEMPOTENCY_KEY"] = idempotency_key
|
actor=actor or "",
|
||||||
|
action=action,
|
||||||
|
tool=tool or action,
|
||||||
|
execution_id=execution_id,
|
||||||
|
enforcement_path="agentic_bridge.pre_tool.h2_registry",
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
timeout_seconds=internal_timeout(),
|
||||||
|
)
|
||||||
|
return evaluate_h2_gate(
|
||||||
|
script,
|
||||||
|
context,
|
||||||
|
env=os.environ,
|
||||||
|
bash_path=bash_bin(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def h5_governance_gate(action, content):
|
||||||
|
"""Invoke the existing H5 gate for action-floor approval decisions."""
|
||||||
|
script = os.path.join(gates_dir(), "governance-check.sh")
|
||||||
|
if not os.path.isfile(script) or not bash_available():
|
||||||
|
return {"approved": False, "reason_code": "h5_gate_unavailable", "detail": "governance gate unavailable"}
|
||||||
|
tmpin = os.path.join(sessions_dir(), ".h5-in-%s" % uuid.uuid4().hex[:8])
|
||||||
|
tmpout = os.path.join(sessions_dir(), ".h5-out-%s" % uuid.uuid4().hex[:8])
|
||||||
try:
|
try:
|
||||||
proc = subprocess.run(
|
with open(tmpin, "w", encoding="utf-8") as handle:
|
||||||
[bash_bin(), script, action],
|
handle.write(content or "")
|
||||||
stdout=subprocess.PIPE,
|
rc, stdout, stderr = _run_gate([bash_bin(), script, tmpin, tmpout, action])
|
||||||
stderr=subprocess.PIPE,
|
if rc == 0 and "GOVERNANCE_APPROVED" in stdout:
|
||||||
timeout=internal_timeout(),
|
return {"approved": True, "reason_code": "h5_approval_verified", "detail": stdout.strip()}
|
||||||
env=env,
|
if rc == 124:
|
||||||
)
|
return {"approved": False, "reason_code": "h5_gate_timeout", "detail": "governance gate timed out"}
|
||||||
if proc.returncode == 0:
|
if rc == 125:
|
||||||
return True, "h2_ok"
|
return {"approved": False, "reason_code": "h5_gate_internal_error", "detail": stderr}
|
||||||
return False, "h2_denied"
|
return {"approved": False, "reason_code": "h5_approval_required", "detail": stderr or stdout}
|
||||||
except subprocess.TimeoutExpired:
|
finally:
|
||||||
return False, "h2_internal_timeout"
|
for path in (tmpin, tmpout):
|
||||||
except (OSError, ValueError) as exc:
|
try:
|
||||||
return False, "h2_error:%s" % exc
|
os.unlink(path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -692,6 +751,8 @@ def write_trace_events(rec, evidence):
|
|||||||
"kind": evidence.get("kind"),
|
"kind": evidence.get("kind"),
|
||||||
"decision": evidence.get("decision"),
|
"decision": evidence.get("decision"),
|
||||||
"detail": evidence.get("detail"),
|
"detail": evidence.get("detail"),
|
||||||
|
"category": evidence.get("category"),
|
||||||
|
"facts": evidence.get("facts", {}),
|
||||||
"certification_strength": rec.get(
|
"certification_strength": rec.get(
|
||||||
"certification_strength"),
|
"certification_strength"),
|
||||||
},
|
},
|
||||||
@@ -700,13 +761,16 @@ def write_trace_events(rec, evidence):
|
|||||||
event, ensure_ascii=False, separators=(",", ":")) + "\n")
|
event, ensure_ascii=False, separators=(",", ":")) + "\n")
|
||||||
|
|
||||||
|
|
||||||
def add_evidence(rec, h, kind, decision, detail):
|
def add_evidence(rec, h, kind, decision, detail, facts=None, category="runtime_control"):
|
||||||
evidence = {
|
evidence = {
|
||||||
|
"evidence_id": "%s:%s" % (rec.get("trace_id", "trace"), len(rec.get("evidence", [])) + 1),
|
||||||
"h": h,
|
"h": h,
|
||||||
|
"category": category,
|
||||||
"kind": kind,
|
"kind": kind,
|
||||||
"decision": decision,
|
"decision": decision,
|
||||||
"at": now_iso(),
|
"at": now_iso(),
|
||||||
"detail": redact(detail, 160),
|
"detail": redact(detail, 160),
|
||||||
|
"facts": facts if isinstance(facts, dict) else {},
|
||||||
}
|
}
|
||||||
rec.setdefault("evidence", []).append(evidence)
|
rec.setdefault("evidence", []).append(evidence)
|
||||||
write_trace_events(rec, evidence)
|
write_trace_events(rec, evidence)
|
||||||
@@ -828,6 +892,27 @@ def write_h6_record(rec, status, quality, warnings, missing):
|
|||||||
return record
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def write_kernel_bundle(rec):
|
||||||
|
"""Dual-emit the canonical contract without changing legacy trace readers."""
|
||||||
|
event = dict(rec)
|
||||||
|
event.update({
|
||||||
|
"mode": enforcement_mode(),
|
||||||
|
"execution_status": rec.get("execution_status", "unknown"),
|
||||||
|
"assurance_status": rec.get("assurance_status", "unknown"),
|
||||||
|
"environment": os.environ.get("CASAN_PROFILE", "development"),
|
||||||
|
"completed_at": rec.get("finalized_at") or now_iso(),
|
||||||
|
"policy_decisions": rec.get("policy_decisions", []),
|
||||||
|
})
|
||||||
|
bundle = AgenticBridgeAdapter().map_execution(event)
|
||||||
|
errors = validate_bundle(bundle)
|
||||||
|
if errors:
|
||||||
|
raise ValueError("kernel_contract_invalid:%s" % ",".join(errors))
|
||||||
|
path = os.path.join(kernel_trace_dir(), "%s.json" % rec.get("trace_id"))
|
||||||
|
atomic_write_json(path, bundle)
|
||||||
|
rec["kernel_contract_ref"] = path
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
def write_trace_file(rec, certified, reasons):
|
def write_trace_file(rec, certified, reasons):
|
||||||
path = os.path.join(trace_dir(), "agentic-%s.json" % rec.get("trace_id"))
|
path = os.path.join(trace_dir(), "agentic-%s.json" % rec.get("trace_id"))
|
||||||
payload = {
|
payload = {
|
||||||
@@ -851,6 +936,9 @@ def write_trace_file(rec, certified, reasons):
|
|||||||
"failures": rec.get("failures", 0),
|
"failures": rec.get("failures", 0),
|
||||||
"certified": certified,
|
"certified": certified,
|
||||||
"certification_reasons": reasons,
|
"certification_reasons": reasons,
|
||||||
|
"execution_outcome": rec.get("execution_status"),
|
||||||
|
"assurance_outcome": rec.get("assurance_status"),
|
||||||
|
"kernel_contract_ref": rec.get("kernel_contract_ref"),
|
||||||
"evidence": rec.get("evidence", []),
|
"evidence": rec.get("evidence", []),
|
||||||
}
|
}
|
||||||
atomic_write_json(path, payload)
|
atomic_write_json(path, payload)
|
||||||
@@ -886,6 +974,8 @@ def op_begin(req):
|
|||||||
"client": client,
|
"client": client,
|
||||||
"client_version": req.get("client_version"),
|
"client_version": req.get("client_version"),
|
||||||
"adapter_version": req.get("adapter_version") or ADAPTER_DEFAULT_VERSION,
|
"adapter_version": req.get("adapter_version") or ADAPTER_DEFAULT_VERSION,
|
||||||
|
"actor": req.get("actor") or os.environ.get("CASAN_ACTOR") or os.environ.get("CASAN_AGENT") or "",
|
||||||
|
"mode": enforcement_mode(),
|
||||||
"project_root": project,
|
"project_root": project,
|
||||||
"project_id": project_id(project),
|
"project_id": project_id(project),
|
||||||
"session_id_hash": salted_hash(req.get("session")),
|
"session_id_hash": salted_hash(req.get("session")),
|
||||||
@@ -900,8 +990,10 @@ def op_begin(req):
|
|||||||
"state": "Admitted",
|
"state": "Admitted",
|
||||||
"tool_calls": 0,
|
"tool_calls": 0,
|
||||||
"failures": 0,
|
"failures": 0,
|
||||||
|
"side_effect_failures": 0,
|
||||||
"retries": 0,
|
"retries": 0,
|
||||||
"evidence": [],
|
"evidence": [],
|
||||||
|
"policy_decisions": [],
|
||||||
"telemetry": {},
|
"telemetry": {},
|
||||||
"bypass_signal": False,
|
"bypass_signal": False,
|
||||||
"finalized": False,
|
"finalized": False,
|
||||||
@@ -1012,30 +1104,109 @@ def op_pre_tool(req):
|
|||||||
# record the degradation but do not block the developer.
|
# record the degradation but do not block the developer.
|
||||||
add_evidence(rec, "H4", "pre-tool", "degraded", "%s:gate_unavailable_no_bash" % tool)
|
add_evidence(rec, "H4", "pre-tool", "degraded", "%s:gate_unavailable_no_bash" % tool)
|
||||||
|
|
||||||
# H2 registry gate for mapped side-effect actions. Opt-in via
|
tool_input_string = tool_input_text if isinstance(tool_input_text, str) else json.dumps(
|
||||||
# CASAN_AGENTIC_H2_REGISTRY=1: the tool-registry is keyed on NAMED CASAN
|
tool_input_text, ensure_ascii=False, sort_keys=True) if tool_input_text is not None else ""
|
||||||
# agent identities, which the transparent developer flow does not carry, so
|
# Native hook payloads commonly wrap a shell command in {"command": ...}.
|
||||||
# enabling it unconditionally would deny every write. The always-on H2
|
# Classify the actual command while retaining the complete serialized input
|
||||||
# equivalent for this flow is the admission gate above (a side effect
|
# for scanning, governance evidence and hashing.
|
||||||
# without a valid admission is denied). Managed deployments that define
|
risk_command = tool_input_string
|
||||||
# agent identities can turn the registry gate on for defence in depth.
|
if isinstance(tool_input_text, dict) and isinstance(tool_input_text.get("command"), str):
|
||||||
registry_on = os.environ.get("CASAN_AGENTIC_H2_REGISTRY", "0") in ("1", "true", "yes")
|
risk_command = tool_input_text["command"]
|
||||||
if side_effect and registry_on and enforcement_mode() == "enforce":
|
risk = evaluate_risk(
|
||||||
action = TOOL_REGISTRY_ACTION.get((tool or "").strip().lower())
|
action=str(req.get("action") or ""),
|
||||||
if action:
|
tool=tool,
|
||||||
key = plain_hash("%s|%s|%s" % (rec["trace_id"], tool, redact(tool_input_text)))[:24]
|
resource=str(req.get("resource") or ""),
|
||||||
ok, reason = h2_registry_gate(action, key)
|
command=risk_command,
|
||||||
if not ok:
|
content=tool_input_string,
|
||||||
add_evidence(rec, "H2", "pre-tool", "deny", "%s:%s" % (tool, reason))
|
actor=rec.get("actor", ""),
|
||||||
save_admission(rec)
|
environment=os.environ.get("CASAN_PROFILE", "development"),
|
||||||
return _base_response("pre-tool", "deny", admission_id=admission_id,
|
)
|
||||||
trace_id=rec.get("trace_id"), reason=reason)
|
# The canonical classifier can prove a shell command read-only; unknown
|
||||||
|
# shell commands remain side-effecting/high-risk by construction.
|
||||||
|
side_effect = bool(risk["side_effecting"])
|
||||||
|
action = REGISTRY_ACTION_BY_CLASS.get(risk["action_class"], TOOL_REGISTRY_ACTION.get((tool or "").strip().lower(), "unknown_tool"))
|
||||||
|
rec["last_tool"] = tool
|
||||||
|
rec["last_action"] = action
|
||||||
|
rec["last_risk"] = risk
|
||||||
|
rec.setdefault("policy_decisions", []).append(risk)
|
||||||
|
add_evidence(
|
||||||
|
rec, "H5", "action-risk", risk["decision"],
|
||||||
|
"class=%s effective=%s" % (risk["action_class"], risk["effective_risk"]),
|
||||||
|
facts=risk,
|
||||||
|
)
|
||||||
|
|
||||||
|
if side_effect and risk["decision"] == "deny":
|
||||||
|
reason = risk["reason_codes"][-1]
|
||||||
|
add_evidence(rec, "H5", "pre-tool", "deny", reason, facts=risk)
|
||||||
|
save_admission(rec)
|
||||||
|
return _base_response("pre-tool", "deny", admission_id=admission_id,
|
||||||
|
trace_id=rec.get("trace_id"), reason=reason,
|
||||||
|
policy_decision=risk)
|
||||||
|
|
||||||
|
if side_effect and risk["decision"] == "require_approval":
|
||||||
|
approval = h5_governance_gate(action, tool_input_string)
|
||||||
|
add_evidence(
|
||||||
|
rec, "H5", "approval", "allow" if approval["approved"] else "require_approval",
|
||||||
|
approval["reason_code"], facts={"risk": risk, "approval": approval},
|
||||||
|
)
|
||||||
|
if not approval["approved"]:
|
||||||
|
save_admission(rec)
|
||||||
|
return _base_response("pre-tool", "require_approval", admission_id=admission_id,
|
||||||
|
trace_id=rec.get("trace_id"), reason=approval["reason_code"],
|
||||||
|
policy_decision=risk)
|
||||||
|
|
||||||
|
registry = evaluate_registry_configuration(
|
||||||
|
enforcement_mode(),
|
||||||
|
os.environ.get("CASAN_PROFILE", "development"),
|
||||||
|
os.environ.get("CASAN_AGENTIC_H2_REGISTRY") if "CASAN_AGENTIC_H2_REGISTRY" in os.environ else None,
|
||||||
|
)
|
||||||
|
if side_effect and not registry["valid"]:
|
||||||
|
add_evidence(rec, "H2", "registry-configuration", "deny", registry["reason_code"], facts=registry)
|
||||||
|
save_admission(rec)
|
||||||
|
return _base_response("pre-tool", "deny", admission_id=admission_id,
|
||||||
|
trace_id=rec.get("trace_id"), reason=registry["reason_code"])
|
||||||
|
if side_effect and registry["unsafe_bypass"]:
|
||||||
|
rec["integration_mode"] = "observed_only"
|
||||||
|
rec["certification_strength"] = "observed_only"
|
||||||
|
add_evidence(rec, "H2", "registry-configuration", "degraded", registry["reason_code"], facts=registry)
|
||||||
|
|
||||||
|
if side_effect and registry["enabled"]:
|
||||||
|
key = plain_hash("%s|%s|%s" % (rec["trace_id"], tool, redact(tool_input_text)))[:24]
|
||||||
|
h2_decision = h2_registry_gate(action, tool, rec.get("actor", ""), rec["trace_id"], key)
|
||||||
|
rec.setdefault("policy_decisions", []).append(h2_decision)
|
||||||
|
add_evidence(
|
||||||
|
rec, "H2", "tool-registry", h2_decision["decision"],
|
||||||
|
h2_decision["reason_code"], facts=h2_decision,
|
||||||
|
)
|
||||||
|
if not h2_decision["execution_allowed"]:
|
||||||
|
save_admission(rec)
|
||||||
|
return _base_response("pre-tool", "deny", admission_id=admission_id,
|
||||||
|
trace_id=rec.get("trace_id"), reason=h2_decision["reason_code"],
|
||||||
|
policy_decision=h2_decision)
|
||||||
|
if not h2_decision["certifiable"]:
|
||||||
|
rec["integration_mode"] = "observed_only"
|
||||||
|
rec["certification_strength"] = "observed_only"
|
||||||
|
|
||||||
add_evidence(rec, "H2/H4", "pre-tool", "allow",
|
add_evidence(rec, "H2/H4", "pre-tool", "allow",
|
||||||
"tool=%s side_effect=%s" % (tool, side_effect))
|
"tool=%s side_effect=%s action=%s" % (tool, side_effect, action),
|
||||||
|
facts={"action_risk": risk, "registry": registry})
|
||||||
save_admission(rec)
|
save_admission(rec)
|
||||||
return _base_response("pre-tool", "allow", admission_id=admission_id,
|
response = _base_response("pre-tool", "allow", admission_id=admission_id,
|
||||||
trace_id=rec.get("trace_id"), reason="allowed")
|
trace_id=rec.get("trace_id"), reason="allowed",
|
||||||
|
policy_decision=risk)
|
||||||
|
if registry["unsafe_bypass"]:
|
||||||
|
response["warnings"].append("HIGH: H2 registry bypass active in development/test; execution is not certifiable")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def single_step_failure_policy(side_effect):
|
||||||
|
decision = evaluate_failure_policy(
|
||||||
|
side_effecting=side_effect,
|
||||||
|
mode=enforcement_mode(),
|
||||||
|
profile=os.environ.get("CASAN_PROFILE", "development"),
|
||||||
|
configured=os.environ.get("CASAN_SINGLE_STEP_FAILURE_POLICY"),
|
||||||
|
)
|
||||||
|
return decision["failure_policy"], decision["source"]
|
||||||
|
|
||||||
|
|
||||||
def op_post_tool(req):
|
def op_post_tool(req):
|
||||||
@@ -1045,11 +1216,52 @@ def op_post_tool(req):
|
|||||||
reason="no_admission")
|
reason="no_admission")
|
||||||
rec["tool_calls"] = rec.get("tool_calls", 0) + 1
|
rec["tool_calls"] = rec.get("tool_calls", 0) + 1
|
||||||
status = req.get("status") or "success"
|
status = req.get("status") or "success"
|
||||||
if status in ("error", "timeout"):
|
risk = evaluate_risk(
|
||||||
|
action=str(rec.get("last_action") or ""),
|
||||||
|
tool=str(req.get("tool") or rec.get("last_tool") or ""),
|
||||||
|
command=str(req.get("command") or ""),
|
||||||
|
actor=rec.get("actor", ""),
|
||||||
|
environment=os.environ.get("CASAN_PROFILE", "development"),
|
||||||
|
)
|
||||||
|
side_effect = bool(risk["side_effecting"])
|
||||||
|
if status in ("error", "timeout", "denied", "failed"):
|
||||||
rec["failures"] = rec.get("failures", 0) + 1
|
rec["failures"] = rec.get("failures", 0) + 1
|
||||||
|
if side_effect:
|
||||||
|
rec["side_effect_failures"] = rec.get("side_effect_failures", 0) + 1
|
||||||
|
policy, source = single_step_failure_policy(side_effect)
|
||||||
|
rec["failure_policy"] = policy
|
||||||
|
rec["execution_status"] = "failed"
|
||||||
|
rec["assurance_status"] = "failed"
|
||||||
|
add_evidence(
|
||||||
|
rec, "H6", "failure-policy", policy,
|
||||||
|
"tool=%s status=%s policy=%s" % (req.get("tool"), status, policy),
|
||||||
|
facts={
|
||||||
|
"command_executed": status not in ("denied",),
|
||||||
|
"command_outcome": status,
|
||||||
|
"telemetry_recorded": True,
|
||||||
|
"assurance_may_continue": policy == "record_only",
|
||||||
|
"failure_policy": policy,
|
||||||
|
"policy_source": source,
|
||||||
|
"side_effecting": side_effect,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
add_evidence(rec, "H5", "post-tool", status,
|
||||||
|
"tool=%s dur_ms=%s result=%s" % (req.get("tool"), req.get("duration_ms"),
|
||||||
|
redact(req.get("result"), 80)),
|
||||||
|
facts={"risk": risk, "failure_policy": policy})
|
||||||
|
save_admission(rec)
|
||||||
|
decision = "recorded" if policy == "record_only" else policy
|
||||||
|
return _base_response(
|
||||||
|
"post-tool", decision, admission_id=rec["admission_id"],
|
||||||
|
trace_id=rec.get("trace_id"), reason="tool_failed:%s" % policy,
|
||||||
|
execution_outcome="failed", assurance_outcome="failed",
|
||||||
|
assurance_may_continue=policy == "record_only",
|
||||||
|
)
|
||||||
|
rec["execution_status"] = rec.get("execution_status") or "success"
|
||||||
add_evidence(rec, "H5", "post-tool", status,
|
add_evidence(rec, "H5", "post-tool", status,
|
||||||
"tool=%s dur_ms=%s result=%s" % (req.get("tool"), req.get("duration_ms"),
|
"tool=%s dur_ms=%s result=%s" % (req.get("tool"), req.get("duration_ms"),
|
||||||
redact(req.get("result"), 80)))
|
redact(req.get("result"), 80)),
|
||||||
|
facts={"risk": risk, "command_outcome": status, "telemetry_recorded": True})
|
||||||
save_admission(rec)
|
save_admission(rec)
|
||||||
return _base_response("post-tool", "recorded", admission_id=rec["admission_id"],
|
return _base_response("post-tool", "recorded", admission_id=rec["admission_id"],
|
||||||
trace_id=rec.get("trace_id"), reason="evidence_appended")
|
trace_id=rec.get("trace_id"), reason="evidence_appended")
|
||||||
@@ -1092,6 +1304,10 @@ def op_finalize(req):
|
|||||||
rec["summary_hash"] = salted_hash(req.get("assistant_summary"))
|
rec["summary_hash"] = salted_hash(req.get("assistant_summary"))
|
||||||
stop_reason = req.get("stop_reason") or "completed"
|
stop_reason = req.get("stop_reason") or "completed"
|
||||||
status = "success" if stop_reason in ("completed", "max_turns") else "failed"
|
status = "success" if stop_reason in ("completed", "max_turns") else "failed"
|
||||||
|
if rec.get("failures", 0) > 0:
|
||||||
|
# Telemetry success is not execution success. A failed tool remains a
|
||||||
|
# failed execution even if the client later emits Stop(completed).
|
||||||
|
status = "failed"
|
||||||
|
|
||||||
observed_harnesses = {
|
observed_harnesses = {
|
||||||
harness
|
harness
|
||||||
@@ -1110,6 +1326,8 @@ def op_finalize(req):
|
|||||||
# H3/H5/H7 finalize controls: run the H4 output filter over the assistant
|
# H3/H5/H7 finalize controls: run the H4 output filter over the assistant
|
||||||
# summary as the closing verification control.
|
# summary as the closing verification control.
|
||||||
reasons = []
|
reasons = []
|
||||||
|
if rec.get("failures", 0) > 0:
|
||||||
|
reasons.append("tool_execution_failed")
|
||||||
if req.get("assistant_summary"):
|
if req.get("assistant_summary"):
|
||||||
h4 = h4_scan(req.get("assistant_summary"), "output")
|
h4 = h4_scan(req.get("assistant_summary"), "output")
|
||||||
if h4 == "ok":
|
if h4 == "ok":
|
||||||
@@ -1158,7 +1376,17 @@ def op_finalize(req):
|
|||||||
reasons.append("coverage_bypass")
|
reasons.append("coverage_bypass")
|
||||||
if status != "success":
|
if status != "success":
|
||||||
certified = False
|
certified = False
|
||||||
reasons.append("stop_%s" % stop_reason)
|
failure_reason = "failed_tool_outcome" if rec.get("failures", 0) > 0 else "stop_%s" % stop_reason
|
||||||
|
if failure_reason not in reasons:
|
||||||
|
reasons.append(failure_reason)
|
||||||
|
trust = evaluate_trust_capabilities()
|
||||||
|
add_evidence(
|
||||||
|
rec, "H7", "trust-capability", "pass" if trust["certifiable"] else "degraded",
|
||||||
|
",".join(trust["reason_codes"]), facts=trust, category="readiness_check",
|
||||||
|
)
|
||||||
|
if trust["production"] and not trust["certifiable"]:
|
||||||
|
certified = False
|
||||||
|
reasons.extend(reason for reason in trust["reason_codes"] if reason not in reasons)
|
||||||
if not certified and not reasons:
|
if not certified and not reasons:
|
||||||
reasons.append("unknown")
|
reasons.append("unknown")
|
||||||
if certified:
|
if certified:
|
||||||
@@ -1168,12 +1396,15 @@ def op_finalize(req):
|
|||||||
rec["certified"] = certified
|
rec["certified"] = certified
|
||||||
rec["finalized"] = True
|
rec["finalized"] = True
|
||||||
rec["finalized_at"] = now_iso()
|
rec["finalized_at"] = now_iso()
|
||||||
|
rec["execution_status"] = status
|
||||||
|
rec["assurance_status"] = "passed" if certified else "failed" if rec.get("failures", 0) else "degraded"
|
||||||
add_evidence(
|
add_evidence(
|
||||||
rec,
|
rec,
|
||||||
"H7",
|
"H7",
|
||||||
"certification",
|
"certification",
|
||||||
"certified" if certified else "non_certified",
|
"certified" if certified else "non_certified",
|
||||||
",".join(reasons),
|
",".join(reasons),
|
||||||
|
category="certification_claim",
|
||||||
)
|
)
|
||||||
|
|
||||||
quality, warnings, missing = classify_telemetry(rec)
|
quality, warnings, missing = classify_telemetry(rec)
|
||||||
@@ -1185,13 +1416,26 @@ def op_finalize(req):
|
|||||||
",".join(warnings) if warnings else "provider_usage_complete",
|
",".join(warnings) if warnings else "provider_usage_complete",
|
||||||
)
|
)
|
||||||
h6_record = write_h6_record(rec, status, quality, warnings, missing)
|
h6_record = write_h6_record(rec, status, quality, warnings, missing)
|
||||||
|
try:
|
||||||
|
write_kernel_bundle(rec)
|
||||||
|
except (OSError, ValueError, TypeError) as exc:
|
||||||
|
certified = False
|
||||||
|
rec["certified"] = False
|
||||||
|
rec["state"] = "NonCertified"
|
||||||
|
rec["assurance_status"] = "failed"
|
||||||
|
if "kernel_contract_emission_failed" not in reasons:
|
||||||
|
reasons.append("kernel_contract_emission_failed")
|
||||||
|
add_evidence(rec, "H7", "kernel-contract", "failed",
|
||||||
|
"kernel_contract_emission_failed:%s" % type(exc).__name__)
|
||||||
trace_path = write_trace_file(rec, certified, reasons)
|
trace_path = write_trace_file(rec, certified, reasons)
|
||||||
save_admission(rec)
|
save_admission(rec)
|
||||||
|
|
||||||
resp = _base_response("finalize", "certified" if certified else "non_certified",
|
resp = _base_response("finalize", "certified" if certified else "non_certified",
|
||||||
admission_id=rec["admission_id"], trace_id=rec.get("trace_id"),
|
admission_id=rec["admission_id"], trace_id=rec.get("trace_id"),
|
||||||
certification_strength=strength, telemetry_quality=quality,
|
certification_strength=strength, telemetry_quality=quality,
|
||||||
reason=",".join(reasons))
|
reason=",".join(reasons), execution_outcome=status,
|
||||||
|
assurance_outcome=rec.get("assurance_status"),
|
||||||
|
certification_outcome="certified" if certified else "non_certified")
|
||||||
resp["warnings"].extend(warnings)
|
resp["warnings"].extend(warnings)
|
||||||
resp["context"] = "trace=%s certified=%s" % (os.path.basename(trace_path), certified)
|
resp["context"] = "trace=%s certified=%s" % (os.path.basename(trace_path), certified)
|
||||||
report_url = dashboard_url(rec.get("trace_id"))
|
report_url = dashboard_url(rec.get("trace_id"))
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""CLI boundary for shell/native runtimes to consume Assurance Kernel policy."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HARNESS_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(HARNESS_ROOT))
|
||||||
|
|
||||||
|
from kernel.adapters import NativeHarnessAdapter # noqa: E402
|
||||||
|
from kernel.contracts import validate_bundle # noqa: E402
|
||||||
|
from kernel.policy import ( # noqa: E402
|
||||||
|
H2GateContext,
|
||||||
|
evaluate_h2_gate,
|
||||||
|
evaluate_registry_configuration,
|
||||||
|
evaluate_risk,
|
||||||
|
evaluate_trust_capabilities,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _print(payload: object) -> None:
|
||||||
|
print(json.dumps(payload, sort_keys=True, separators=(",", ":")))
|
||||||
|
|
||||||
|
|
||||||
|
def _read_content(path: str | None) -> str:
|
||||||
|
if not path:
|
||||||
|
return ""
|
||||||
|
with open(path, encoding="utf-8") as handle:
|
||||||
|
return handle.read()
|
||||||
|
|
||||||
|
|
||||||
|
def _append_jsonl(path: str, payload: object) -> None:
|
||||||
|
target = Path(path)
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
data = (json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
|
||||||
|
fd = os.open(str(target), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
import fcntl
|
||||||
|
fcntl.flock(fd, fcntl.LOCK_EX)
|
||||||
|
except (ImportError, OSError):
|
||||||
|
pass
|
||||||
|
os.write(fd, data)
|
||||||
|
os.fsync(fd)
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_risk(args: argparse.Namespace) -> int:
|
||||||
|
payload = evaluate_risk(
|
||||||
|
action=args.action,
|
||||||
|
tool=args.tool,
|
||||||
|
resource=args.resource,
|
||||||
|
command=args.command,
|
||||||
|
content=_read_content(args.content_file),
|
||||||
|
actor=args.actor,
|
||||||
|
environment=args.environment,
|
||||||
|
)
|
||||||
|
_print(payload)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_registry(args: argparse.Namespace) -> int:
|
||||||
|
payload = evaluate_registry_configuration(args.mode, args.profile, args.explicit)
|
||||||
|
if args.evidence_log:
|
||||||
|
_append_jsonl(args.evidence_log, payload)
|
||||||
|
_print(payload)
|
||||||
|
return 0 if payload["valid"] else 2
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_h2(args: argparse.Namespace) -> int:
|
||||||
|
context = H2GateContext(
|
||||||
|
mode=args.mode,
|
||||||
|
actor=args.actor,
|
||||||
|
action=args.action,
|
||||||
|
tool=args.tool,
|
||||||
|
execution_id=args.execution_id,
|
||||||
|
enforcement_path=args.enforcement_path,
|
||||||
|
idempotency_key=args.idempotency_key,
|
||||||
|
timeout_seconds=args.timeout,
|
||||||
|
)
|
||||||
|
payload = evaluate_h2_gate(args.gate, context, bash_path=args.bash)
|
||||||
|
if args.evidence_log:
|
||||||
|
_append_jsonl(args.evidence_log, payload)
|
||||||
|
_print(payload)
|
||||||
|
return 0 if payload["execution_allowed"] else 2
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_trust(_args: argparse.Namespace) -> int:
|
||||||
|
payload = evaluate_trust_capabilities()
|
||||||
|
_print(payload)
|
||||||
|
return 0 if payload["ready"] else 2
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_native(args: argparse.Namespace) -> int:
|
||||||
|
event = json.load(sys.stdin) if args.event_file == "-" else json.load(open(args.event_file, encoding="utf-8"))
|
||||||
|
payload = NativeHarnessAdapter().map_execution(event)
|
||||||
|
errors = validate_bundle(payload)
|
||||||
|
if errors:
|
||||||
|
_print({"valid": False, "errors": errors, "bundle": payload})
|
||||||
|
return 2
|
||||||
|
_print(payload)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def parser() -> argparse.ArgumentParser:
|
||||||
|
root = argparse.ArgumentParser()
|
||||||
|
sub = root.add_subparsers(dest="command", required=True)
|
||||||
|
risk = sub.add_parser("risk")
|
||||||
|
for name in ("action", "tool", "resource", "command", "actor", "environment"):
|
||||||
|
risk.add_argument(f"--{name}", default="")
|
||||||
|
risk.add_argument("--content-file")
|
||||||
|
risk.set_defaults(func=cmd_risk)
|
||||||
|
|
||||||
|
registry = sub.add_parser("registry-config")
|
||||||
|
registry.add_argument("--mode", default="observe")
|
||||||
|
registry.add_argument("--profile", default="development")
|
||||||
|
registry.add_argument("--explicit")
|
||||||
|
registry.add_argument("--evidence-log")
|
||||||
|
registry.set_defaults(func=cmd_registry)
|
||||||
|
|
||||||
|
h2 = sub.add_parser("h2-gate")
|
||||||
|
h2.add_argument("--gate", required=True)
|
||||||
|
h2.add_argument("--mode", choices=("observe", "enforce"), required=True)
|
||||||
|
h2.add_argument("--actor", default="")
|
||||||
|
h2.add_argument("--action", required=True)
|
||||||
|
h2.add_argument("--tool", required=True)
|
||||||
|
h2.add_argument("--execution-id", required=True)
|
||||||
|
h2.add_argument("--enforcement-path", required=True)
|
||||||
|
h2.add_argument("--idempotency-key", required=True)
|
||||||
|
h2.add_argument("--timeout", type=float, default=8.0)
|
||||||
|
h2.add_argument("--bash", default="bash")
|
||||||
|
h2.add_argument("--evidence-log")
|
||||||
|
h2.set_defaults(func=cmd_h2)
|
||||||
|
|
||||||
|
trust = sub.add_parser("trust-capabilities")
|
||||||
|
trust.set_defaults(func=cmd_trust)
|
||||||
|
|
||||||
|
native = sub.add_parser("adapt-native")
|
||||||
|
native.add_argument("event_file")
|
||||||
|
native.set_defaults(func=cmd_native)
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parser().parse_args()
|
||||||
|
return int(args.func(args))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,750 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Zero-dependency, read-only reporting engine for CASAN Core.
|
||||||
|
|
||||||
|
The evidence files under .specify remain the source of truth. This module only
|
||||||
|
builds bounded, sanitized projections for the Local Assurance Viewer and
|
||||||
|
on-demand JSON/HTML exports.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from html import escape
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
SAFE_ID = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$")
|
||||||
|
STALE_AFTER_S = 900
|
||||||
|
MAX_SOURCE_BYTES = 20 * 1024 * 1024
|
||||||
|
MAX_RECORDS = 2000
|
||||||
|
GATES = (
|
||||||
|
("H1-context", "H1 · Context", "Prompt contract, mode and risk classification"),
|
||||||
|
("H2-tool", "H2 · Tool", "Allowlisted source and tool preparation"),
|
||||||
|
("H3-eval", "H3 · Eval", "Grounded synthesis and quality evaluation"),
|
||||||
|
("H4-security", "H4 · Security", "Input and output security boundary"),
|
||||||
|
("H5-governance", "H5 · Governance", "Decision policy and append-only audit"),
|
||||||
|
("H6-agentops", "H6 · AgentOps", "Runtime, token, cost and failure telemetry"),
|
||||||
|
("H7-orchestration", "H7 · Orchestration", "Final governed outcome and certification"),
|
||||||
|
)
|
||||||
|
GATE_MAP = {f"H{index}": gate[0] for index, gate in enumerate(GATES, 1)}
|
||||||
|
SENSITIVE_KEYS = {
|
||||||
|
"authorization", "api_key", "apikey", "access_token", "refresh_token",
|
||||||
|
"password", "secret", "prompt", "raw_prompt", "tool_input", "tool_output",
|
||||||
|
"request_body", "response_body",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def state_root(root: str | Path) -> Path:
|
||||||
|
override = os.environ.get("CASAN_STATE_ROOT")
|
||||||
|
return Path(override).expanduser().resolve() if override else Path(root).resolve() / ".specify"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_text(path: Path, maximum: int = MAX_SOURCE_BYTES) -> str:
|
||||||
|
try:
|
||||||
|
if not path.is_file() or path.stat().st_size > maximum:
|
||||||
|
return ""
|
||||||
|
return path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _read_tail_text(path: Path, maximum: int = MAX_SOURCE_BYTES) -> str:
|
||||||
|
try:
|
||||||
|
if not path.is_file():
|
||||||
|
return ""
|
||||||
|
size = path.stat().st_size
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
if size > maximum:
|
||||||
|
handle.seek(-maximum, os.SEEK_END)
|
||||||
|
handle.readline() # discard a possibly partial first record
|
||||||
|
return handle.read(maximum).decode("utf-8", errors="replace")
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def read_json(path: Path) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
value = json.loads(_read_text(path))
|
||||||
|
return value if isinstance(value, dict) else {}
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def read_jsonl(path: Path, maximum_records: int = MAX_RECORDS) -> list[dict[str, Any]]:
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
for line in _read_tail_text(path).splitlines()[-maximum_records:]:
|
||||||
|
try:
|
||||||
|
value = json.loads(line)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if isinstance(value, dict):
|
||||||
|
rows.append(value)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_value(value: Any, depth: int = 0) -> Any:
|
||||||
|
if depth > 5:
|
||||||
|
return "[depth limited]"
|
||||||
|
if isinstance(value, dict):
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
for key, item in list(value.items())[:80]:
|
||||||
|
normalized = str(key).lower().replace("-", "_")
|
||||||
|
if normalized in SENSITIVE_KEYS:
|
||||||
|
result[str(key)] = "[redacted]"
|
||||||
|
else:
|
||||||
|
result[str(key)] = _safe_value(item, depth + 1)
|
||||||
|
return result
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_safe_value(item, depth + 1) for item in value[:80]]
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value if len(value) <= 4096 else value[:4096] + "…"
|
||||||
|
if value is None or isinstance(value, (bool, int, float)):
|
||||||
|
return value
|
||||||
|
return str(value)[:4096]
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
value = _safe_value(row)
|
||||||
|
return value if isinstance(value, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _number(value: Any) -> float | None:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return None
|
||||||
|
if isinstance(value, (int, float)) and math.isfinite(float(value)):
|
||||||
|
return float(value)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _threshold(name: str, fallback: float) -> float:
|
||||||
|
try:
|
||||||
|
value = float(os.environ.get(name, str(fallback)))
|
||||||
|
return value if math.isfinite(value) and value >= 0 else fallback
|
||||||
|
except ValueError:
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value: Any, fallback: str = "") -> str:
|
||||||
|
return value.strip() if isinstance(value, str) and value.strip() else fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _row_run(row: dict[str, Any]) -> str:
|
||||||
|
return _text(row.get("run_id") or row.get("trace_id"), "unattributed")
|
||||||
|
|
||||||
|
|
||||||
|
def _row_project(row: dict[str, Any]) -> str:
|
||||||
|
return _text(row.get("project") or row.get("project_id"), "default")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_time(value: Any) -> float | None:
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _status(value: Any) -> str:
|
||||||
|
status = _text(value).lower()
|
||||||
|
if status in {"success", "pass", "passed", "allow", "allowed", "answered", "opened", "certified"}:
|
||||||
|
return "pass"
|
||||||
|
if status in {"warn", "warning", "degraded", "partial", "insufficient"}:
|
||||||
|
return "warning"
|
||||||
|
if status in {"block", "blocked", "deny", "denied", "flag", "non_certified"}:
|
||||||
|
return "blocked"
|
||||||
|
if status in {"fail", "failed", "error"}:
|
||||||
|
return "error"
|
||||||
|
if status in {"running", "skipped"}:
|
||||||
|
return status
|
||||||
|
return "queued"
|
||||||
|
|
||||||
|
|
||||||
|
def paths(root: str | Path) -> dict[str, Path]:
|
||||||
|
state = state_root(root)
|
||||||
|
return {
|
||||||
|
"config": Path(root).resolve() / ".casan" / "config.json",
|
||||||
|
"readiness": Path(root).resolve() / ".casan" / "readiness.json",
|
||||||
|
"receipt": state / "state" / "latest-run.json",
|
||||||
|
"metrics": state / "logs" / "cost" / "metrics.jsonl",
|
||||||
|
"provider": state / "logs" / "level5" / "provider-usage.jsonl",
|
||||||
|
"alerts": state / "agentops" / "alerts.log",
|
||||||
|
"trace_dir": state / "logs" / "trace",
|
||||||
|
"event_dir": state / "logs" / "trace-events",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def project_profile(root: str | Path) -> dict[str, Any]:
|
||||||
|
config = read_json(paths(root)["config"])
|
||||||
|
readiness = read_json(paths(root)["readiness"])
|
||||||
|
maturity = config.get("maturity") if isinstance(config.get("maturity"), dict) else {}
|
||||||
|
return {
|
||||||
|
"project_id": _text(config.get("project_id"), Path(root).resolve().name),
|
||||||
|
"project_name": _text(config.get("project_name"), Path(root).resolve().name),
|
||||||
|
"edition": _text(config.get("edition") or config.get("target_level_name"), "core"),
|
||||||
|
"edition_status": _text(config.get("edition_status"), "configured"),
|
||||||
|
"enforcement_mode": _text(config.get("enforcement_mode"), "unknown"),
|
||||||
|
"integration_mode": _text(config.get("integration_mode"), "unknown"),
|
||||||
|
"clients": [str(value) for value in config.get("clients", []) if isinstance(value, str)],
|
||||||
|
"maturity": {
|
||||||
|
"level": maturity.get("level") if _number(maturity.get("level")) is not None else None,
|
||||||
|
"status": _text(maturity.get("status"), "not_assessed"),
|
||||||
|
"evidence": _text(maturity.get("evidence")) or None,
|
||||||
|
},
|
||||||
|
"readiness": readiness if readiness else {
|
||||||
|
"schema_version": 1,
|
||||||
|
"overall_status": "not_assessed",
|
||||||
|
"dimensions": {
|
||||||
|
"core": {
|
||||||
|
"status": "not_assessed",
|
||||||
|
"summary": "Run `casan readiness --refresh` to assess this installation.",
|
||||||
|
"acceptance": [],
|
||||||
|
},
|
||||||
|
"domain_pipeline": {
|
||||||
|
"status": "not_assessed",
|
||||||
|
"summary": "Domain Pipeline readiness has not been assessed.",
|
||||||
|
},
|
||||||
|
"provider_telemetry": {
|
||||||
|
"status": "not_assessed",
|
||||||
|
"summary": "Provider Telemetry readiness has not been assessed.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"next_actions": [],
|
||||||
|
},
|
||||||
|
"viewer": {
|
||||||
|
"mode": "local_core",
|
||||||
|
"scope": "single_project",
|
||||||
|
"read_only": True,
|
||||||
|
"network": "loopback_only",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _trace(root: str | Path, trace_id: str) -> dict[str, Any]:
|
||||||
|
if not SAFE_ID.fullmatch(trace_id):
|
||||||
|
return {}
|
||||||
|
return read_json(paths(root)["trace_dir"] / f"agentic-{trace_id}.json")
|
||||||
|
|
||||||
|
|
||||||
|
def _metric_rows(root: str | Path) -> list[dict[str, Any]]:
|
||||||
|
return read_jsonl(paths(root)["metrics"])
|
||||||
|
|
||||||
|
|
||||||
|
def trace_graph(root: str | Path, trace_id: str) -> dict[str, Any]:
|
||||||
|
if not SAFE_ID.fullmatch(trace_id):
|
||||||
|
return _empty_graph(trace_id)
|
||||||
|
source = paths(root)
|
||||||
|
events: list[dict[str, Any]] = []
|
||||||
|
for row in read_jsonl(source["event_dir"] / f"{trace_id}.jsonl", 500):
|
||||||
|
if row.get("trace_id") != trace_id or row.get("gate_id") not in {gate[0] for gate in GATES}:
|
||||||
|
continue
|
||||||
|
events.append({
|
||||||
|
"timestamp": _text(row.get("timestamp")),
|
||||||
|
"trace_id": trace_id,
|
||||||
|
"gate_id": _text(row.get("gate_id")),
|
||||||
|
"status": _status(row.get("status")),
|
||||||
|
"reason": _text(row.get("reason"), "Evidence observed"),
|
||||||
|
"evidence": sanitize(row.get("evidence") if isinstance(row.get("evidence"), dict) else {}),
|
||||||
|
})
|
||||||
|
if not events:
|
||||||
|
trace = _trace(root, trace_id)
|
||||||
|
harness = _text(trace.get("harness"))
|
||||||
|
if harness in {gate[0] for gate in GATES}:
|
||||||
|
events.append({
|
||||||
|
"timestamp": _text(trace.get("timestamp")),
|
||||||
|
"trace_id": trace_id,
|
||||||
|
"gate_id": harness,
|
||||||
|
"status": _status(trace.get("status") or trace.get("action")),
|
||||||
|
"reason": f"Legacy {harness} trace",
|
||||||
|
"evidence": sanitize({
|
||||||
|
"mode": trace.get("mode"),
|
||||||
|
"action": trace.get("action"),
|
||||||
|
"risk_level": trace.get("risk_level"),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
evidence_rows = trace.get("evidence") if isinstance(trace.get("evidence"), list) else []
|
||||||
|
for evidence in evidence_rows[:200]:
|
||||||
|
if not isinstance(evidence, dict):
|
||||||
|
continue
|
||||||
|
for marker in _text(evidence.get("h")).split("/"):
|
||||||
|
gate_id = GATE_MAP.get(marker)
|
||||||
|
if gate_id:
|
||||||
|
events.append({
|
||||||
|
"timestamp": _text(evidence.get("at") or trace.get("finalized_at")),
|
||||||
|
"trace_id": trace_id,
|
||||||
|
"gate_id": gate_id,
|
||||||
|
"status": _status(evidence.get("decision")),
|
||||||
|
"reason": f"{_text(evidence.get('kind'), 'legacy-evidence')}: {_text(evidence.get('detail'))}",
|
||||||
|
"evidence": sanitize({
|
||||||
|
"kind": evidence.get("kind"),
|
||||||
|
"decision": evidence.get("decision"),
|
||||||
|
"detail": evidence.get("detail"),
|
||||||
|
"certification_strength": trace.get("certification_strength"),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
for metric in _metric_rows(root):
|
||||||
|
if metric.get("trace_id") != trace_id:
|
||||||
|
continue
|
||||||
|
quality = _text(metric.get("telemetry_quality"))
|
||||||
|
events.append({
|
||||||
|
"timestamp": _text(metric.get("timestamp")),
|
||||||
|
"trace_id": trace_id,
|
||||||
|
"gate_id": "H6-agentops",
|
||||||
|
"status": "warning" if quality in {"partial", "insufficient"} else _status(metric.get("status")),
|
||||||
|
"reason": _text(metric.get("step"), "Runtime metric"),
|
||||||
|
"evidence": sanitize({
|
||||||
|
"latency_ms": metric.get("latency_ms"),
|
||||||
|
"total_tokens": metric.get("total_tokens"),
|
||||||
|
"cost_estimate": metric.get("cost_estimate"),
|
||||||
|
"telemetry_quality": metric.get("telemetry_quality"),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
events.sort(key=lambda row: _text(row.get("timestamp")))
|
||||||
|
nodes = []
|
||||||
|
for gate_id, title, description in GATES:
|
||||||
|
gate_events = [row for row in events if row["gate_id"] == gate_id]
|
||||||
|
latest = gate_events[-1] if gate_events else {}
|
||||||
|
nodes.append({
|
||||||
|
"id": gate_id,
|
||||||
|
"title": title,
|
||||||
|
"description": description,
|
||||||
|
"status": latest.get("status", "queued"),
|
||||||
|
"reason": latest.get("reason", "Waiting for evidence"),
|
||||||
|
"updated_at": latest.get("timestamp") or None,
|
||||||
|
"evidence": latest.get("evidence", {}),
|
||||||
|
"events": gate_events,
|
||||||
|
})
|
||||||
|
outcome = nodes[-1]
|
||||||
|
return {
|
||||||
|
"found": bool(events),
|
||||||
|
"trace_id": trace_id,
|
||||||
|
"updated_at": events[-1]["timestamp"] if events else None,
|
||||||
|
"terminal": outcome["status"] in {"pass", "blocked", "error"},
|
||||||
|
"progress": sum(1 for node in nodes if node["status"] != "queued"),
|
||||||
|
"nodes": nodes,
|
||||||
|
"events": events,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_graph(trace_id: str) -> dict[str, Any]:
|
||||||
|
nodes = [{
|
||||||
|
"id": gate_id, "title": title, "description": description,
|
||||||
|
"status": "queued", "reason": "Waiting for evidence",
|
||||||
|
"updated_at": None, "evidence": {}, "events": [],
|
||||||
|
} for gate_id, title, description in GATES]
|
||||||
|
return {
|
||||||
|
"found": False, "trace_id": trace_id, "updated_at": None,
|
||||||
|
"terminal": False, "progress": 0, "nodes": nodes, "events": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_report(root: str | Path, trace_id: str) -> dict[str, Any]:
|
||||||
|
graph = trace_graph(root, trace_id)
|
||||||
|
trace = _trace(root, trace_id)
|
||||||
|
metric = next(
|
||||||
|
(row for row in reversed(_metric_rows(root)) if row.get("trace_id") == trace_id),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
profile = project_profile(root)
|
||||||
|
metric_row = metric or {}
|
||||||
|
duration = _number(metric_row.get("duration_ms"))
|
||||||
|
if duration is None:
|
||||||
|
duration = _number(metric_row.get("latency_ms"))
|
||||||
|
found = bool(trace or graph["found"] or metric)
|
||||||
|
finalized = _text(trace.get("finalized_at") or metric_row.get("finished_at")) or None
|
||||||
|
if not found:
|
||||||
|
verdict = "not_found"
|
||||||
|
elif not graph["terminal"] and not finalized:
|
||||||
|
verdict = "in_progress"
|
||||||
|
elif trace.get("certified") is True:
|
||||||
|
verdict = "certified"
|
||||||
|
else:
|
||||||
|
verdict = "non_certified"
|
||||||
|
cost = metric_row.get("cost") if isinstance(metric_row.get("cost"), dict) else {}
|
||||||
|
reasons = trace.get("certification_reasons") if isinstance(trace.get("certification_reasons"), list) else []
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"report_id": f"RUN-{trace_id}",
|
||||||
|
"generated_at": utc_now(),
|
||||||
|
"trace_id": trace_id,
|
||||||
|
"project": {
|
||||||
|
"id": profile["project_id"],
|
||||||
|
"name": profile["project_name"],
|
||||||
|
"edition": profile["edition"],
|
||||||
|
"maturity": profile["maturity"],
|
||||||
|
},
|
||||||
|
"verdict": verdict,
|
||||||
|
"certification": {
|
||||||
|
"strength": _text(trace.get("certification_strength") or metric_row.get("certification_strength")) or None,
|
||||||
|
"reasons": [str(value) for value in reasons if isinstance(value, str)],
|
||||||
|
"finalized_at": finalized,
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"gates_observed": graph["progress"],
|
||||||
|
"gates_total": 7,
|
||||||
|
"tool_calls": int(_number(trace.get("tool_calls") or metric_row.get("tool_calls")) or 0),
|
||||||
|
"failures": int(_number(trace.get("failures") or metric_row.get("failures")) or 0),
|
||||||
|
"duration_ms": duration,
|
||||||
|
"telemetry_quality": _text(metric_row.get("telemetry_quality"), "unknown"),
|
||||||
|
"token_usage_available": any(_number(metric_row.get(key)) is not None for key in ("total_tokens", "input_tokens", "output_tokens")),
|
||||||
|
"cost_available": _number(metric_row.get("cost_estimate")) is not None or _number(cost.get("amount")) is not None,
|
||||||
|
},
|
||||||
|
"gates": graph["nodes"],
|
||||||
|
"h6": sanitize(metric_row) if metric else None,
|
||||||
|
"source": {
|
||||||
|
"trace_found": bool(trace),
|
||||||
|
"graph_found": graph["found"],
|
||||||
|
"metric_found": metric is not None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def runs(root: str | Path, limit: int = 100) -> dict[str, Any]:
|
||||||
|
limit = max(1, min(int(limit), 200))
|
||||||
|
rows = _metric_rows(root)
|
||||||
|
receipts = read_json(paths(root)["receipt"])
|
||||||
|
safe_rows = [sanitize(row) for row in reversed(rows[-limit:])]
|
||||||
|
if not safe_rows and receipts.get("trace_id"):
|
||||||
|
safe_rows = [sanitize({
|
||||||
|
"trace_id": receipts.get("trace_id"),
|
||||||
|
"project_id": receipts.get("project_id"),
|
||||||
|
"status": "success" if receipts.get("certified") else "failed",
|
||||||
|
"timestamp": receipts.get("finalized_at"),
|
||||||
|
"latency_ms": receipts.get("duration_ms"),
|
||||||
|
"tool_calls": receipts.get("tool_calls"),
|
||||||
|
"failures": receipts.get("failures"),
|
||||||
|
"telemetry_quality": receipts.get("telemetry_quality"),
|
||||||
|
})]
|
||||||
|
return {"count": len(rows) if rows else len(safe_rows), "runs": safe_rows, "latest": sanitize(receipts)}
|
||||||
|
|
||||||
|
|
||||||
|
def _freshness(path: Path, source: str, required: bool) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
stat = path.stat()
|
||||||
|
age = max(0, int(datetime.now(timezone.utc).timestamp() - stat.st_mtime))
|
||||||
|
present = path.is_file()
|
||||||
|
except OSError:
|
||||||
|
age, present = None, False
|
||||||
|
return {
|
||||||
|
"source": source,
|
||||||
|
"path": str(path),
|
||||||
|
"present": present,
|
||||||
|
"updated_at": datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat().replace("+00:00", "Z") if present else None,
|
||||||
|
"age_s": age,
|
||||||
|
"stale": bool(present and age is not None and age > STALE_AFTER_S),
|
||||||
|
"required": required,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _display_path(root: str | Path, path: Path) -> str:
|
||||||
|
try:
|
||||||
|
return str(path.resolve().relative_to(Path(root).resolve()))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return f"[external]/{path.name}"
|
||||||
|
|
||||||
|
|
||||||
|
def _percentile(values: list[float], pct: int) -> float:
|
||||||
|
if not values:
|
||||||
|
return 0
|
||||||
|
ordered = sorted(values)
|
||||||
|
return ordered[max(0, math.ceil((pct / 100) * len(ordered)) - 1)]
|
||||||
|
|
||||||
|
|
||||||
|
def _grouped(rows: list[dict[str, Any]], key) -> list[dict[str, Any]]:
|
||||||
|
groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||||
|
for row in rows:
|
||||||
|
groups[key(row)].append(row)
|
||||||
|
result = []
|
||||||
|
for name, records in groups.items():
|
||||||
|
latencies = [_number(row.get("latency_ms")) for row in records]
|
||||||
|
latencies = [value for value in latencies if value is not None and value > 0]
|
||||||
|
tokens = [_number(row.get("total_tokens")) for row in records]
|
||||||
|
cost_values = [
|
||||||
|
_number(row.get("cost_usd")) if _number(row.get("cost_usd")) is not None
|
||||||
|
else _number(row.get("cost_estimate"))
|
||||||
|
for row in records
|
||||||
|
]
|
||||||
|
result.append({
|
||||||
|
"key": name,
|
||||||
|
"runs": len(records),
|
||||||
|
"failures": sum(1 for row in records if _text(row.get("status"), "unknown") == "failed"),
|
||||||
|
"latency_avg_ms": round(sum(latencies) / len(latencies)) if latencies else 0,
|
||||||
|
"tokens": round(sum(value for value in tokens if value is not None)) if any(value is not None for value in tokens) else None,
|
||||||
|
"cost_usd": round(sum(value for value in cost_values if value is not None), 6) if any(value is not None for value in cost_values) else None,
|
||||||
|
})
|
||||||
|
return sorted(result, key=lambda row: (-row["runs"], row["key"]))
|
||||||
|
|
||||||
|
|
||||||
|
def _matches(row: dict[str, Any], project: str | None, run: str | None,
|
||||||
|
start: float | None, end: float | None) -> bool:
|
||||||
|
if project and _row_project(row) != project:
|
||||||
|
return False
|
||||||
|
if run and _row_run(row) != run:
|
||||||
|
return False
|
||||||
|
timestamp = _parse_time(row.get("timestamp"))
|
||||||
|
if start is not None and (timestamp is None or timestamp < start):
|
||||||
|
return False
|
||||||
|
if end is not None and (timestamp is None or timestamp > end):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _date_boundary(value: str | None, end: bool = False) -> tuple[str | None, float | None]:
|
||||||
|
if not value:
|
||||||
|
return None, None
|
||||||
|
normalized = value
|
||||||
|
if re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
|
||||||
|
normalized += "T23:59:59.999Z" if end else "T00:00:00.000Z"
|
||||||
|
parsed = _parse_time(normalized)
|
||||||
|
if parsed is None:
|
||||||
|
raise ValueError("invalid report time boundary")
|
||||||
|
return datetime.fromtimestamp(parsed, timezone.utc).isoformat().replace("+00:00", "Z"), parsed
|
||||||
|
|
||||||
|
|
||||||
|
def h6_report(root: str | Path, project: str | None = None, run: str | None = None,
|
||||||
|
from_date: str | None = None, to_date: str | None = None,
|
||||||
|
limit: int = 50) -> dict[str, Any]:
|
||||||
|
for value in (project, run):
|
||||||
|
if value and not SAFE_ID.fullmatch(value):
|
||||||
|
raise ValueError("invalid project or run filter")
|
||||||
|
normalized_from, start = _date_boundary(from_date)
|
||||||
|
normalized_to, end = _date_boundary(to_date, True)
|
||||||
|
if start is not None and end is not None and start > end:
|
||||||
|
raise ValueError("invalid report time range")
|
||||||
|
limit = max(1, min(int(limit), 200))
|
||||||
|
source = paths(root)
|
||||||
|
all_metrics = _metric_rows(root)
|
||||||
|
all_provider = read_jsonl(source["provider"])
|
||||||
|
all_alerts = read_jsonl(source["alerts"])
|
||||||
|
metrics = [row for row in all_metrics if _matches(row, project, run, start, end)]
|
||||||
|
provider = [row for row in all_provider if _matches(row, project, run, start, end)]
|
||||||
|
alerts = [row for row in all_alerts if _matches(row, project, run, start, end)]
|
||||||
|
latencies = [value for value in (_number(row.get("latency_ms")) for row in metrics) if value is not None and value > 0]
|
||||||
|
failed = sum(1 for row in metrics if _text(row.get("status"), "unknown") == "failed")
|
||||||
|
degraded = sum(1 for row in metrics if _text(row.get("status"), "unknown") == "degraded")
|
||||||
|
success = sum(1 for row in metrics if _text(row.get("status"), "unknown") in {"success", "pass", "passed"})
|
||||||
|
provider_token_runs = {_row_run(row) for row in provider if _number(row.get("total_tokens")) is not None}
|
||||||
|
provider_cost_runs = {_row_run(row) for row in provider if _number(row.get("cost_usd")) is not None}
|
||||||
|
token_records = sum(1 for row in metrics if any(_number(row.get(key)) is not None for key in ("total_tokens", "input_tokens", "output_tokens")) or _row_run(row) in provider_token_runs)
|
||||||
|
cost_records = sum(1 for row in metrics if _number(row.get("cost_estimate")) is not None or _row_run(row) in provider_cost_runs)
|
||||||
|
token_pct = round((token_records / len(metrics)) * 100, 1) if metrics else 0
|
||||||
|
cost_pct = round((cost_records / len(metrics)) * 100, 1) if metrics else 0
|
||||||
|
quality = Counter(_text(row.get("telemetry_quality"), "unknown") for row in metrics)
|
||||||
|
evidence = [
|
||||||
|
{**_freshness(source["metrics"], "metrics", True), "records": len(all_metrics)},
|
||||||
|
{**_freshness(source["provider"], "provider", False), "records": len(all_provider)},
|
||||||
|
{**_freshness(source["alerts"], "alerts", False), "records": len(all_alerts)},
|
||||||
|
]
|
||||||
|
for item in evidence:
|
||||||
|
item["path"] = _display_path(root, Path(item["path"]))
|
||||||
|
primary = evidence[0]
|
||||||
|
failure_rate = round((failed / len(metrics)) * 100, 1) if metrics else 0
|
||||||
|
p95 = _percentile(latencies, 95)
|
||||||
|
failure_threshold = _threshold("CASAN_H6_FAILURE_RATE_THRESHOLD_PCT", 5)
|
||||||
|
p95_threshold = _threshold("CASAN_H6_P95_LATENCY_THRESHOLD_MS", 5000)
|
||||||
|
findings: list[dict[str, Any]] = []
|
||||||
|
warnings: list[str] = []
|
||||||
|
if not primary["present"]:
|
||||||
|
findings.append({"severity": "critical", "code": "METRICS_MISSING", "message": "The required runtime metrics source is missing."})
|
||||||
|
elif primary["stale"]:
|
||||||
|
findings.append({"severity": "warning", "code": "METRICS_STALE", "message": "The primary runtime metrics source is stale.", "metric": "age_s", "value": primary["age_s"], "threshold": STALE_AFTER_S})
|
||||||
|
if failure_rate > failure_threshold:
|
||||||
|
findings.append({"severity": "critical", "code": "FAILURE_RATE_BREACH", "message": "Failure rate exceeds the configured H6 threshold.", "metric": "failure_rate_pct", "value": failure_rate, "threshold": failure_threshold})
|
||||||
|
elif failed:
|
||||||
|
findings.append({"severity": "warning", "code": "FAILURES_PRESENT", "message": f"{failed} failed run(s) are present in the selected scope.", "metric": "failed", "value": failed})
|
||||||
|
if p95 > p95_threshold:
|
||||||
|
findings.append({"severity": "warning", "code": "P95_LATENCY_BREACH", "message": "P95 latency exceeds the configured H6 threshold.", "metric": "p95_latency_ms", "value": p95, "threshold": p95_threshold})
|
||||||
|
alert_counter: Counter[str] = Counter()
|
||||||
|
fingerprints: set[tuple[str, str]] = set()
|
||||||
|
for row in metrics + alerts:
|
||||||
|
alert_types: list[str] = []
|
||||||
|
if isinstance(row.get("alerts"), list):
|
||||||
|
alert_types.extend(str(value) for value in row["alerts"] if isinstance(value, str))
|
||||||
|
body = row.get("body") if isinstance(row.get("body"), dict) else {}
|
||||||
|
if isinstance(body.get("alert.type"), str):
|
||||||
|
alert_types.append(body["alert.type"])
|
||||||
|
for alert in alert_types:
|
||||||
|
row_run = _row_run(row)
|
||||||
|
fingerprint = (
|
||||||
|
_text(row.get("timestamp"), "unknown-time")
|
||||||
|
if row_run == "unattributed" else row_run,
|
||||||
|
alert,
|
||||||
|
)
|
||||||
|
if fingerprint not in fingerprints:
|
||||||
|
alert_counter[alert] += 1
|
||||||
|
fingerprints.add(fingerprint)
|
||||||
|
alert_count = sum(alert_counter.values())
|
||||||
|
if alert_count:
|
||||||
|
findings.append({"severity": "warning", "code": "ALERTS_PRESENT", "message": f"{alert_count} alert signal(s) require review.", "metric": "alerts", "value": alert_count})
|
||||||
|
if degraded:
|
||||||
|
findings.append({"severity": "warning", "code": "DEGRADED_RUNS_PRESENT", "message": f"{degraded} degraded run(s) are present in the selected scope.", "metric": "degraded", "value": degraded})
|
||||||
|
if metrics and (token_pct < 100 or cost_pct < 100):
|
||||||
|
findings.append({"severity": "warning", "code": "TELEMETRY_COVERAGE_GAP", "message": "Some runs do not have reliable provider token or cost attribution.", "metric": "token_cost_coverage_pct", "value": f"{token_pct}/{cost_pct}", "threshold": "100/100"})
|
||||||
|
if not provider:
|
||||||
|
warnings.append("No provider usage records matched the selected scope; token and actual-cost breakdown may be incomplete.")
|
||||||
|
if metrics and token_pct < 100:
|
||||||
|
warnings.append(f"{token_pct}% of runtime records have reliable token attribution; unavailable values remain null, never zero.")
|
||||||
|
if metrics and cost_pct < 100:
|
||||||
|
warnings.append(f"{cost_pct}% of runtime records have reliable cost attribution; unavailable values remain null, never zero.")
|
||||||
|
for item in evidence[1:]:
|
||||||
|
if not item["present"]:
|
||||||
|
warnings.append(f"Optional {item['source']} telemetry source is missing; its breakdown is unavailable.")
|
||||||
|
elif item["stale"]:
|
||||||
|
warnings.append(f"Optional {item['source']} telemetry source is stale; its breakdown may not reflect recent activity.")
|
||||||
|
critical = any(item["severity"] == "critical" for item in findings)
|
||||||
|
warning = any(item["severity"] == "warning" for item in findings)
|
||||||
|
verdict = "no_data" if not metrics and not provider else "fail" if critical else "attention" if warning else "pass"
|
||||||
|
status_counts = Counter(_text(row.get("status"), "unknown") for row in metrics)
|
||||||
|
generated_at = utc_now()
|
||||||
|
total = lambda rows, key: sum(value for value in (_number(row.get(key)) for row in rows) if value is not None)
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"report_id": "H6-" + re.sub(r"[-:.TZ+]", "", generated_at)[:14],
|
||||||
|
"harness": "H6",
|
||||||
|
"title": "H6 · AgentOps Report",
|
||||||
|
"description": "Evidence-backed runtime, token, cost, failure, retry and alert telemetry.",
|
||||||
|
"generated_at": generated_at,
|
||||||
|
"scope": {"project": project, "from": normalized_from, "to": normalized_to, "run": run},
|
||||||
|
"verdict": verdict,
|
||||||
|
"verdict_reasons": [item["code"] for item in findings],
|
||||||
|
"freshness": {"status": "missing" if not primary["present"] else "stale" if primary["stale"] else "live", "stale_after_s": STALE_AFTER_S, "primary_age_s": primary["age_s"], "sources": evidence},
|
||||||
|
"summary": {
|
||||||
|
"runs": len(metrics), "success": success, "failed": failed, "degraded": degraded,
|
||||||
|
"failure_rate_pct": failure_rate, "retries": int(total(metrics, "retry_count")),
|
||||||
|
"latency_ms": {
|
||||||
|
"average": round(sum(latencies) / len(latencies)) if latencies else 0,
|
||||||
|
"p50": _percentile(latencies, 50), "p95": p95,
|
||||||
|
"p99": _percentile(latencies, 99), "max": max(latencies) if latencies else 0,
|
||||||
|
},
|
||||||
|
"tokens": {
|
||||||
|
"input": total(metrics, "input_tokens") if any(_number(row.get("input_tokens")) is not None for row in metrics) else None,
|
||||||
|
"output": total(metrics, "output_tokens") if any(_number(row.get("output_tokens")) is not None for row in metrics) else None,
|
||||||
|
"total": total(metrics, "total_tokens") if any(_number(row.get("total_tokens")) is not None for row in metrics) else None,
|
||||||
|
"provider_total": total(provider, "total_tokens") if any(_number(row.get("total_tokens")) is not None for row in provider) else None,
|
||||||
|
},
|
||||||
|
"cost_usd": {
|
||||||
|
"provider_actual": round(total(provider, "cost_usd"), 6) if any(_number(row.get("cost_usd")) is not None for row in provider) else None,
|
||||||
|
"estimated": round(total(metrics, "cost_estimate"), 6) if any(_number(row.get("cost_estimate")) is not None for row in metrics) else None,
|
||||||
|
},
|
||||||
|
"provider_calls": len(provider), "alerts": alert_count,
|
||||||
|
"coverage": {
|
||||||
|
"runtime_records": len(metrics), "token_records": token_records, "cost_records": cost_records,
|
||||||
|
"token_pct": token_pct, "cost_pct": cost_pct,
|
||||||
|
"quality": {name: quality.get(name, 0) for name in ("complete", "partial", "insufficient", "unknown")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"thresholds": {"failure_rate_pct": failure_threshold, "p95_latency_ms": p95_threshold, "freshness_age_s": STALE_AFTER_S},
|
||||||
|
"findings": findings,
|
||||||
|
"evidence_sources": evidence,
|
||||||
|
"data_quality": {"status": "insufficient" if not primary["present"] or (metrics and not token_records and not cost_records) else "partial" if warnings else "complete", "warnings": warnings},
|
||||||
|
"available_filters": {
|
||||||
|
"projects": sorted({_row_project(row) for row in all_metrics + all_provider + all_alerts}),
|
||||||
|
"runs": sorted({_row_run(row) for row in all_metrics + all_provider + all_alerts} - {"unattributed"}),
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"by_status": [{"status": key, "count": value} for key, value in status_counts.most_common()],
|
||||||
|
"by_step": _grouped(metrics, lambda row: _text(row.get("step"), "unknown-step")),
|
||||||
|
"by_provider": _grouped(provider, lambda row: f"{_text(row.get('provider'), 'unknown-provider')} · {_text(row.get('model'), 'unknown-model')}"),
|
||||||
|
"by_cost_source": [{"source": row["key"], "records": row["runs"], "cost_usd": row["cost_usd"] or 0} for row in _grouped(metrics, lambda row: _text(row.get("cost_source"), "unknown"))],
|
||||||
|
"by_alert": [{"alert": key, "count": value} for key, value in alert_counter.most_common()],
|
||||||
|
"recent_runs": [sanitize(row) for row in reversed(metrics[-limit:])],
|
||||||
|
"recent_alerts": [sanitize(row) for row in reversed(alerts[-limit:])],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def report_html(report: dict[str, Any], kind: str) -> str:
|
||||||
|
"""Render a self-contained, print-ready evidence dossier."""
|
||||||
|
title = report.get("title") or (
|
||||||
|
f"Run assurance · {report.get('trace_id', 'unknown')}"
|
||||||
|
)
|
||||||
|
verdict = _text(report.get("verdict"), "unknown").replace("_", " ")
|
||||||
|
if kind == "h6":
|
||||||
|
summary = report.get("summary", {})
|
||||||
|
metrics = [
|
||||||
|
("Governed runs", summary.get("runs")),
|
||||||
|
("Failure rate", f"{summary.get('failure_rate_pct', 0)}%"),
|
||||||
|
("P95 latency", f"{summary.get('latency_ms', {}).get('p95', 0)} ms"),
|
||||||
|
("Data quality", report.get("data_quality", {}).get("status")),
|
||||||
|
]
|
||||||
|
findings = report.get("findings", [])
|
||||||
|
statuses = report.get("details", {}).get("by_status", [])
|
||||||
|
maximum = max(
|
||||||
|
[float(item.get("count", 0) or 0) for item in statuses] or [1]
|
||||||
|
)
|
||||||
|
total_runs = float(summary.get("runs", 0) or 0)
|
||||||
|
outcome_rows = "".join(
|
||||||
|
'<li><div><span><strong>%s</strong><small>%s%% of selected runs</small></span><b>%s</b></div>'
|
||||||
|
'<i><em class="%s" style="width:%s%%"></em></i></li>' % (
|
||||||
|
escape(_text(item.get("status"), "unknown").replace("_", " ")),
|
||||||
|
round((float(item.get("count", 0) or 0) / total_runs) * 100)
|
||||||
|
if total_runs else 0,
|
||||||
|
escape(str(int(float(item.get("count", 0) or 0)))),
|
||||||
|
"fail" if item.get("status") == "failed" else
|
||||||
|
"attention" if item.get("status") == "degraded" else "pass",
|
||||||
|
max((float(item.get("count", 0) or 0) / maximum) * 100, 3)
|
||||||
|
if float(item.get("count", 0) or 0) else 0,
|
||||||
|
)
|
||||||
|
for item in statuses
|
||||||
|
)
|
||||||
|
outcomes = (
|
||||||
|
'<section class="panel"><h2>Run distribution</h2>'
|
||||||
|
'<p class="sub">Canonical runtime status values, normalized against the largest observed group.</p>'
|
||||||
|
f'<ol class="distribution">{outcome_rows}</ol></section>'
|
||||||
|
if outcome_rows else ""
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
summary = report.get("summary", {})
|
||||||
|
metrics = [
|
||||||
|
("Evidence gates", f"{summary.get('gates_observed', 0)}/7"),
|
||||||
|
("Tool calls", summary.get("tool_calls")),
|
||||||
|
("Failures", summary.get("failures")),
|
||||||
|
("H6 quality", summary.get("telemetry_quality")),
|
||||||
|
]
|
||||||
|
findings = [
|
||||||
|
{"code": gate.get("title"), "severity": gate.get("status"), "message": gate.get("reason")}
|
||||||
|
for gate in report.get("gates", [])
|
||||||
|
]
|
||||||
|
outcomes = ""
|
||||||
|
cards = "".join(
|
||||||
|
f'<div class="metric"><span>{escape(str(label))}</span><strong>{escape(str(value if value is not None else "Unavailable"))}</strong></div>'
|
||||||
|
for label, value in metrics
|
||||||
|
)
|
||||||
|
rows = "".join(
|
||||||
|
'<article class="finding"><div><code>%s</code><em>%s</em></div><p>%s</p></article>' % (
|
||||||
|
escape(str(item.get("code", "EVIDENCE"))),
|
||||||
|
escape(str(item.get("severity", "info"))),
|
||||||
|
escape(str(item.get("message", "No explanation emitted."))),
|
||||||
|
) for item in findings
|
||||||
|
) or '<div class="clear">No threshold breach was detected.</div>'
|
||||||
|
contract = escape(json.dumps(report, ensure_ascii=False, indent=2))
|
||||||
|
return f"""<!doctype html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>{escape(str(title))}</title>
|
||||||
|
<style>
|
||||||
|
:root{{--ink:#101828;--muted:#667085;--line:#e4e7ec;--paper:#f7f8fa;--navy:#101828;--cyan:#06b6d4;--green:#067647;--amber:#b54708}}
|
||||||
|
*{{box-sizing:border-box}}body{{margin:0;background:var(--paper);color:var(--ink);font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif}}
|
||||||
|
main{{max-width:1060px;margin:auto;padding:44px 24px 72px}}header{{border-radius:28px;background:var(--navy);color:white;padding:38px;box-shadow:0 24px 70px #10182820}}
|
||||||
|
.eyebrow,dt,.metric span{{font-size:11px;font-weight:800;letter-spacing:.14em;text-transform:uppercase}}.eyebrow{{color:#67e8f9}}h1{{max-width:760px;margin:12px 0 14px;font-size:42px;line-height:1.05;letter-spacing:-.04em}}
|
||||||
|
.meta{{display:flex;flex-wrap:wrap;gap:12px;color:#98a2b3;font:12px ui-monospace,monospace}}.badge{{display:inline-block;margin-top:20px;border:1px solid #344054;border-radius:999px;padding:8px 12px;font-size:11px;font-weight:800;text-transform:uppercase}}
|
||||||
|
.grid{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:18px 0}}.metric,.panel{{border:1px solid var(--line);border-radius:18px;background:white;padding:20px;box-shadow:0 10px 30px #10182808}}
|
||||||
|
.metric span{{display:block;color:var(--muted)}}.metric strong{{display:block;margin-top:16px;font-size:24px;letter-spacing:-.03em}}.panel{{margin-top:18px;padding:26px}}h2{{margin:0 0 6px;font-size:20px}}.sub{{margin:0 0 20px;color:var(--muted);font-size:14px}}
|
||||||
|
.finding{{border-left:4px solid var(--cyan);border-radius:10px;background:#f8fafc;padding:14px 16px;margin-top:10px}}.finding div{{display:flex;justify-content:space-between;gap:16px}}code{{font-weight:800}}em{{color:var(--muted);font-size:11px;text-transform:uppercase}}.finding p{{margin:8px 0 0;color:#344054;font-size:14px;line-height:1.55}}.clear{{border-radius:12px;background:#ecfdf3;color:var(--green);padding:16px}}
|
||||||
|
.distribution{{display:grid;gap:16px;margin:20px 0 0;padding:0;list-style:none}}.distribution li>div{{display:flex;align-items:end;justify-content:space-between;gap:16px}}.distribution span strong,.distribution span small{{display:block}}.distribution span strong{{font-size:13px;text-transform:capitalize}}.distribution span small{{margin-top:4px;color:var(--muted);font-size:11px}}.distribution b{{font:700 12px ui-monospace,monospace}}.distribution i{{display:block;overflow:hidden;height:8px;margin-top:8px;border-radius:99px;background:#eaecf0}}.distribution em{{display:block;height:100%;border-radius:inherit;background:var(--cyan)}}.distribution em.pass{{background:#12b76a}}.distribution em.attention{{background:#f79009}}.distribution em.fail{{background:#f04438}}
|
||||||
|
details{{margin-top:18px}}summary{{cursor:pointer;font-weight:700}}pre{{overflow:auto;max-height:720px;border-radius:14px;background:#101828;color:#d0d5dd;padding:20px;font:11px/1.6 ui-monospace,monospace}}
|
||||||
|
footer{{margin-top:22px;color:var(--muted);font-size:11px}}@media(max-width:760px){{h1{{font-size:32px}}.grid{{grid-template-columns:1fr 1fr}}header{{padding:28px}}}}@media print{{body{{background:white}}main{{max-width:none;padding:0}}header,.panel,.metric{{box-shadow:none}}details{{break-before:page}}}}
|
||||||
|
</style></head><body><main><header><div class="eyebrow">CASAN · Independent assurance dossier</div><h1>{escape(str(title))}</h1>
|
||||||
|
<div class="meta"><span>{escape(str(report.get("report_id", "")))}</span><span>Generated {escape(str(report.get("generated_at", "")))}</span></div><div class="badge">Verdict · {escape(verdict)}</div></header>
|
||||||
|
<section class="grid">{cards}</section><section class="panel"><h2>Decision evidence</h2><p class="sub">Threshold findings and control outcomes from canonical local evidence.</p>{rows}</section>{outcomes}
|
||||||
|
<section class="panel"><h2>Machine-auditable contract</h2><p class="sub">The complete sanitized report payload used to render this dossier.</p><details><summary>Open JSON evidence</summary><pre>{contract}</pre></details></section>
|
||||||
|
<footer>Generated on demand by CASAN Core Local Assurance Viewer · read-only · offline-capable</footer></main></body></html>"""
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Loopback-only HTTP server for the CASAN Core Local Assurance Viewer."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
import json
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import signal
|
||||||
|
import threading
|
||||||
|
from urllib.parse import parse_qs, unquote, urlparse
|
||||||
|
|
||||||
|
from local_report import (
|
||||||
|
SAFE_ID,
|
||||||
|
h6_report,
|
||||||
|
project_profile,
|
||||||
|
report_html,
|
||||||
|
run_report,
|
||||||
|
runs,
|
||||||
|
trace_graph,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ASSET_ROOT = Path(__file__).resolve().parents[2] / "assets" / "local-viewer"
|
||||||
|
MAX_RESPONSE_BYTES = 24 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_json(path: Path, payload: dict) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
||||||
|
temporary.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
||||||
|
try:
|
||||||
|
os.chmod(temporary, 0o600)
|
||||||
|
except OSError:
|
||||||
|
if os.name != "nt":
|
||||||
|
raise
|
||||||
|
os.replace(temporary, path)
|
||||||
|
|
||||||
|
|
||||||
|
class ViewerServer(ThreadingHTTPServer):
|
||||||
|
daemon_threads = True
|
||||||
|
allow_reuse_address = False
|
||||||
|
|
||||||
|
def __init__(self, address, root: Path, token: str):
|
||||||
|
self.root = root.resolve()
|
||||||
|
self.token = token
|
||||||
|
super().__init__(address, ViewerHandler)
|
||||||
|
|
||||||
|
|
||||||
|
class ViewerHandler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "CASAN-Core-Viewer/1"
|
||||||
|
sys_version = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def viewer(self) -> ViewerServer:
|
||||||
|
return self.server # type: ignore[return-value]
|
||||||
|
|
||||||
|
def log_message(self, fmt: str, *args) -> None:
|
||||||
|
# Never persist the session token from the initial/query download URL.
|
||||||
|
print(
|
||||||
|
"%s - - [%s] %s %s" % (
|
||||||
|
self.client_address[0],
|
||||||
|
self.log_date_time_string(),
|
||||||
|
self.command,
|
||||||
|
urlparse(self.path).path,
|
||||||
|
),
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _host_allowed(self) -> bool:
|
||||||
|
host = self.headers.get("Host", "").split(":", 1)[0].strip("[]").lower()
|
||||||
|
return host in {"127.0.0.1", "localhost", "::1"}
|
||||||
|
|
||||||
|
def _authorized(self, query: dict[str, list[str]]) -> bool:
|
||||||
|
supplied = self.headers.get("X-CASAN-Viewer-Token", "")
|
||||||
|
if not supplied:
|
||||||
|
supplied = query.get("token", [""])[0]
|
||||||
|
return bool(supplied) and supplied == self.viewer.token
|
||||||
|
|
||||||
|
def _headers(self, status: int, content_type: str, length: int) -> None:
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", content_type)
|
||||||
|
self.send_header("Content-Length", str(length))
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("Pragma", "no-cache")
|
||||||
|
self.send_header("X-Content-Type-Options", "nosniff")
|
||||||
|
self.send_header("X-Frame-Options", "DENY")
|
||||||
|
self.send_header("Referrer-Policy", "no-referrer")
|
||||||
|
self.send_header("Cross-Origin-Resource-Policy", "same-origin")
|
||||||
|
self.send_header(
|
||||||
|
"Content-Security-Policy",
|
||||||
|
"default-src 'self'; script-src 'self'; style-src 'self'; "
|
||||||
|
"img-src 'self' data:; connect-src 'self'; object-src 'none'; "
|
||||||
|
"base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
|
||||||
|
)
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def _send(self, status: int, body: bytes, content_type: str) -> None:
|
||||||
|
if len(body) > MAX_RESPONSE_BYTES:
|
||||||
|
self._json(413, {"error": "CASAN_VIEW_RESPONSE_TOO_LARGE"})
|
||||||
|
return
|
||||||
|
self._headers(status, content_type, len(body))
|
||||||
|
if self.command != "HEAD":
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _json(self, status: int, payload) -> None:
|
||||||
|
self._send(
|
||||||
|
status,
|
||||||
|
(json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8"),
|
||||||
|
"application/json; charset=utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _asset(self, name: str) -> None:
|
||||||
|
safe_name = "index.html" if name in {"", "/"} else name.lstrip("/")
|
||||||
|
if safe_name not in {"index.html", "app.css", "app.js", "favicon.svg"}:
|
||||||
|
self._json(404, {"error": "CASAN_VIEW_NOT_FOUND"})
|
||||||
|
return
|
||||||
|
path = ASSET_ROOT / safe_name
|
||||||
|
try:
|
||||||
|
body = path.read_bytes()
|
||||||
|
except OSError:
|
||||||
|
self._json(503, {"error": "CASAN_VIEW_ASSET_MISSING"})
|
||||||
|
return
|
||||||
|
content_type = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
|
||||||
|
if content_type.startswith("text/") or content_type == "application/javascript":
|
||||||
|
content_type += "; charset=utf-8"
|
||||||
|
self._send(200, body, content_type)
|
||||||
|
|
||||||
|
def _download(self, report: dict, fmt: str, filename: str) -> None:
|
||||||
|
if fmt == "json":
|
||||||
|
body = (json.dumps(report, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
|
||||||
|
content_type = "application/json; charset=utf-8"
|
||||||
|
elif fmt == "html":
|
||||||
|
body = report_html(report, "h6" if report.get("harness") == "H6" else "run").encode("utf-8")
|
||||||
|
content_type = "text/html; charset=utf-8"
|
||||||
|
else:
|
||||||
|
self._json(400, {"error": "CASAN_REPORT_FORMAT_INVALID"})
|
||||||
|
return
|
||||||
|
if len(body) > MAX_RESPONSE_BYTES:
|
||||||
|
self._json(413, {"error": "CASAN_REPORT_TOO_LARGE"})
|
||||||
|
return
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", content_type)
|
||||||
|
self.send_header("Content-Disposition", f'attachment; filename="{filename}.{fmt}"')
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("X-Content-Type-Options", "nosniff")
|
||||||
|
self.send_header("Referrer-Policy", "no-referrer")
|
||||||
|
self.end_headers()
|
||||||
|
if self.command != "HEAD":
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_HEAD(self) -> None:
|
||||||
|
self.do_GET()
|
||||||
|
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
if self.client_address[0] not in {"127.0.0.1", "::1"} or not self._host_allowed():
|
||||||
|
self._json(403, {"error": "CASAN_VIEW_LOOPBACK_ONLY"})
|
||||||
|
return
|
||||||
|
parsed = urlparse(self.path)
|
||||||
|
path = unquote(parsed.path)
|
||||||
|
query = parse_qs(parsed.query, keep_blank_values=False)
|
||||||
|
if path in {"/", "/index.html", "/app.css", "/app.js"}:
|
||||||
|
self._asset(path)
|
||||||
|
return
|
||||||
|
if not self._authorized(query):
|
||||||
|
self._json(401, {"error": "CASAN_VIEW_TOKEN_REQUIRED"})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if path == "/healthz":
|
||||||
|
self._json(200, {
|
||||||
|
"status": "ok",
|
||||||
|
"mode": "local_core",
|
||||||
|
"project_id": project_profile(self.viewer.root)["project_id"],
|
||||||
|
"pid": os.getpid(),
|
||||||
|
})
|
||||||
|
elif path == "/api/v1/project":
|
||||||
|
self._json(200, project_profile(self.viewer.root))
|
||||||
|
elif path == "/api/v1/runs":
|
||||||
|
self._json(200, runs(self.viewer.root, int(query.get("limit", ["100"])[0])))
|
||||||
|
elif path == "/api/v1/reports/h6":
|
||||||
|
report = self._h6(query)
|
||||||
|
self._json(200, report)
|
||||||
|
elif path == "/api/v1/reports/h6/export":
|
||||||
|
report = self._h6(query)
|
||||||
|
self._download(report, query.get("format", ["html"])[0], report["report_id"])
|
||||||
|
elif path == "/__shutdown":
|
||||||
|
self._json(200, {"status": "stopping"})
|
||||||
|
threading.Thread(target=self.viewer.shutdown, daemon=True).start()
|
||||||
|
elif path.startswith("/api/v1/runs/") and path.endswith("/graph"):
|
||||||
|
trace_id = path[len("/api/v1/runs/"):-len("/graph")].strip("/")
|
||||||
|
self._trace_required(trace_id)
|
||||||
|
self._json(200, trace_graph(self.viewer.root, trace_id))
|
||||||
|
elif path.startswith("/api/v1/reports/run/"):
|
||||||
|
suffix = path[len("/api/v1/reports/run/"):].strip("/")
|
||||||
|
exporting = suffix.endswith("/export")
|
||||||
|
trace_id = suffix[:-len("/export")].strip("/") if exporting else suffix
|
||||||
|
self._trace_required(trace_id)
|
||||||
|
report = run_report(self.viewer.root, trace_id)
|
||||||
|
if report["verdict"] == "not_found":
|
||||||
|
self._json(404, {"error": "CASAN_RUN_NOT_FOUND"})
|
||||||
|
elif exporting:
|
||||||
|
self._download(
|
||||||
|
report,
|
||||||
|
query.get("format", ["html"])[0],
|
||||||
|
report["report_id"],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._json(200, report)
|
||||||
|
else:
|
||||||
|
self._json(404, {"error": "CASAN_VIEW_NOT_FOUND"})
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
self._json(400, {"error": "CASAN_VIEW_INVALID_QUERY"})
|
||||||
|
except BrokenPipeError:
|
||||||
|
return
|
||||||
|
except Exception as error: # fail closed without leaking project paths
|
||||||
|
self.log_error("request failed: %s", type(error).__name__)
|
||||||
|
self._json(500, {"error": "CASAN_VIEW_INTERNAL_ERROR"})
|
||||||
|
|
||||||
|
def _trace_required(self, trace_id: str) -> None:
|
||||||
|
if not SAFE_ID.fullmatch(trace_id):
|
||||||
|
raise ValueError("unsafe trace id")
|
||||||
|
|
||||||
|
def _h6(self, query: dict[str, list[str]]) -> dict:
|
||||||
|
return h6_report(
|
||||||
|
self.viewer.root,
|
||||||
|
project=query.get("project", [None])[0],
|
||||||
|
run=query.get("run", [None])[0],
|
||||||
|
from_date=query.get("from", [None])[0],
|
||||||
|
to_date=query.get("to", [None])[0],
|
||||||
|
limit=int(query.get("limit", ["50"])[0]),
|
||||||
|
)
|
||||||
|
|
||||||
|
def do_POST(self) -> None:
|
||||||
|
self._json(405, {"error": "CASAN_VIEW_READ_ONLY"})
|
||||||
|
|
||||||
|
do_PUT = do_POST
|
||||||
|
do_PATCH = do_POST
|
||||||
|
do_DELETE = do_POST
|
||||||
|
|
||||||
|
|
||||||
|
def serve(root: Path, port: int, token: str, state_file: Path) -> int:
|
||||||
|
if not token or len(token) < 32:
|
||||||
|
raise ValueError("viewer token is too short")
|
||||||
|
server = ViewerServer(("127.0.0.1", port), root, token)
|
||||||
|
actual_port = server.server_address[1]
|
||||||
|
atomic_json(state_file, {
|
||||||
|
"schema_version": 1,
|
||||||
|
"pid": os.getpid(),
|
||||||
|
"port": actual_port,
|
||||||
|
"token": token,
|
||||||
|
"project_root": str(root.resolve()),
|
||||||
|
"started_at": __import__("datetime").datetime.now(
|
||||||
|
__import__("datetime").timezone.utc
|
||||||
|
).isoformat().replace("+00:00", "Z"),
|
||||||
|
})
|
||||||
|
|
||||||
|
def stop(_signum, _frame):
|
||||||
|
threading.Thread(target=server.shutdown, daemon=True).start()
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, stop)
|
||||||
|
signal.signal(signal.SIGINT, stop)
|
||||||
|
try:
|
||||||
|
server.serve_forever(poll_interval=0.25)
|
||||||
|
finally:
|
||||||
|
server.server_close()
|
||||||
|
try:
|
||||||
|
current = json.loads(state_file.read_text(encoding="utf-8"))
|
||||||
|
if current.get("pid") == os.getpid():
|
||||||
|
state_file.unlink()
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--root", required=True)
|
||||||
|
parser.add_argument("--port", type=int, default=0)
|
||||||
|
parser.add_argument("--token", required=True)
|
||||||
|
parser.add_argument("--state-file", required=True)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
return serve(
|
||||||
|
Path(args.root).expanduser().resolve(),
|
||||||
|
max(0, min(args.port, 65535)),
|
||||||
|
args.token,
|
||||||
|
Path(args.state_file).expanduser().resolve(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""CASAN commercial-readiness discovery and assessment.
|
||||||
|
|
||||||
|
This module is intentionally stdlib-only. It assesses the CASAN-owned
|
||||||
|
installation contract without changing application source or inventing domain
|
||||||
|
traceability. Core, Domain Pipeline, and Provider Telemetry are independent
|
||||||
|
dimensions because each has a different owner and activation lifecycle.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA_VERSION = 1
|
||||||
|
SKIP_DIRECTORIES = {
|
||||||
|
".casan", ".git", ".hg", ".idea", ".specify", ".svn", ".venv",
|
||||||
|
"coverage", "dist", "node_modules", "target", "vendor",
|
||||||
|
}
|
||||||
|
PROJECT_MARKERS = (
|
||||||
|
"package.json", "pyproject.toml", "pom.xml", "build.gradle",
|
||||||
|
"build.gradle.kts", "go.mod", "Cargo.toml", "composer.json",
|
||||||
|
)
|
||||||
|
SOURCE_ROOT_NAMES = ("src", "app", "apps", "packages", "lib", "services")
|
||||||
|
REQUIREMENT_NAMES = (
|
||||||
|
"requirements.md", "requirement.md", "srs.md", "spec.md",
|
||||||
|
"product-requirements.md",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def read_json(path: Path) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
return value if isinstance(value, dict) else {}
|
||||||
|
except (OSError, TypeError, ValueError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def write_json(path: Path, value: dict[str, Any]) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
temporary.write_text(
|
||||||
|
json.dumps(value, ensure_ascii=False, indent=2) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
os.replace(temporary, path)
|
||||||
|
|
||||||
|
|
||||||
|
def configure_domain(root: str | Path, manifest: str | Path) -> dict[str, Any]:
|
||||||
|
"""Select an existing project manifest without copying or editing app code."""
|
||||||
|
project_root = Path(root).expanduser().resolve()
|
||||||
|
selected = Path(manifest).expanduser()
|
||||||
|
if not selected.is_absolute():
|
||||||
|
selected = project_root / selected
|
||||||
|
selected = selected.resolve()
|
||||||
|
try:
|
||||||
|
relative = selected.relative_to(project_root).as_posix()
|
||||||
|
except ValueError as error:
|
||||||
|
raise ValueError("domain manifest must stay inside the project root") from error
|
||||||
|
if not selected.is_file():
|
||||||
|
raise ValueError("domain manifest does not exist")
|
||||||
|
try:
|
||||||
|
document = json.loads(selected.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, TypeError, ValueError) as error:
|
||||||
|
raise ValueError("domain manifest must be valid JSON") from error
|
||||||
|
if not isinstance(document, dict) or not (
|
||||||
|
document.get("project_id") or document.get("schema_version")
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"domain manifest must declare project_id or schema_version")
|
||||||
|
value = {
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"configured_at": utc_now(),
|
||||||
|
"manifest": relative,
|
||||||
|
"ownership": "project_source_referenced_by_casan",
|
||||||
|
}
|
||||||
|
write_json(project_root / ".casan" / "domain.json", value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _relative(root: Path, path: Path) -> str:
|
||||||
|
return path.relative_to(root).as_posix()
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_files(root: Path, maximum_depth: int = 5) -> list[Path]:
|
||||||
|
files: list[Path] = []
|
||||||
|
for current, directories, names in os.walk(root):
|
||||||
|
current_path = Path(current)
|
||||||
|
try:
|
||||||
|
depth = len(current_path.relative_to(root).parts)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
directories[:] = sorted(
|
||||||
|
name for name in directories
|
||||||
|
if name not in SKIP_DIRECTORIES and not name.startswith(".cache")
|
||||||
|
)
|
||||||
|
if depth >= maximum_depth:
|
||||||
|
directories[:] = []
|
||||||
|
for name in sorted(names):
|
||||||
|
files.append(current_path / name)
|
||||||
|
if len(files) >= 10_000:
|
||||||
|
return files
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def discover(root: str | Path) -> dict[str, Any]:
|
||||||
|
project_root = Path(root).expanduser().resolve()
|
||||||
|
priority_files: list[Path] = []
|
||||||
|
for relative in ("apps", "packages", "docs"):
|
||||||
|
candidate = project_root / relative
|
||||||
|
if candidate.is_dir():
|
||||||
|
priority_files.extend(_bounded_files(candidate, maximum_depth=6))
|
||||||
|
files = list(dict.fromkeys(
|
||||||
|
priority_files + _bounded_files(project_root)
|
||||||
|
))
|
||||||
|
markers = [
|
||||||
|
name for name in PROJECT_MARKERS
|
||||||
|
if (project_root / name).is_file()
|
||||||
|
]
|
||||||
|
source_roots = [
|
||||||
|
name for name in SOURCE_ROOT_NAMES
|
||||||
|
if (project_root / name).is_dir()
|
||||||
|
]
|
||||||
|
domain_packs: list[str] = []
|
||||||
|
manifests: list[str] = []
|
||||||
|
requirements: list[str] = []
|
||||||
|
for path in files:
|
||||||
|
relative = _relative(project_root, path)
|
||||||
|
lowered = path.name.lower()
|
||||||
|
if lowered == "domain-pack.yaml":
|
||||||
|
domain_packs.append(relative)
|
||||||
|
elif lowered == "project.manifest.json":
|
||||||
|
manifests.append(relative)
|
||||||
|
elif (
|
||||||
|
lowered in REQUIREMENT_NAMES
|
||||||
|
or ("requirement" in lowered and lowered.endswith(".md"))
|
||||||
|
):
|
||||||
|
requirements.append(relative)
|
||||||
|
return {
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"generated_at": utc_now(),
|
||||||
|
"project_root": str(project_root),
|
||||||
|
"project_markers": markers[:32],
|
||||||
|
"source_roots": source_roots[:32],
|
||||||
|
"domain_pack_candidates": domain_packs[:64],
|
||||||
|
"project_manifest_candidates": manifests[:64],
|
||||||
|
"requirements_candidates": requirements[:64],
|
||||||
|
"scan": {
|
||||||
|
"maximum_depth": 5,
|
||||||
|
"maximum_files_per_root": 10_000,
|
||||||
|
"priority_roots": [
|
||||||
|
value for value in ("apps", "packages", "docs")
|
||||||
|
if (project_root / value).is_dir()
|
||||||
|
],
|
||||||
|
"files_observed": len(files),
|
||||||
|
"truncated": len(files) >= 10_000,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _contains(path: Path, *needles: str) -> bool:
|
||||||
|
try:
|
||||||
|
if not path.is_file() or path.stat().st_size > 2 * 1024 * 1024:
|
||||||
|
return False
|
||||||
|
content = path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
return all(needle in content for needle in needles)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime_harness(root: Path, config: dict[str, Any]) -> Path | None:
|
||||||
|
runtime_path = config.get("runtime_path")
|
||||||
|
if not isinstance(runtime_path, str) or not runtime_path.strip():
|
||||||
|
return None
|
||||||
|
base = Path(runtime_path).expanduser()
|
||||||
|
if not base.is_absolute():
|
||||||
|
base = root / base
|
||||||
|
candidate = base.resolve()
|
||||||
|
nested = candidate / "packages" / "casan-harness"
|
||||||
|
if nested.is_dir():
|
||||||
|
return nested
|
||||||
|
return candidate if candidate.name == "casan-harness" and candidate.is_dir() else None
|
||||||
|
|
||||||
|
|
||||||
|
def _check(
|
||||||
|
gate: str,
|
||||||
|
title: str,
|
||||||
|
status: str,
|
||||||
|
summary: str,
|
||||||
|
evidence: list[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"gate": gate,
|
||||||
|
"legacy_gate": gate,
|
||||||
|
"category": "readiness_check",
|
||||||
|
"check_id": "ReadinessCheck.%s" % gate,
|
||||||
|
"title": title,
|
||||||
|
"status": status,
|
||||||
|
"summary": summary,
|
||||||
|
"evidence": evidence or [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_available(root: Path) -> bool:
|
||||||
|
candidates = (
|
||||||
|
root / ".specify" / "logs" / "level5" / "provider-usage.jsonl",
|
||||||
|
root / ".specify" / "logs" / "cost" / "metrics.jsonl",
|
||||||
|
)
|
||||||
|
for path in candidates:
|
||||||
|
try:
|
||||||
|
if path.is_file() and path.stat().st_size > 2:
|
||||||
|
text = path.read_text(encoding="utf-8", errors="replace")[-262_144:]
|
||||||
|
for line in reversed(text.splitlines()):
|
||||||
|
try:
|
||||||
|
row = json.loads(line)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
isinstance(row.get("provider"), str) and row["provider"].strip()
|
||||||
|
or isinstance(row.get("model"), str) and row["model"].strip()
|
||||||
|
or isinstance(row.get("total_tokens"), (int, float))
|
||||||
|
or isinstance(row.get("cost_estimate"), (int, float))
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def assess(
|
||||||
|
root: str | Path,
|
||||||
|
*,
|
||||||
|
persist: bool = False,
|
||||||
|
client_state: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
project_root = Path(root).expanduser().resolve()
|
||||||
|
config_path = project_root / ".casan" / "config.json"
|
||||||
|
lock_path = project_root / ".casan" / "version.lock"
|
||||||
|
bootstrap_path = project_root / ".casan" / "casan-hook.py"
|
||||||
|
config = read_json(config_path)
|
||||||
|
lock = read_json(lock_path)
|
||||||
|
domain_config = read_json(project_root / ".casan" / "domain.json")
|
||||||
|
previous_readiness = read_json(project_root / ".casan" / "readiness.json")
|
||||||
|
if client_state is None:
|
||||||
|
previous_core = (
|
||||||
|
(previous_readiness.get("dimensions") or {}).get("core") or {}
|
||||||
|
)
|
||||||
|
previous_client_state = previous_core.get("client_activation")
|
||||||
|
client_state = (
|
||||||
|
previous_client_state
|
||||||
|
if isinstance(previous_client_state, dict) else {}
|
||||||
|
)
|
||||||
|
discovery = discover(project_root)
|
||||||
|
clients = [
|
||||||
|
str(value) for value in config.get("clients", [])
|
||||||
|
if isinstance(value, str)
|
||||||
|
]
|
||||||
|
runtime = _runtime_harness(project_root, config)
|
||||||
|
|
||||||
|
h1_ok = bool(config.get("project_id") and config.get("schema_version"))
|
||||||
|
h2_evidence: list[str] = []
|
||||||
|
client_integrations: list[dict[str, Any]] = []
|
||||||
|
for client in clients:
|
||||||
|
declared_state = (client_state or {}).get(client) or {}
|
||||||
|
if client == "claude":
|
||||||
|
relative = ".claude/settings.json"
|
||||||
|
present = _contains(
|
||||||
|
project_root / relative, "casan-hook.py", "--client claude")
|
||||||
|
operational = bool(
|
||||||
|
present and declared_state.get("operational", True))
|
||||||
|
elif client == "codex":
|
||||||
|
relative = ".codex/hooks.json"
|
||||||
|
present = _contains(
|
||||||
|
project_root / relative, "casan-hook.py", "--client codex")
|
||||||
|
trust_state = declared_state
|
||||||
|
operational = bool(
|
||||||
|
present and trust_state.get("verified")
|
||||||
|
and trust_state.get("operational")
|
||||||
|
)
|
||||||
|
elif client == "vscode-copilot":
|
||||||
|
relative = ".casan/casan-hook.py"
|
||||||
|
present = (project_root / relative).is_file()
|
||||||
|
operational = bool(
|
||||||
|
present and declared_state.get("operational", True))
|
||||||
|
else:
|
||||||
|
client_integrations.append({
|
||||||
|
"client": client,
|
||||||
|
"status": "unsupported",
|
||||||
|
"evidence": None,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
if present:
|
||||||
|
h2_evidence.append(relative)
|
||||||
|
client_integrations.append({
|
||||||
|
"client": client,
|
||||||
|
"status": (
|
||||||
|
"operational" if operational else
|
||||||
|
"action_required" if present else
|
||||||
|
"missing"
|
||||||
|
),
|
||||||
|
"evidence": relative if present else None,
|
||||||
|
})
|
||||||
|
operational_clients = sum(
|
||||||
|
item["status"] == "operational" for item in client_integrations)
|
||||||
|
configured_clients = sum(
|
||||||
|
item["status"] in {"operational", "action_required"}
|
||||||
|
for item in client_integrations)
|
||||||
|
h2_status = (
|
||||||
|
"warning" if operational_clients and operational_clients < len(client_integrations) else
|
||||||
|
"pass" if operational_clients else
|
||||||
|
"action_required" if configured_clients else
|
||||||
|
"fail"
|
||||||
|
)
|
||||||
|
|
||||||
|
report_engine = (
|
||||||
|
runtime / "scripts" / "python" / "local_report.py"
|
||||||
|
if runtime else Path()
|
||||||
|
)
|
||||||
|
h3_ok = bool(runtime and report_engine.is_file())
|
||||||
|
enforcement = str(config.get("enforcement_mode") or "unknown")
|
||||||
|
h4_status = "pass" if enforcement == "enforce" else "warning"
|
||||||
|
state_dirs = (
|
||||||
|
project_root / ".specify" / "logs",
|
||||||
|
project_root / ".specify" / "state",
|
||||||
|
)
|
||||||
|
h5_ok = all(
|
||||||
|
path.is_dir() and os.access(path, os.W_OK | os.X_OK)
|
||||||
|
for path in state_dirs
|
||||||
|
)
|
||||||
|
provider_available = _provider_available(project_root)
|
||||||
|
h7_ok = bool(
|
||||||
|
lock.get("harness_hash")
|
||||||
|
and lock.get("harness_version")
|
||||||
|
and bootstrap_path.is_file()
|
||||||
|
)
|
||||||
|
|
||||||
|
trust = (client_state or {}).get("codex") or {}
|
||||||
|
codex_action_required = (
|
||||||
|
"codex" in clients
|
||||||
|
and not (trust.get("verified") and trust.get("operational"))
|
||||||
|
)
|
||||||
|
|
||||||
|
checks = [
|
||||||
|
_check("H1", "Project contract", "pass" if h1_ok else "fail",
|
||||||
|
"CASAN project identity and schema are valid." if h1_ok
|
||||||
|
else "CASAN project configuration is missing or invalid.",
|
||||||
|
[".casan/config.json"] if h1_ok else []),
|
||||||
|
_check("H2", "Client activation", h2_status,
|
||||||
|
f"{operational_clients} configured client route(s) are operational."
|
||||||
|
if h2_status in {"pass", "warning"} else
|
||||||
|
"Configured client hooks require an activation step."
|
||||||
|
if h2_status == "action_required" else
|
||||||
|
"No complete supported client integration is installed.",
|
||||||
|
h2_evidence),
|
||||||
|
_check("H3", "Local report capability", "pass" if h3_ok else "fail",
|
||||||
|
"Core Local Assurance Viewer and on-demand export are available."
|
||||||
|
if h3_ok else "The resolved Core report runtime is unavailable.",
|
||||||
|
[str(report_engine)] if h3_ok else []),
|
||||||
|
_check("H4", "Enforcement boundary", h4_status,
|
||||||
|
"Policy enforcement is active." if enforcement == "enforce"
|
||||||
|
else "Observe mode records evidence but does not enforce decisions.",
|
||||||
|
[".casan/config.json"]),
|
||||||
|
_check("H5", "Evidence persistence", "pass" if h5_ok else "fail",
|
||||||
|
"Local logs and state directories are writable." if h5_ok
|
||||||
|
else "CASAN evidence directories are missing or not writable.",
|
||||||
|
[".specify/logs", ".specify/state"] if h5_ok else []),
|
||||||
|
_check("H6", "Provider telemetry", "pass" if provider_available else "optional_unavailable",
|
||||||
|
"Provider token/cost telemetry is available." if provider_available
|
||||||
|
else "Core runtime evidence is available; provider token/cost telemetry is optional and not observed yet."),
|
||||||
|
_check("H7", "Pinned runtime", "pass" if h7_ok else "fail",
|
||||||
|
"The project bootstrap and pinned runtime contract are present."
|
||||||
|
if h7_ok else "The bootstrap or version pin is incomplete.",
|
||||||
|
[".casan/casan-hook.py", ".casan/version.lock"] if h7_ok else []),
|
||||||
|
]
|
||||||
|
blocking = any(item["status"] == "fail" for item in checks)
|
||||||
|
action_required = any(
|
||||||
|
item["status"] == "action_required" for item in checks
|
||||||
|
)
|
||||||
|
attention = any(item["status"] == "warning" for item in checks)
|
||||||
|
core_status = (
|
||||||
|
"blocked" if blocking else
|
||||||
|
"action_required" if action_required else
|
||||||
|
"ready_with_attention" if attention else
|
||||||
|
"ready"
|
||||||
|
)
|
||||||
|
|
||||||
|
manifests = discovery["project_manifest_candidates"]
|
||||||
|
packs = discovery["domain_pack_candidates"]
|
||||||
|
selected_manifest = domain_config.get("manifest")
|
||||||
|
selected_path = (
|
||||||
|
project_root / selected_manifest
|
||||||
|
if isinstance(selected_manifest, str) else None
|
||||||
|
)
|
||||||
|
if selected_path and selected_path.is_file():
|
||||||
|
domain_status = "configured"
|
||||||
|
domain_summary = (
|
||||||
|
"A project manifest is selected. Run project validation or the "
|
||||||
|
"project-owned pipeline to produce domain acceptance evidence."
|
||||||
|
)
|
||||||
|
elif manifests:
|
||||||
|
domain_status = "discovered"
|
||||||
|
domain_summary = (
|
||||||
|
"Project manifest candidates were discovered but none is selected. "
|
||||||
|
"Use `casan domain configure <manifest>` when domain acceptance is needed."
|
||||||
|
)
|
||||||
|
elif packs:
|
||||||
|
domain_status = "discovered"
|
||||||
|
domain_summary = (
|
||||||
|
"A Domain Pack was discovered, but no project manifest proves a "
|
||||||
|
"runnable domain pipeline."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
domain_status = "not_configured"
|
||||||
|
domain_summary = (
|
||||||
|
"No Domain Pack is configured. Core prompt assurance and reports "
|
||||||
|
"remain fully available without modifying application source."
|
||||||
|
)
|
||||||
|
|
||||||
|
telemetry_status = "available" if provider_available else "optional_unavailable"
|
||||||
|
next_actions: list[dict[str, str]] = []
|
||||||
|
if blocking:
|
||||||
|
next_actions.append({
|
||||||
|
"code": "repair_core",
|
||||||
|
"message": "Run `casan init` again, then `casan doctor`.",
|
||||||
|
})
|
||||||
|
if codex_action_required:
|
||||||
|
reason = str(trust.get("reason") or trust.get("status") or "not_verified")
|
||||||
|
next_actions.append({
|
||||||
|
"code": "verify_codex_trust",
|
||||||
|
"message": (
|
||||||
|
"Open `/hooks` in Codex and review the project hooks, then run "
|
||||||
|
f"`casan doctor` (current trust state: {reason})."
|
||||||
|
),
|
||||||
|
})
|
||||||
|
for integration in client_integrations:
|
||||||
|
if (
|
||||||
|
integration["status"] != "operational"
|
||||||
|
and integration["client"] != "codex"
|
||||||
|
):
|
||||||
|
next_actions.append({
|
||||||
|
"code": "activate_%s" % integration["client"].replace("-", "_"),
|
||||||
|
"message": (
|
||||||
|
f"Complete activation for the selected "
|
||||||
|
f"{integration['client']} client route, then run `casan doctor`."
|
||||||
|
),
|
||||||
|
})
|
||||||
|
optional_actions: list[dict[str, str]] = []
|
||||||
|
if domain_status != "configured":
|
||||||
|
optional_actions.append({
|
||||||
|
"code": "configure_domain_pipeline",
|
||||||
|
"message": (
|
||||||
|
"Optional: install/configure a CASAN Domain Pack when the team "
|
||||||
|
"needs project-specific SRS→test acceptance."
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
readiness = {
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"generated_at": utc_now(),
|
||||||
|
"project_id": str(config.get("project_id") or project_root.name),
|
||||||
|
"overall_status": core_status,
|
||||||
|
"dimensions": {
|
||||||
|
"core": {
|
||||||
|
"status": core_status,
|
||||||
|
"summary": (
|
||||||
|
"Core is ready for governed prompts and visual local reports."
|
||||||
|
if core_status == "ready" else
|
||||||
|
"Core is operational through at least one client route; another selected route needs attention."
|
||||||
|
if core_status == "ready_with_attention" else
|
||||||
|
"Core requires an explicit activation step."
|
||||||
|
if core_status == "action_required" else
|
||||||
|
"Core installation is incomplete."
|
||||||
|
),
|
||||||
|
"acceptance": checks,
|
||||||
|
"client_activation": client_state or {},
|
||||||
|
"client_integrations": client_integrations,
|
||||||
|
},
|
||||||
|
"domain_pipeline": {
|
||||||
|
"status": domain_status,
|
||||||
|
"summary": domain_summary,
|
||||||
|
"domain_packs": packs,
|
||||||
|
"project_manifests": manifests,
|
||||||
|
"selected_manifest": selected_manifest
|
||||||
|
if selected_path and selected_path.is_file() else None,
|
||||||
|
},
|
||||||
|
"provider_telemetry": {
|
||||||
|
"status": telemetry_status,
|
||||||
|
"summary": (
|
||||||
|
"Provider token and cost telemetry has been observed."
|
||||||
|
if provider_available else
|
||||||
|
"Provider token and cost telemetry is optional and has not "
|
||||||
|
"been observed; this does not block Core."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"next_actions": next_actions,
|
||||||
|
"optional_actions": optional_actions,
|
||||||
|
"discovery_file": ".casan/discovery.json",
|
||||||
|
}
|
||||||
|
if persist:
|
||||||
|
write_json(project_root / ".casan" / "discovery.json", discovery)
|
||||||
|
write_json(project_root / ".casan" / "readiness.json", readiness)
|
||||||
|
return readiness
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--root", default=os.getcwd())
|
||||||
|
parser.add_argument("--write", action="store_true")
|
||||||
|
arguments = parser.parse_args()
|
||||||
|
print(json.dumps(
|
||||||
|
assess(arguments.root, persist=arguments.write),
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
))
|
||||||
@@ -1,102 +1,188 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Read-only CASAN report discovery for Core installations.
|
"""CASAN Core report, export and Local Assurance Viewer commands."""
|
||||||
|
|
||||||
Core owns the trace/evidence source of truth but not the Control Plane UI. This
|
|
||||||
helper exposes the latest materialized receipt and opens an enrolled dashboard
|
|
||||||
without generating HTML on the prompt hot path.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
|
import secrets
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
|
from urllib.error import URLError
|
||||||
|
from urllib.parse import quote, urlencode
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
import webbrowser
|
import webbrowser
|
||||||
from urllib.parse import quote
|
|
||||||
|
from local_report import (
|
||||||
|
SAFE_ID,
|
||||||
|
h6_report,
|
||||||
|
read_json,
|
||||||
|
report_html,
|
||||||
|
run_report,
|
||||||
|
)
|
||||||
|
from readiness import (
|
||||||
|
assess as assess_readiness,
|
||||||
|
configure_domain,
|
||||||
|
discover as discover_project,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def find_root(start):
|
def find_root(start: str) -> Path:
|
||||||
current = os.path.abspath(start)
|
current = Path(start).expanduser().resolve()
|
||||||
while current != os.path.dirname(current):
|
for candidate in (current, *current.parents):
|
||||||
if os.path.isfile(os.path.join(current, ".casan", "config.json")):
|
if (candidate / ".casan" / "config.json").is_file():
|
||||||
return current
|
return candidate
|
||||||
current = os.path.dirname(current)
|
return current
|
||||||
return os.path.abspath(start)
|
|
||||||
|
|
||||||
|
|
||||||
def read_json(path):
|
def latest_receipt(root: Path) -> dict:
|
||||||
|
return read_json(root / ".specify" / "state" / "latest-run.json")
|
||||||
|
|
||||||
|
|
||||||
|
def require_trace(value, receipt) -> str:
|
||||||
|
trace_id = str(value or receipt.get("trace_id") or "")
|
||||||
|
if not SAFE_ID.fullmatch(trace_id):
|
||||||
|
raise ValueError("no safe trace id was supplied and no latest run exists")
|
||||||
|
return trace_id
|
||||||
|
|
||||||
|
|
||||||
|
def viewer_state_path(root: Path) -> Path:
|
||||||
|
return root / ".specify" / "state" / "local-viewer.json"
|
||||||
|
|
||||||
|
|
||||||
|
def viewer_log_path(root: Path) -> Path:
|
||||||
|
return root / ".specify" / "state" / "local-viewer.log"
|
||||||
|
|
||||||
|
|
||||||
|
def load_viewer_state(root: Path) -> dict:
|
||||||
|
state = read_json(viewer_state_path(root))
|
||||||
|
if state.get("project_root") != str(root.resolve()):
|
||||||
|
return {}
|
||||||
|
if not isinstance(state.get("port"), int) or not isinstance(state.get("token"), str):
|
||||||
|
return {}
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def health(state: dict, timeout: float = 0.7) -> dict:
|
||||||
|
if not state:
|
||||||
|
return {}
|
||||||
|
request = Request(
|
||||||
|
f"http://127.0.0.1:{state['port']}/healthz",
|
||||||
|
headers={"X-CASAN-Viewer-Token": state["token"]},
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
with open(path, "r", encoding="utf-8") as handle:
|
with urlopen(request, timeout=timeout) as response:
|
||||||
value = json.load(handle)
|
payload = json.loads(response.read().decode("utf-8"))
|
||||||
return value if isinstance(value, dict) else {}
|
return payload if response.status == 200 and isinstance(payload, dict) else {}
|
||||||
except (OSError, ValueError):
|
except (OSError, URLError, ValueError):
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def dashboard_base(root):
|
def ensure_viewer(root: Path, port: int = 0) -> dict:
|
||||||
configured = os.environ.get("CASAN_DASHBOARD_URL")
|
existing = load_viewer_state(root)
|
||||||
if not configured:
|
if health(existing):
|
||||||
control_plane = read_json(
|
return existing
|
||||||
os.path.join(root, ".casan", "config.json")
|
state_path = viewer_state_path(root)
|
||||||
).get("control_plane")
|
state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
if isinstance(control_plane, dict):
|
lock_path = state_path.with_suffix(".lock")
|
||||||
configured = control_plane.get("dashboard_url")
|
acquired = False
|
||||||
base = str(configured or "").strip().rstrip("/")
|
for _attempt in range(70):
|
||||||
return base if re.match(r"^https?://[a-zA-Z0-9]", base) else None
|
try:
|
||||||
|
lock_path.mkdir()
|
||||||
|
acquired = True
|
||||||
|
break
|
||||||
|
except FileExistsError:
|
||||||
|
state = load_viewer_state(root)
|
||||||
|
if health(state):
|
||||||
|
return state
|
||||||
|
time.sleep(0.1)
|
||||||
|
if not acquired:
|
||||||
|
try:
|
||||||
|
if time.time() - lock_path.stat().st_mtime > 15:
|
||||||
|
lock_path.rmdir()
|
||||||
|
lock_path.mkdir()
|
||||||
|
acquired = True
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if not acquired:
|
||||||
|
raise RuntimeError("another viewer start is still in progress")
|
||||||
|
try:
|
||||||
|
return _start_viewer(root, port, state_path)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
lock_path.rmdir()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def latest_receipt(root):
|
def _start_viewer(root: Path, port: int, state_path: Path) -> dict:
|
||||||
return read_json(
|
existing = load_viewer_state(root)
|
||||||
os.path.join(root, ".specify", "state", "latest-run.json")
|
if health(existing):
|
||||||
)
|
return existing
|
||||||
|
try:
|
||||||
|
state_path.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
log_path = viewer_log_path(root)
|
||||||
|
try:
|
||||||
|
if log_path.stat().st_size > 1024 * 1024:
|
||||||
|
rotated = log_path.with_suffix(".log.1")
|
||||||
|
try:
|
||||||
|
rotated.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
os.replace(log_path, rotated)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
server = Path(__file__).with_name("local_viewer.py")
|
||||||
|
with log_path.open("ab", buffering=0) as log:
|
||||||
|
popen_options = {
|
||||||
|
"cwd": str(root),
|
||||||
|
"stdin": subprocess.DEVNULL,
|
||||||
|
"stdout": log,
|
||||||
|
"stderr": subprocess.STDOUT,
|
||||||
|
"close_fds": True,
|
||||||
|
}
|
||||||
|
if os.name == "nt":
|
||||||
|
popen_options["creationflags"] = (
|
||||||
|
getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
||||||
|
| getattr(subprocess, "DETACHED_PROCESS", 0)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
popen_options["start_new_session"] = True
|
||||||
|
subprocess.Popen(
|
||||||
|
[
|
||||||
|
sys.executable, str(server),
|
||||||
|
"--root", str(root),
|
||||||
|
"--port", str(port),
|
||||||
|
"--token", token,
|
||||||
|
"--state-file", str(state_path),
|
||||||
|
],
|
||||||
|
**popen_options,
|
||||||
|
)
|
||||||
|
for _attempt in range(50):
|
||||||
|
time.sleep(0.1)
|
||||||
|
state = load_viewer_state(root)
|
||||||
|
if state.get("token") == token and health(state):
|
||||||
|
return state
|
||||||
|
raise RuntimeError(f"viewer did not become ready; inspect {log_path}")
|
||||||
|
|
||||||
|
|
||||||
def require_trace(value, receipt):
|
def viewer_url(state: dict, trace_id: str | None = None, view: str | None = None) -> str:
|
||||||
trace_id = value or receipt.get("trace_id")
|
query = {"token": state["token"]}
|
||||||
if not trace_id or not re.match(r"^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$", str(trace_id)):
|
if trace_id:
|
||||||
raise ValueError("no safe trace id was supplied and no latest run exists")
|
query.update({"view": "runs", "trace": trace_id})
|
||||||
return str(trace_id)
|
elif view:
|
||||||
|
query["view"] = view
|
||||||
|
return f"http://127.0.0.1:{state['port']}/?{urlencode(query)}"
|
||||||
|
|
||||||
|
|
||||||
def report_url(root, trace_id):
|
def open_or_print(url: str, no_open: bool) -> int:
|
||||||
base = dashboard_base(root)
|
|
||||||
if not base:
|
|
||||||
return None
|
|
||||||
return "%s/runs?trace=%s" % (base, quote(trace_id, safe=""))
|
|
||||||
|
|
||||||
|
|
||||||
def export_url(root, trace_id, export_format):
|
|
||||||
base = dashboard_base(root)
|
|
||||||
if not base:
|
|
||||||
return None
|
|
||||||
return "%s/api/v1/reports/run/%s/export?format=%s" % (
|
|
||||||
base,
|
|
||||||
quote(trace_id, safe=""),
|
|
||||||
export_format,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def print_receipt(receipt):
|
|
||||||
if not receipt:
|
|
||||||
print("CASAN_REPORT_NONE — no finalized prompt receipt exists yet")
|
|
||||||
return 1
|
|
||||||
print("CASAN %s" % ("CERTIFIED" if receipt.get("certified") else "NON-CERTIFIED"))
|
|
||||||
print("trace_id=%s" % (receipt.get("trace_id") or "unknown"))
|
|
||||||
print("project_id=%s" % (receipt.get("project_id") or "unknown"))
|
|
||||||
print("h6_quality=%s" % (receipt.get("telemetry_quality") or "unknown"))
|
|
||||||
print("duration_ms=%s tool_calls=%s failures=%s" % (
|
|
||||||
receipt.get("duration_ms"),
|
|
||||||
receipt.get("tool_calls", 0),
|
|
||||||
receipt.get("failures", 0),
|
|
||||||
))
|
|
||||||
if receipt.get("report_url"):
|
|
||||||
print("report_url=%s" % receipt["report_url"])
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def open_or_print(url, no_open):
|
|
||||||
print(url)
|
print(url)
|
||||||
if no_open:
|
if no_open:
|
||||||
return 0
|
return 0
|
||||||
@@ -106,52 +192,232 @@ def open_or_print(url, no_open):
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None):
|
def print_receipt(receipt: dict, root: Path) -> int:
|
||||||
parser = argparse.ArgumentParser(description="CASAN Core report discovery")
|
if not receipt:
|
||||||
parser.add_argument("--root", default=os.environ.get("CASAN_APP_ROOT") or os.getcwd())
|
print("CASAN_REPORT_NONE — no finalized prompt receipt exists yet")
|
||||||
commands = parser.add_subparsers(dest="command", required=True)
|
print("next=complete a prompt, then run `casan view`")
|
||||||
|
return 1
|
||||||
|
certified = receipt.get("certified") is True
|
||||||
|
print("CASAN %s" % ("CERTIFIED" if certified else "NON-CERTIFIED"))
|
||||||
|
print("trace_id=%s" % (receipt.get("trace_id") or "unknown"))
|
||||||
|
print("project_id=%s" % (receipt.get("project_id") or "unknown"))
|
||||||
|
print("h6_quality=%s" % (receipt.get("telemetry_quality") or "unknown"))
|
||||||
|
print("duration_ms=%s tool_calls=%s failures=%s" % (
|
||||||
|
receipt.get("duration_ms"),
|
||||||
|
receipt.get("tool_calls", 0),
|
||||||
|
receipt.get("failures", 0),
|
||||||
|
))
|
||||||
|
print("view_command=casan view %s" % (receipt.get("trace_id") or ""))
|
||||||
|
active_viewer = load_viewer_state(root)
|
||||||
|
if health(active_viewer):
|
||||||
|
print("local_report_url=%s" % viewer_url(
|
||||||
|
active_viewer, str(receipt.get("trace_id") or "") or None))
|
||||||
|
if receipt.get("report_url"):
|
||||||
|
print("central_report_url=%s" % receipt["report_url"])
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def export_report(root: Path, args, receipt: dict) -> int:
|
||||||
|
if args.h6:
|
||||||
|
report = h6_report(
|
||||||
|
root,
|
||||||
|
project=args.project,
|
||||||
|
run=args.trace_id,
|
||||||
|
from_date=args.from_date,
|
||||||
|
to_date=args.to_date,
|
||||||
|
limit=args.limit,
|
||||||
|
)
|
||||||
|
stem = report["report_id"].lower()
|
||||||
|
kind = "h6"
|
||||||
|
else:
|
||||||
|
trace_id = require_trace(args.trace_id, receipt)
|
||||||
|
report = run_report(root, trace_id)
|
||||||
|
if report["verdict"] == "not_found":
|
||||||
|
print("CASAN_REPORT_NOT_FOUND — no evidence exists for that trace", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
stem = re.sub(r"[^a-zA-Z0-9._-]", "-", report["report_id"]).lower()
|
||||||
|
kind = "run"
|
||||||
|
destination = Path(args.output or f"{stem}.{args.format}").expanduser()
|
||||||
|
if not destination.is_absolute():
|
||||||
|
destination = Path.cwd() / destination
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if args.format == "json":
|
||||||
|
body = json.dumps(report, ensure_ascii=False, indent=2) + "\n"
|
||||||
|
else:
|
||||||
|
body = report_html(report, kind)
|
||||||
|
destination.write_text(body, encoding="utf-8")
|
||||||
|
print(str(destination.resolve()))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def viewer_command(root: Path, action: str, port: int, no_open: bool) -> int:
|
||||||
|
if action in {"start", "open"}:
|
||||||
|
try:
|
||||||
|
state = ensure_viewer(root, port)
|
||||||
|
except RuntimeError as error:
|
||||||
|
print(f"CASAN_VIEW_START_FAILED — {error}", file=sys.stderr)
|
||||||
|
return 3
|
||||||
|
return open_or_print(viewer_url(state), no_open or action == "start")
|
||||||
|
state = load_viewer_state(root)
|
||||||
|
status = health(state)
|
||||||
|
if action == "status":
|
||||||
|
if not status:
|
||||||
|
print("CASAN_VIEW_STOPPED")
|
||||||
|
return 1
|
||||||
|
print(json.dumps({
|
||||||
|
"status": "running",
|
||||||
|
"pid": state.get("pid"),
|
||||||
|
"url": viewer_url(state),
|
||||||
|
"project_id": status.get("project_id"),
|
||||||
|
"mode": "local_core",
|
||||||
|
}, indent=2))
|
||||||
|
return 0
|
||||||
|
if action == "stop":
|
||||||
|
if not status:
|
||||||
|
try:
|
||||||
|
viewer_state_path(root).unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
print("CASAN_VIEW_ALREADY_STOPPED")
|
||||||
|
return 0
|
||||||
|
request = Request(
|
||||||
|
f"http://127.0.0.1:{state['port']}/__shutdown",
|
||||||
|
headers={"X-CASAN-Viewer-Token": state["token"]},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
urlopen(request, timeout=1).read()
|
||||||
|
except (OSError, URLError):
|
||||||
|
print("CASAN_VIEW_STOP_FAILED", file=sys.stderr)
|
||||||
|
return 3
|
||||||
|
for _attempt in range(30):
|
||||||
|
time.sleep(0.1)
|
||||||
|
if not health(state, timeout=0.1):
|
||||||
|
print("CASAN_VIEW_STOPPED")
|
||||||
|
return 0
|
||||||
|
print("CASAN_VIEW_STOP_PENDING")
|
||||||
|
return 0
|
||||||
|
return 64
|
||||||
|
|
||||||
|
|
||||||
|
def parser() -> argparse.ArgumentParser:
|
||||||
|
value = argparse.ArgumentParser(description=__doc__)
|
||||||
|
value.add_argument("--root", default=os.environ.get("CASAN_APP_ROOT") or os.getcwd())
|
||||||
|
commands = value.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
latest = commands.add_parser("latest", help="show the latest finalized prompt receipt")
|
latest = commands.add_parser("latest", help="show the latest finalized prompt receipt")
|
||||||
latest.add_argument("--json", action="store_true")
|
latest.add_argument("--json", action="store_true")
|
||||||
|
|
||||||
view = commands.add_parser("view", help="open a trace in the enrolled Control Plane")
|
readiness = commands.add_parser(
|
||||||
|
"readiness",
|
||||||
|
help="assess Core, Domain Pipeline, and Provider Telemetry separately",
|
||||||
|
)
|
||||||
|
readiness.add_argument("--refresh", action="store_true")
|
||||||
|
readiness.add_argument("--json", action="store_true")
|
||||||
|
|
||||||
|
domain = commands.add_parser(
|
||||||
|
"domain",
|
||||||
|
help="discover or select an optional project-owned Domain Pack manifest",
|
||||||
|
)
|
||||||
|
domain.add_argument("action", choices=["status", "discover", "configure"])
|
||||||
|
domain.add_argument("manifest", nargs="?")
|
||||||
|
domain.add_argument("--json", action="store_true")
|
||||||
|
|
||||||
|
view = commands.add_parser("view", help="open a trace in the local Core viewer")
|
||||||
view.add_argument("trace_id", nargs="?")
|
view.add_argument("trace_id", nargs="?")
|
||||||
view.add_argument("--no-open", action="store_true", help="print the URL without opening a browser")
|
view.add_argument("--no-open", action="store_true")
|
||||||
|
view.add_argument("--port", type=int, default=0)
|
||||||
|
|
||||||
export = commands.add_parser("export", help="download a trace evidence snapshot on demand")
|
export = commands.add_parser("export", help="write a self-contained report on demand")
|
||||||
export.add_argument("trace_id", nargs="?")
|
export.add_argument("trace_id", nargs="?")
|
||||||
|
export.add_argument("--h6", action="store_true", help="export the H6 dossier instead of a run dossier")
|
||||||
export.add_argument("--format", choices=["html", "json"], default="html")
|
export.add_argument("--format", choices=["html", "json"], default="html")
|
||||||
export.add_argument("--no-open", action="store_true", help="print the URL without opening a browser")
|
export.add_argument("--output", "-o")
|
||||||
|
export.add_argument("--project")
|
||||||
|
export.add_argument("--from", dest="from_date")
|
||||||
|
export.add_argument("--to", dest="to_date")
|
||||||
|
export.add_argument("--limit", type=int, default=50)
|
||||||
|
|
||||||
args = parser.parse_args(argv)
|
viewer = commands.add_parser("viewer", help="manage the local Core viewer")
|
||||||
|
viewer.add_argument("action", choices=["start", "status", "stop", "open"])
|
||||||
|
viewer.add_argument("--port", type=int, default=0)
|
||||||
|
viewer.add_argument("--no-open", action="store_true")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None) -> int:
|
||||||
|
args = parser().parse_args(argv)
|
||||||
root = find_root(args.root)
|
root = find_root(args.root)
|
||||||
receipt = latest_receipt(root)
|
receipt = latest_receipt(root)
|
||||||
|
|
||||||
if args.command == "latest":
|
if args.command == "latest":
|
||||||
if args.json:
|
if args.json:
|
||||||
print(json.dumps(receipt, ensure_ascii=False, indent=2))
|
print(json.dumps(receipt, ensure_ascii=False, indent=2))
|
||||||
return 0 if receipt else 1
|
return 0 if receipt else 1
|
||||||
return print_receipt(receipt)
|
return print_receipt(receipt, root)
|
||||||
|
if args.command == "readiness":
|
||||||
|
result = assess_readiness(root, persist=args.refresh)
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||||
|
else:
|
||||||
|
dimensions = result["dimensions"]
|
||||||
|
print(f"CASAN readiness — {result['project_id']}")
|
||||||
|
print(f" Core {dimensions['core']['status']}")
|
||||||
|
print(f" Domain Pipeline {dimensions['domain_pipeline']['status']}")
|
||||||
|
print(f" Provider Telemetry {dimensions['provider_telemetry']['status']}")
|
||||||
|
for action in result.get("next_actions", []):
|
||||||
|
print(f" → {action['message']}")
|
||||||
|
return 2 if result["overall_status"] == "blocked" else 0
|
||||||
|
if args.command == "domain":
|
||||||
|
try:
|
||||||
|
if args.action == "discover":
|
||||||
|
payload = discover_project(root)
|
||||||
|
elif args.action == "configure":
|
||||||
|
if not args.manifest:
|
||||||
|
raise ValueError(
|
||||||
|
"`casan domain configure` requires a manifest path")
|
||||||
|
configure_domain(root, args.manifest)
|
||||||
|
payload = assess_readiness(root, persist=True)
|
||||||
|
else:
|
||||||
|
payload = assess_readiness(root, persist=False)[
|
||||||
|
"dimensions"]["domain_pipeline"]
|
||||||
|
except ValueError as error:
|
||||||
|
print(f"CASAN_DOMAIN_INVALID — {error}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||||
|
elif args.action == "discover":
|
||||||
|
print("CASAN Domain discovery")
|
||||||
|
print(
|
||||||
|
f" Manifests {len(payload['project_manifest_candidates'])}")
|
||||||
|
print(f" Domain Packs {len(payload['domain_pack_candidates'])}")
|
||||||
|
print(f" Requirements {len(payload['requirements_candidates'])}")
|
||||||
|
else:
|
||||||
|
dimension = (
|
||||||
|
payload["dimensions"]["domain_pipeline"]
|
||||||
|
if "dimensions" in payload else payload
|
||||||
|
)
|
||||||
|
print(f"CASAN Domain Pipeline — {dimension['status']}")
|
||||||
|
print(f" {dimension['summary']}")
|
||||||
|
return 0
|
||||||
|
if args.command == "viewer":
|
||||||
|
return viewer_command(root, args.action, args.port, args.no_open)
|
||||||
|
if args.command == "export":
|
||||||
|
try:
|
||||||
|
return export_report(root, args, receipt)
|
||||||
|
except ValueError as error:
|
||||||
|
print(f"CASAN_REPORT_INVALID — {error}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
try:
|
try:
|
||||||
trace_id = require_trace(args.trace_id, receipt)
|
if args.trace_id and not SAFE_ID.fullmatch(str(args.trace_id)):
|
||||||
|
raise ValueError("unsafe trace id")
|
||||||
|
trace_id = str(args.trace_id or receipt.get("trace_id") or "") or None
|
||||||
|
state = ensure_viewer(root, args.port)
|
||||||
except ValueError as error:
|
except ValueError as error:
|
||||||
print("CASAN_REPORT_NOT_FOUND — %s" % error, file=sys.stderr)
|
print(f"CASAN_REPORT_NOT_FOUND — {error}", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
|
except RuntimeError as error:
|
||||||
url = (
|
print(f"CASAN_VIEW_START_FAILED — {error}", file=sys.stderr)
|
||||||
report_url(root, trace_id)
|
|
||||||
if args.command == "view"
|
|
||||||
else export_url(root, trace_id, args.format)
|
|
||||||
)
|
|
||||||
if not url:
|
|
||||||
print(
|
|
||||||
"CASAN_CONTROL_PLANE_NOT_ENROLLED — run `casan init "
|
|
||||||
"--dashboard-url https://your-casan.example` or set CASAN_DASHBOARD_URL",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
return 3
|
return 3
|
||||||
return open_or_print(url, args.no_open)
|
return open_or_print(viewer_url(state, trace_id), args.no_open)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Deterministic unit and cross-runtime conformance tests for the kernel."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from copy import deepcopy
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
HARNESS_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(HARNESS_ROOT))
|
||||||
|
|
||||||
|
from kernel.adapters import AgenticBridgeAdapter, NativeHarnessAdapter
|
||||||
|
from kernel.contracts import validate_bundle
|
||||||
|
from kernel.policy import (
|
||||||
|
H2GateContext,
|
||||||
|
classify_action,
|
||||||
|
evaluate_failure_policy,
|
||||||
|
evaluate_h2_gate,
|
||||||
|
evaluate_registry_configuration,
|
||||||
|
evaluate_risk,
|
||||||
|
evaluate_trust_capabilities,
|
||||||
|
)
|
||||||
|
from kernel.taxonomy import CertificationClaim, ReadinessCheck, ReportDimension, RuntimeControl, same_legacy_label
|
||||||
|
from kernel.supervision import RuntimeCapabilities, negotiate_intervention
|
||||||
|
|
||||||
|
|
||||||
|
class RiskFloorTests(unittest.TestCase):
|
||||||
|
def test_benign_deploy_has_high_floor_and_requires_approval(self) -> None:
|
||||||
|
decision = evaluate_risk(action="deploy", content="publish a harmless documentation update", actor="alice")
|
||||||
|
self.assertEqual(decision["action_class"], "deployment")
|
||||||
|
self.assertEqual(decision["risk_factors"]["content_risk"], "low")
|
||||||
|
self.assertEqual(decision["effective_risk"], "high")
|
||||||
|
self.assertEqual(decision["decision"], "require_approval")
|
||||||
|
|
||||||
|
def test_effective_risk_is_maximum_of_all_factors(self) -> None:
|
||||||
|
decision = evaluate_risk(action="read", resource="/.ssh/id_rsa", actor="alice")
|
||||||
|
self.assertEqual(decision["risk_factors"]["action_risk"], "high")
|
||||||
|
self.assertEqual(decision["risk_factors"]["resource_risk"], "high")
|
||||||
|
self.assertEqual(decision["effective_risk"], "high")
|
||||||
|
|
||||||
|
def test_missing_actor_denies_high_impact_action(self) -> None:
|
||||||
|
decision = evaluate_risk(action="migration", content="apply schema", actor="")
|
||||||
|
self.assertEqual(decision["decision"], "deny")
|
||||||
|
self.assertIn("actor_identity_required", decision["reason_codes"])
|
||||||
|
|
||||||
|
def test_read_only_shell_command_is_not_side_effecting(self) -> None:
|
||||||
|
decision = classify_action(tool="Bash", command="git status --short")
|
||||||
|
self.assertEqual(decision["action_class"], "read_only")
|
||||||
|
self.assertFalse(decision["side_effecting"])
|
||||||
|
|
||||||
|
|
||||||
|
class RegistryConfigurationTests(unittest.TestCase):
|
||||||
|
def test_enforce_defaults_registry_on(self) -> None:
|
||||||
|
result = evaluate_registry_configuration("enforce", "development", None)
|
||||||
|
self.assertTrue(result["enabled"])
|
||||||
|
self.assertEqual(result["reason_code"], "h2_registry_default_enabled")
|
||||||
|
|
||||||
|
def test_development_bypass_is_visible_and_non_silent(self) -> None:
|
||||||
|
result = evaluate_registry_configuration("enforce", "test", "0")
|
||||||
|
self.assertFalse(result["enabled"])
|
||||||
|
self.assertTrue(result["unsafe_bypass"])
|
||||||
|
self.assertEqual(result["severity"], "high")
|
||||||
|
|
||||||
|
def test_production_bypass_is_rejected(self) -> None:
|
||||||
|
result = evaluate_registry_configuration("enforce", "production", "0")
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertEqual(result["reason_code"], "h2_registry_bypass_forbidden")
|
||||||
|
|
||||||
|
|
||||||
|
class FailurePolicyTests(unittest.TestCase):
|
||||||
|
def test_all_supported_policies_are_explicit(self) -> None:
|
||||||
|
for configured in ("halt", "quarantine", "require_approval", "record_only"):
|
||||||
|
result = evaluate_failure_policy(
|
||||||
|
side_effecting=False, mode="observe", profile="test", configured=configured,
|
||||||
|
)
|
||||||
|
self.assertEqual(result["failure_policy"], configured)
|
||||||
|
self.assertEqual(result["execution_result"], "failed")
|
||||||
|
self.assertEqual(result["telemetry_result"], "recorded")
|
||||||
|
|
||||||
|
def test_production_side_effect_cannot_be_record_only(self) -> None:
|
||||||
|
result = evaluate_failure_policy(
|
||||||
|
side_effecting=True, mode="enforce", profile="production", configured="record_only",
|
||||||
|
)
|
||||||
|
self.assertEqual(result["failure_policy"], "halt")
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertEqual(result["source"], "production_record_only_forbidden")
|
||||||
|
|
||||||
|
def test_invalid_policy_fails_closed(self) -> None:
|
||||||
|
result = evaluate_failure_policy(
|
||||||
|
side_effecting=True, mode="enforce", profile="test", configured="continue_anyway",
|
||||||
|
)
|
||||||
|
self.assertEqual(result["failure_policy"], "halt")
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
|
||||||
|
|
||||||
|
class TaxonomyTests(unittest.TestCase):
|
||||||
|
def test_same_h_label_cannot_be_interpreted_as_same_category(self) -> None:
|
||||||
|
self.assertTrue(same_legacy_label(RuntimeControl.H4, ReadinessCheck.H4))
|
||||||
|
self.assertNotEqual(RuntimeControl.H4.value, ReadinessCheck.H4.value)
|
||||||
|
self.assertNotEqual(ReportDimension.H4.value, CertificationClaim.H4.value)
|
||||||
|
|
||||||
|
def test_unsupported_runtime_intervention_is_explicit(self) -> None:
|
||||||
|
result = negotiate_intervention("i-1", "roll_back", "operator", RuntimeCapabilities())
|
||||||
|
self.assertEqual(result["status"], "unsupported")
|
||||||
|
self.assertEqual(result["reason_code"], "runtime_intervention_unsupported")
|
||||||
|
|
||||||
|
|
||||||
|
class H2DependencyTests(unittest.TestCase):
|
||||||
|
def context(self, mode: str) -> H2GateContext:
|
||||||
|
return H2GateContext(mode, "alice", "write_file", "Edit", "run-1", "test.h2", "idem", 0.5)
|
||||||
|
|
||||||
|
def script(self, directory: str, body: str) -> str:
|
||||||
|
path = Path(directory) / "gate.sh"
|
||||||
|
path.write_text("#!/usr/bin/env bash\n" + body + "\n", encoding="utf-8")
|
||||||
|
path.chmod(0o700)
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
def test_missing_gate_denies_enforce_and_degrades_observe(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
|
missing = str(Path(temp) / "missing.sh")
|
||||||
|
enforced = evaluate_h2_gate(missing, self.context("enforce"))
|
||||||
|
observed = evaluate_h2_gate(missing, self.context("observe"))
|
||||||
|
self.assertFalse(enforced["execution_allowed"])
|
||||||
|
self.assertEqual(enforced["reason_code"], "h2_gate_unavailable")
|
||||||
|
self.assertTrue(observed["execution_allowed"])
|
||||||
|
self.assertEqual(observed["decision"], "observe_only")
|
||||||
|
self.assertFalse(observed["certifiable"])
|
||||||
|
|
||||||
|
def test_permission_denied_fails_closed(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
|
path = self.script(temp, "echo 'TOOL_APPROVED tool=x reason=test'")
|
||||||
|
os.chmod(path, 0)
|
||||||
|
result = evaluate_h2_gate(path, self.context("enforce"))
|
||||||
|
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
||||||
|
self.assertFalse(result["execution_allowed"])
|
||||||
|
self.assertEqual(result["reason_code"], "h2_gate_permission_denied")
|
||||||
|
|
||||||
|
def test_timeout_malformed_and_internal_exception_fail_closed(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
|
timeout = self.script(temp, "sleep 1")
|
||||||
|
timed = evaluate_h2_gate(timeout, self.context("enforce"))
|
||||||
|
malformed = self.script(temp, "echo nonsense")
|
||||||
|
malformed_result = evaluate_h2_gate(malformed, self.context("enforce"))
|
||||||
|
with patch("kernel.policy.subprocess.run", side_effect=OSError("boom")):
|
||||||
|
internal = evaluate_h2_gate(malformed, self.context("enforce"))
|
||||||
|
self.assertEqual(timed["reason_code"], "h2_gate_timeout")
|
||||||
|
self.assertEqual(malformed_result["reason_code"], "h2_gate_malformed_response")
|
||||||
|
self.assertEqual(internal["reason_code"], "h2_gate_internal_error")
|
||||||
|
self.assertTrue(all(not item["execution_allowed"] for item in (timed, malformed_result, internal)))
|
||||||
|
|
||||||
|
def test_explicit_approval_and_denial_protocols(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
|
allowed = self.script(temp, "echo 'TOOL_APPROVED tool=write_file reason=registered'")
|
||||||
|
allow_result = evaluate_h2_gate(allowed, self.context("enforce"))
|
||||||
|
denied = self.script(temp, "echo 'TOOL_DENIED tool=write_file reason=missing_agent_identity' >&2; exit 2")
|
||||||
|
deny_result = evaluate_h2_gate(denied, self.context("enforce"))
|
||||||
|
self.assertTrue(allow_result["execution_allowed"])
|
||||||
|
self.assertFalse(deny_result["execution_allowed"])
|
||||||
|
self.assertEqual(deny_result["reason_code"], "h2_denied:missing_agent_identity")
|
||||||
|
|
||||||
|
|
||||||
|
class TrustRootTests(unittest.TestCase):
|
||||||
|
def test_production_refuses_local_fallback(self) -> None:
|
||||||
|
result = evaluate_trust_capabilities({"CASAN_PROFILE": "production"})
|
||||||
|
self.assertFalse(result["ready"])
|
||||||
|
self.assertFalse(result["certifiable"])
|
||||||
|
self.assertIn("external_signing_trust_root_required", result["reason_codes"])
|
||||||
|
|
||||||
|
def test_external_configuration_is_capable_but_not_claimed_provisioned(self) -> None:
|
||||||
|
result = evaluate_trust_capabilities({
|
||||||
|
"CASAN_PROFILE": "production",
|
||||||
|
"CASAN_SIGNING_PROVIDER": "vault_kms",
|
||||||
|
"VAULT_ADDR": "https://vault.example",
|
||||||
|
"VAULT_TOKEN": "redacted-runtime-token",
|
||||||
|
"CASAN_IMMUTABLE_ANCHOR_PROVIDER": "s3_object_lock",
|
||||||
|
"CASAN_S3_BUCKET": "audit-lock",
|
||||||
|
"CASAN_S3_KMS_KEY_ID": "kms-key",
|
||||||
|
})
|
||||||
|
self.assertTrue(result["ready"])
|
||||||
|
self.assertTrue(result["certifiable"])
|
||||||
|
|
||||||
|
def test_emergency_override_never_certifies(self) -> None:
|
||||||
|
result = evaluate_trust_capabilities({
|
||||||
|
"CASAN_PROFILE": "production",
|
||||||
|
"CASAN_TRUST_EMERGENCY_OVERRIDE": "1",
|
||||||
|
})
|
||||||
|
self.assertFalse(result["certifiable"])
|
||||||
|
self.assertEqual(result["severity"], "critical")
|
||||||
|
|
||||||
|
|
||||||
|
class CrossRuntimeConformanceTests(unittest.TestCase):
|
||||||
|
def events(self, **overrides: object) -> tuple[dict[str, object], dict[str, object]]:
|
||||||
|
common: dict[str, object] = {
|
||||||
|
"run_id": "run-1", "trace_id": "run-1", "action": "deploy", "tool": "Bash",
|
||||||
|
"command": "deploy harmless docs", "actor": "alice", "mode": "enforce",
|
||||||
|
"execution_status": "failed", "assurance_status": "failed", "certification_status": "non_certified",
|
||||||
|
"certified": False, "correlation_id": "corr-1", "parent_run_id": "parent-1",
|
||||||
|
"parent_step_id": "parent-step-1", "evidence": [
|
||||||
|
{"evidence_id": "e-1", "decision": "deny", "category": "runtime_control"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
common.update(overrides)
|
||||||
|
return dict(common), dict(common, adapter_version="20.2.0")
|
||||||
|
|
||||||
|
def map_both(self, **overrides: object) -> tuple[dict[str, object], dict[str, object]]:
|
||||||
|
native_event, agentic_event = self.events(**overrides)
|
||||||
|
native = NativeHarnessAdapter().map_execution(native_event)
|
||||||
|
agentic = AgenticBridgeAdapter().map_execution(agentic_event)
|
||||||
|
return native, agentic
|
||||||
|
|
||||||
|
def test_01_same_dangerous_action_classification(self) -> None:
|
||||||
|
native, agentic = self.map_both()
|
||||||
|
self.assertEqual(validate_bundle(native), [])
|
||||||
|
self.assertEqual(validate_bundle(agentic), [])
|
||||||
|
self.assertEqual(native["run"]["risk_summary"]["action_class"], "deployment")
|
||||||
|
self.assertEqual(agentic["run"]["risk_summary"]["action_class"], "deployment")
|
||||||
|
self.assertEqual(native["run"]["risk_summary"]["effective_risk"], "high")
|
||||||
|
|
||||||
|
def test_02_same_missing_actor_denial(self) -> None:
|
||||||
|
native, agentic = self.map_both(actor="")
|
||||||
|
for bundle in (native, agentic):
|
||||||
|
self.assertEqual(bundle["run"]["risk_summary"]["decision"], "deny")
|
||||||
|
self.assertIn("actor_identity_required", bundle["run"]["risk_summary"]["reason_codes"])
|
||||||
|
|
||||||
|
def test_03_same_missing_h2_gate_denial(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
|
missing = str(Path(temp) / "missing.sh")
|
||||||
|
decisions = [
|
||||||
|
evaluate_h2_gate(missing, H2GateContext("enforce", "alice", "write_file", "Edit", "run-1", point, "idem"))
|
||||||
|
for point in ("native-harness.h2", "agentic-bridge.h2")
|
||||||
|
]
|
||||||
|
native, agentic = self.map_both(
|
||||||
|
action="write_file", tool="Edit", command="safe edit",
|
||||||
|
policy_decisions=[decisions[0]], evidence=[{"evidence_id": "e-1", "decision": "deny"}],
|
||||||
|
)
|
||||||
|
# The second adapter receives the same kernel decision with only its
|
||||||
|
# enforcement-point extension changed.
|
||||||
|
agentic_event = self.events(
|
||||||
|
action="write_file", tool="Edit", command="safe edit",
|
||||||
|
policy_decisions=[decisions[1]], evidence=[{"evidence_id": "e-1", "decision": "deny"}],
|
||||||
|
)[1]
|
||||||
|
agentic = AgenticBridgeAdapter().map_execution(agentic_event)
|
||||||
|
for bundle in (native, agentic):
|
||||||
|
decision = bundle["steps"][0]["policy_decisions"][0]
|
||||||
|
self.assertEqual(decision["decision"], "deny")
|
||||||
|
self.assertIn("h2_gate_unavailable", decision["reason_codes"])
|
||||||
|
|
||||||
|
def test_04_same_approval_requirement(self) -> None:
|
||||||
|
native, agentic = self.map_both(actor="alice")
|
||||||
|
for bundle in (native, agentic):
|
||||||
|
self.assertEqual(bundle["run"]["risk_summary"]["decision"], "require_approval")
|
||||||
|
self.assertEqual(bundle["run"]["risk_summary"]["evidence_requirement"], "required")
|
||||||
|
|
||||||
|
def test_05_same_claim_evidence_relationship(self) -> None:
|
||||||
|
native, agentic = self.map_both()
|
||||||
|
for bundle in (native, agentic):
|
||||||
|
self.assertEqual(bundle["claims"][0]["evidence_refs"], ["e-1"])
|
||||||
|
self.assertEqual(bundle["evidence"][0]["claim_refs"], [bundle["claims"][0]["claim_id"]])
|
||||||
|
|
||||||
|
def test_06_observed_only_execution_is_never_certified(self) -> None:
|
||||||
|
native, agentic = self.map_both(
|
||||||
|
mode="observe", certification_status="certified", certified=True,
|
||||||
|
)
|
||||||
|
for bundle in (native, agentic):
|
||||||
|
self.assertIn("observed_only_cannot_be_certified", validate_bundle(bundle))
|
||||||
|
|
||||||
|
def test_07_execution_and_assurance_outcomes_are_distinct(self) -> None:
|
||||||
|
native, agentic = self.map_both(
|
||||||
|
execution_status="failed", assurance_status="passed", certification_status="non_certified",
|
||||||
|
)
|
||||||
|
for bundle in (native, agentic):
|
||||||
|
outcome = bundle["run"]["outcome"]
|
||||||
|
self.assertEqual(outcome["execution_result"], "failed")
|
||||||
|
self.assertEqual(outcome["assurance_result"], "passed")
|
||||||
|
self.assertEqual(outcome["certification_result"], "non_certified")
|
||||||
|
|
||||||
|
def test_08_correlation_and_causation_are_preserved(self) -> None:
|
||||||
|
native, agentic = self.map_both()
|
||||||
|
for bundle in (native, agentic):
|
||||||
|
self.assertEqual(bundle["run"]["correlation_id"], "corr-1")
|
||||||
|
self.assertEqual(bundle["run"]["parent_run_id"], "parent-1")
|
||||||
|
self.assertEqual(bundle["steps"][0]["parent_step_id"], "parent-step-1")
|
||||||
|
self.assertEqual(bundle["trace_links"][0]["from"], "run-1")
|
||||||
|
|
||||||
|
def test_09_same_verifier_detects_evidence_tampering(self) -> None:
|
||||||
|
native, agentic = self.map_both()
|
||||||
|
for original in (native, agentic):
|
||||||
|
self.assertEqual(validate_bundle(original), [])
|
||||||
|
tampered = deepcopy(original)
|
||||||
|
tampered["evidence"][0]["artifact_ref"] = "inline:tampered"
|
||||||
|
self.assertIn("evidence.0.integrity_invalid", validate_bundle(tampered))
|
||||||
|
|
||||||
|
def test_10_runtime_details_are_namespaced_extensions(self) -> None:
|
||||||
|
native, agentic = self.map_both()
|
||||||
|
self.assertNotEqual(native["run"]["runtime"]["type"], agentic["run"]["runtime"]["type"])
|
||||||
|
self.assertIn("casan.runtime.casan-native-harness", native["extensions"])
|
||||||
|
self.assertIn("casan.runtime.agentic-bridge", agentic["extensions"])
|
||||||
|
|
||||||
|
def test_delegation_depth_is_enforced(self) -> None:
|
||||||
|
native, _ = self.map_both()
|
||||||
|
native["actors"].append({
|
||||||
|
"actor_id": "child", "actor_type": "child_agent", "issuer": "alice",
|
||||||
|
"trust_level": "verified", "authentication_evidence_ref": None,
|
||||||
|
})
|
||||||
|
native["delegations"] = [
|
||||||
|
{"delegation_id": "d1", "delegator_ref": "alice", "delegate_ref": "child", "allowed_authority": ["read"], "resource_scope": ["project"], "expiry": "2030-01-01T00:00:00Z", "maximum_depth": 3, "parent_delegation_ref": None, "revoked": False, "approval_ref": None},
|
||||||
|
{"delegation_id": "d2", "delegator_ref": "alice", "delegate_ref": "child", "allowed_authority": ["read"], "resource_scope": ["project"], "expiry": "2030-01-01T00:00:00Z", "maximum_depth": 3, "parent_delegation_ref": "d1", "revoked": False, "approval_ref": None},
|
||||||
|
{"delegation_id": "d3", "delegator_ref": "alice", "delegate_ref": "child", "allowed_authority": ["read"], "resource_scope": ["project"], "expiry": "2030-01-01T00:00:00Z", "maximum_depth": 1, "parent_delegation_ref": "d2", "revoked": False, "approval_ref": None},
|
||||||
|
]
|
||||||
|
self.assertIn("delegations.2.maximum_depth_exceeded", validate_bundle(native))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main(verbosity=2)
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Focused bridge integration regressions for the Assurance Kernel upgrade."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
HARNESS_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(HARNESS_ROOT / "scripts" / "python"))
|
||||||
|
|
||||||
|
import agentic_bridge as bridge
|
||||||
|
|
||||||
|
|
||||||
|
class BridgeUpgradeIntegrationTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.state = str(Path(self.temp.name) / "state")
|
||||||
|
self.project = str(HARNESS_ROOT.parent.parent)
|
||||||
|
self.base_env = {
|
||||||
|
"CASAN_STATE_ROOT": self.state,
|
||||||
|
"CASAN_AGENTIC_ENFORCEMENT_MODE": "enforce",
|
||||||
|
"CASAN_AGENT": "boss",
|
||||||
|
"CASAN_ACTOR": "boss",
|
||||||
|
"CASAN_PROFILE": "test",
|
||||||
|
}
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.temp.cleanup()
|
||||||
|
|
||||||
|
def begin(self, actor: str = "boss") -> dict[str, object]:
|
||||||
|
return bridge.op_begin({
|
||||||
|
"op": "begin", "client": "codex", "project": self.project,
|
||||||
|
"session": "integration", "prompt": "edit a source file safely",
|
||||||
|
"integration_mode": "project_hook", "actor": actor,
|
||||||
|
})
|
||||||
|
|
||||||
|
def fixture_gate(self, body: str) -> str:
|
||||||
|
path = Path(self.temp.name) / "h2-gate.sh"
|
||||||
|
path.write_text("#!/usr/bin/env bash\n" + body + "\n", encoding="utf-8")
|
||||||
|
path.chmod(0o700)
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
def pre_edit(self, admission_id: str) -> dict[str, object]:
|
||||||
|
return bridge.op_pre_tool({
|
||||||
|
"op": "pre-tool", "admission_id": admission_id, "tool": "Edit",
|
||||||
|
"tool_input": "update a source file", "project": self.project,
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_missing_h2_gate_denies_enforce_with_structured_evidence(self) -> None:
|
||||||
|
missing = str(Path(self.temp.name) / "missing.sh")
|
||||||
|
with patch.dict(os.environ, {**self.base_env, "CASAN_H2_GATE_PATH": missing}, clear=False):
|
||||||
|
begin = self.begin()
|
||||||
|
result = self.pre_edit(str(begin["admission_id"]))
|
||||||
|
record = bridge.load_admission(str(begin["admission_id"]))
|
||||||
|
self.assertEqual(result["decision"], "deny")
|
||||||
|
self.assertEqual(result["reason"], "h2_gate_unavailable")
|
||||||
|
evidence = next(item for item in record["evidence"] if item["kind"] == "tool-registry")
|
||||||
|
self.assertEqual(evidence["facts"]["mode"], "enforce")
|
||||||
|
self.assertEqual(evidence["facts"]["actor"], "boss")
|
||||||
|
self.assertEqual(evidence["facts"]["tool"], "Edit")
|
||||||
|
self.assertEqual(evidence["facts"]["execution_id"], begin["trace_id"])
|
||||||
|
self.assertEqual(evidence["facts"]["enforcement_path"], "agentic_bridge.pre_tool.h2_registry")
|
||||||
|
|
||||||
|
def test_missing_h2_gate_observe_allows_only_degraded_non_certifiable_execution(self) -> None:
|
||||||
|
missing = str(Path(self.temp.name) / "missing.sh")
|
||||||
|
env = {**self.base_env, "CASAN_AGENTIC_ENFORCEMENT_MODE": "observe", "CASAN_H2_GATE_PATH": missing}
|
||||||
|
with patch.dict(os.environ, env, clear=False):
|
||||||
|
begin = self.begin()
|
||||||
|
result = self.pre_edit(str(begin["admission_id"]))
|
||||||
|
finalized = bridge.op_finalize({"op": "finalize", "admission_id": begin["admission_id"], "stop_reason": "completed"})
|
||||||
|
self.assertEqual(result["decision"], "allow")
|
||||||
|
self.assertEqual(finalized["decision"], "non_certified")
|
||||||
|
self.assertEqual(finalized["certification_strength"], "observed_only")
|
||||||
|
|
||||||
|
def test_registry_defaults_on_and_development_bypass_is_visible(self) -> None:
|
||||||
|
missing = str(Path(self.temp.name) / "missing.sh")
|
||||||
|
with patch.dict(os.environ, {**self.base_env, "CASAN_H2_GATE_PATH": missing}, clear=False):
|
||||||
|
begin = self.begin()
|
||||||
|
default_result = self.pre_edit(str(begin["admission_id"]))
|
||||||
|
self.assertEqual(default_result["reason"], "h2_gate_unavailable")
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {**self.base_env, "CASAN_AGENTIC_H2_REGISTRY": "0", "CASAN_H2_GATE_PATH": missing}, clear=False):
|
||||||
|
begin = self.begin()
|
||||||
|
bypass = self.pre_edit(str(begin["admission_id"]))
|
||||||
|
record = bridge.load_admission(str(begin["admission_id"]))
|
||||||
|
self.assertEqual(bypass["decision"], "allow")
|
||||||
|
self.assertTrue(any("HIGH" in warning for warning in bypass["warnings"]))
|
||||||
|
self.assertEqual(record["certification_strength"], "observed_only")
|
||||||
|
|
||||||
|
def test_benign_deploy_requires_approval_from_action_floor(self) -> None:
|
||||||
|
gate = self.fixture_gate("echo 'TOOL_APPROVED tool=deploy reason=registered'")
|
||||||
|
with patch.dict(os.environ, {**self.base_env, "CASAN_H2_GATE_PATH": gate}, clear=False):
|
||||||
|
begin = self.begin()
|
||||||
|
result = bridge.op_pre_tool({
|
||||||
|
"op": "pre-tool", "admission_id": begin["admission_id"], "tool": "Bash",
|
||||||
|
"tool_input": "deploy harmless documentation", "project": self.project,
|
||||||
|
})
|
||||||
|
self.assertEqual(result["decision"], "require_approval")
|
||||||
|
risk = result["policy_decision"]
|
||||||
|
self.assertEqual(risk["action_class"], "deployment")
|
||||||
|
self.assertEqual(risk["risk_factors"]["content_risk"], "medium")
|
||||||
|
self.assertEqual(risk["effective_risk"], "high")
|
||||||
|
|
||||||
|
def test_structured_shell_payload_classifies_the_inner_read_only_command(self) -> None:
|
||||||
|
with patch.dict(os.environ, self.base_env, clear=False):
|
||||||
|
begin = self.begin()
|
||||||
|
result = bridge.op_pre_tool({
|
||||||
|
"op": "pre-tool", "admission_id": begin["admission_id"], "tool": "Bash",
|
||||||
|
"tool_input": {"command": "ls"}, "project": self.project,
|
||||||
|
})
|
||||||
|
self.assertEqual(result["decision"], "allow")
|
||||||
|
self.assertEqual(result["policy_decision"]["action_class"], "read_only")
|
||||||
|
|
||||||
|
def test_failed_side_effect_halts_and_cannot_finalize_successfully(self) -> None:
|
||||||
|
gate = self.fixture_gate("echo 'TOOL_APPROVED tool=write_file reason=registered'")
|
||||||
|
with patch.dict(os.environ, {**self.base_env, "CASAN_H2_GATE_PATH": gate}, clear=False):
|
||||||
|
begin = self.begin()
|
||||||
|
admitted = self.pre_edit(str(begin["admission_id"]))
|
||||||
|
post = bridge.op_post_tool({
|
||||||
|
"op": "post-tool", "admission_id": begin["admission_id"], "tool": "Edit",
|
||||||
|
"status": "error", "duration_ms": 2, "result": "write failed",
|
||||||
|
})
|
||||||
|
finalized = bridge.op_finalize({
|
||||||
|
"op": "finalize", "admission_id": begin["admission_id"],
|
||||||
|
"stop_reason": "completed", "assistant_summary": "completed",
|
||||||
|
})
|
||||||
|
self.assertEqual(admitted["decision"], "allow")
|
||||||
|
self.assertEqual(post["decision"], "halt")
|
||||||
|
self.assertFalse(post["assurance_may_continue"])
|
||||||
|
self.assertEqual(finalized["decision"], "non_certified")
|
||||||
|
self.assertEqual(finalized["execution_outcome"], "failed")
|
||||||
|
self.assertEqual(finalized["assurance_outcome"], "failed")
|
||||||
|
self.assertIn("failed_tool_outcome", finalized["reason"])
|
||||||
|
kernel_files = list((Path(self.state) / "logs" / "kernel").glob("*.json"))
|
||||||
|
self.assertEqual(len(kernel_files), 1)
|
||||||
|
kernel = json.loads(kernel_files[0].read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual(kernel["run"]["outcome"]["execution_result"], "failed")
|
||||||
|
self.assertEqual(kernel["run"]["outcome"]["certification_result"], "non_certified")
|
||||||
|
|
||||||
|
def test_production_without_external_trust_root_never_certifies(self) -> None:
|
||||||
|
env = {**self.base_env, "CASAN_PROFILE": "production", "CASAN_AGENTIC_ENFORCEMENT_MODE": "enforce"}
|
||||||
|
with patch.dict(os.environ, env, clear=False):
|
||||||
|
begin = self.begin()
|
||||||
|
finalized = bridge.op_finalize({"op": "finalize", "admission_id": begin["admission_id"], "stop_reason": "completed"})
|
||||||
|
self.assertEqual(finalized["decision"], "non_certified")
|
||||||
|
self.assertIn("external_signing_trust_root_required", finalized["reason"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main(verbosity=2)
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Proves that the release artifact—not the source checkout—contains a complete,
|
||||||
|
# offline, read-only Local Assurance Viewer.
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||||
|
WORK="$(mktemp -d)"
|
||||||
|
DIST="$WORK/dist"
|
||||||
|
PROJECT="$WORK/clean-project"
|
||||||
|
VIEWER_STARTED=0
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [[ "$VIEWER_STARTED" == "1" && -n "${CASAN_BIN:-}" ]]; then
|
||||||
|
CASAN_APP_ROOT="$PROJECT" "$CASAN_BIN" dashboard stop >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
rm -rf "$WORK"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
CASAN_DIST_DIR="$DIST" bash "$ROOT/scripts/package-release.sh" core >/dev/null
|
||||||
|
ARTIFACT="$DIST/casan-core-v$(cat "$ROOT/VERSION").tar.gz"
|
||||||
|
tar -xzf "$ARTIFACT" -C "$WORK"
|
||||||
|
BUNDLE="$WORK/casan-core-v$(cat "$ROOT/VERSION")"
|
||||||
|
CASAN_BIN="$BUNDLE/bin/casan"
|
||||||
|
|
||||||
|
test -f "$BUNDLE/packages/casan-harness/assets/local-viewer/index.html"
|
||||||
|
test -f "$BUNDLE/packages/casan-harness/scripts/python/local_report.py"
|
||||||
|
test -f "$BUNDLE/packages/casan-harness/scripts/python/local_viewer.py"
|
||||||
|
test -f "$BUNDLE/packages/casan-harness/scripts/python/readiness.py"
|
||||||
|
test ! -e "$BUNDLE/packages/casan-control-panel"
|
||||||
|
test ! -e "$BUNDLE/package.json"
|
||||||
|
|
||||||
|
mkdir -p \
|
||||||
|
"$PROJECT/.casan" \
|
||||||
|
"$PROJECT/.specify/state" \
|
||||||
|
"$PROJECT/.specify/logs/trace" \
|
||||||
|
"$PROJECT/.specify/logs/trace-events" \
|
||||||
|
"$PROJECT/.specify/logs/cost"
|
||||||
|
|
||||||
|
python3 - "$PROJECT" "$BUNDLE" <<'PY'
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
root = Path(sys.argv[1])
|
||||||
|
bundle = Path(sys.argv[2])
|
||||||
|
(root / ".casan/config.json").write_text(json.dumps({
|
||||||
|
"schema_version": "21.3",
|
||||||
|
"project_id": "artifact-project",
|
||||||
|
"project_name": "Artifact Project",
|
||||||
|
"edition": "core",
|
||||||
|
"enforcement_mode": "enforce",
|
||||||
|
"runtime_path": str(bundle),
|
||||||
|
"clients": ["vscode-copilot"],
|
||||||
|
}), encoding="utf-8")
|
||||||
|
(root / ".casan/casan-hook.py").write_text("# casan-hook.py\n", encoding="utf-8")
|
||||||
|
(root / ".casan/version.lock").write_text(json.dumps({
|
||||||
|
"harness_version": "artifact",
|
||||||
|
"harness_hash": "artifact-hash",
|
||||||
|
}), encoding="utf-8")
|
||||||
|
trace_id = "artifact-run-1"
|
||||||
|
trace = {
|
||||||
|
"schema_version": "20.1",
|
||||||
|
"trace_id": trace_id,
|
||||||
|
"project_id": "artifact-project",
|
||||||
|
"certified": True,
|
||||||
|
"certification_strength": "project_hook",
|
||||||
|
"finalized_at": "2026-07-28T10:00:07Z",
|
||||||
|
"tool_calls": 1,
|
||||||
|
"failures": 0,
|
||||||
|
"evidence": [
|
||||||
|
{"h": f"H{i}", "at": f"2026-07-28T10:00:0{i}Z", "decision": "pass", "kind": "gate", "detail": f"H{i} passed"}
|
||||||
|
for i in range(1, 8)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
(root / f".specify/logs/trace/agentic-{trace_id}.json").write_text(
|
||||||
|
json.dumps(trace), encoding="utf-8")
|
||||||
|
metric = {
|
||||||
|
"timestamp": "2026-07-28T10:00:06Z",
|
||||||
|
"trace_id": trace_id,
|
||||||
|
"project_id": "artifact-project",
|
||||||
|
"harness": "H6-agentic",
|
||||||
|
"step": "agentic-turn",
|
||||||
|
"status": "success",
|
||||||
|
"latency_ms": 812,
|
||||||
|
"retry_count": 0,
|
||||||
|
"input_tokens": None,
|
||||||
|
"output_tokens": None,
|
||||||
|
"total_tokens": None,
|
||||||
|
"cost_estimate": None,
|
||||||
|
"telemetry_quality": "insufficient",
|
||||||
|
"alerts": [],
|
||||||
|
}
|
||||||
|
(root / ".specify/logs/cost/metrics.jsonl").write_text(
|
||||||
|
json.dumps(metric) + "\n", encoding="utf-8")
|
||||||
|
(root / ".specify/state/latest-run.json").write_text(json.dumps({
|
||||||
|
"schema_version": 1,
|
||||||
|
"trace_id": trace_id,
|
||||||
|
"project_id": "artifact-project",
|
||||||
|
"certified": True,
|
||||||
|
"telemetry_quality": "insufficient",
|
||||||
|
"duration_ms": 812,
|
||||||
|
"tool_calls": 1,
|
||||||
|
"failures": 0,
|
||||||
|
"finalized_at": "2026-07-28T10:00:07Z",
|
||||||
|
}), encoding="utf-8")
|
||||||
|
PY
|
||||||
|
|
||||||
|
(
|
||||||
|
cd "$PROJECT"
|
||||||
|
CASAN_APP_ROOT="$PROJECT" "$CASAN_BIN" readiness --refresh --json > "$WORK/readiness.json"
|
||||||
|
CASAN_APP_ROOT="$PROJECT" "$CASAN_BIN" report latest | grep -q "view_command=casan view artifact-run-1"
|
||||||
|
CASAN_APP_ROOT="$PROJECT" "$CASAN_BIN" report export artifact-run-1 \
|
||||||
|
--format html --output "$WORK/run.html" >/dev/null
|
||||||
|
CASAN_APP_ROOT="$PROJECT" "$CASAN_BIN" report export --h6 \
|
||||||
|
--format json --output "$WORK/h6.json" >/dev/null
|
||||||
|
CASAN_APP_ROOT="$PROJECT" "$CASAN_BIN" report export --h6 \
|
||||||
|
--format html --output "$WORK/h6.html" >/dev/null
|
||||||
|
)
|
||||||
|
grep -q "Independent assurance dossier" "$WORK/run.html"
|
||||||
|
grep -q "H6 · AgentOps Report" "$WORK/h6.html"
|
||||||
|
python3 - "$WORK/h6.json" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
d = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||||
|
assert d["harness"] == "H6"
|
||||||
|
assert d["summary"]["runs"] == 1
|
||||||
|
assert d["summary"]["tokens"]["total"] is None
|
||||||
|
assert d["summary"]["cost_usd"]["estimated"] is None
|
||||||
|
PY
|
||||||
|
python3 - "$WORK/readiness.json" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
d = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||||
|
assert d["dimensions"]["core"]["status"] == "ready"
|
||||||
|
assert d["dimensions"]["domain_pipeline"]["status"] == "not_configured"
|
||||||
|
assert d["dimensions"]["provider_telemetry"]["status"] == "optional_unavailable"
|
||||||
|
PY
|
||||||
|
|
||||||
|
URL="$(CASAN_APP_ROOT="$PROJECT" "$CASAN_BIN" view artifact-run-1 --no-open)"
|
||||||
|
VIEWER_STARTED=1
|
||||||
|
python3 - "$URL" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from urllib.error import HTTPError
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
url = urlparse(sys.argv[1])
|
||||||
|
token = parse_qs(url.query)["token"][0]
|
||||||
|
base = f"{url.scheme}://{url.netloc}"
|
||||||
|
|
||||||
|
def get(path):
|
||||||
|
request = Request(base + path, headers={"X-CASAN-Viewer-Token": token})
|
||||||
|
return json.loads(urlopen(request, timeout=3).read())
|
||||||
|
|
||||||
|
project = get("/api/v1/project")
|
||||||
|
assert project["project_id"] == "artifact-project"
|
||||||
|
assert project["viewer"] == {
|
||||||
|
"mode": "local_core",
|
||||||
|
"scope": "single_project",
|
||||||
|
"read_only": True,
|
||||||
|
"network": "loopback_only",
|
||||||
|
}
|
||||||
|
run = get("/api/v1/reports/run/artifact-run-1")
|
||||||
|
assert run["verdict"] == "certified"
|
||||||
|
assert run["summary"]["gates_observed"] == 7
|
||||||
|
h6 = get("/api/v1/reports/h6?run=artifact-run-1")
|
||||||
|
assert h6["summary"]["runs"] == 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
urlopen(Request(base + "/api/v1/project"), timeout=3)
|
||||||
|
raise AssertionError("API accepted a request without the session token")
|
||||||
|
except HTTPError as error:
|
||||||
|
assert error.code == 401
|
||||||
|
|
||||||
|
try:
|
||||||
|
urlopen(Request(
|
||||||
|
base + "/api/v1/project",
|
||||||
|
method="POST",
|
||||||
|
headers={"X-CASAN-Viewer-Token": token},
|
||||||
|
), timeout=3)
|
||||||
|
raise AssertionError("read-only viewer accepted POST")
|
||||||
|
except HTTPError as error:
|
||||||
|
assert error.code == 405
|
||||||
|
|
||||||
|
try:
|
||||||
|
urlopen(Request(
|
||||||
|
base + "/api/v1/project",
|
||||||
|
headers={"X-CASAN-Viewer-Token": token, "Host": "attacker.example"},
|
||||||
|
), timeout=3)
|
||||||
|
raise AssertionError("viewer accepted a non-loopback Host header")
|
||||||
|
except HTTPError as error:
|
||||||
|
assert error.code == 403
|
||||||
|
PY
|
||||||
|
|
||||||
|
CASAN_APP_ROOT="$PROJECT" "$CASAN_BIN" dashboard status | grep -q '"mode": "local_core"'
|
||||||
|
CASAN_APP_ROOT="$PROJECT" "$CASAN_BIN" dashboard stop | grep -q "CASAN_VIEW_STOPPED"
|
||||||
|
VIEWER_STARTED=0
|
||||||
|
echo "PASS: Core release artifact provides offline visual reports without Platform or Node"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# NEHOPS analyzer invariants. These expressions are project/domain policy,
|
||||||
|
# while generic severity-tag preservation belongs to the Core compressor.
|
||||||
|
VBSource root not found
|
||||||
|
common code tracing skipped
|
||||||
|
Error Messages
|
||||||
|
Output Directory
|
||||||
|
Generated [0-9]+ files
|
||||||
|
exit code
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Acceptance tests for the stdlib-only CASAN Core reporting boundary."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
MODULE = Path(__file__).resolve().parents[1] / "scripts" / "python" / "local_report.py"
|
||||||
|
APP_JS = Path(__file__).resolve().parents[1] / "assets" / "local-viewer" / "app.js"
|
||||||
|
SPEC = importlib.util.spec_from_file_location("casan_local_report", MODULE)
|
||||||
|
REPORT = importlib.util.module_from_spec(SPEC)
|
||||||
|
assert SPEC.loader
|
||||||
|
SPEC.loader.exec_module(REPORT)
|
||||||
|
|
||||||
|
|
||||||
|
class LocalReportTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temporary = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self.temporary.name)
|
||||||
|
(self.root / ".casan").mkdir()
|
||||||
|
(self.root / ".casan" / "config.json").write_text(json.dumps({
|
||||||
|
"project_id": "project-one",
|
||||||
|
"project_name": "Project One",
|
||||||
|
"edition": "core",
|
||||||
|
"enforcement_mode": "enforce",
|
||||||
|
"maturity": {"level": 4, "status": "assessed"},
|
||||||
|
}), encoding="utf-8")
|
||||||
|
(self.root / ".casan" / "readiness.json").write_text(json.dumps({
|
||||||
|
"schema_version": 1,
|
||||||
|
"overall_status": "ready",
|
||||||
|
"dimensions": {
|
||||||
|
"core": {"status": "ready", "acceptance": []},
|
||||||
|
"domain_pipeline": {"status": "not_configured"},
|
||||||
|
"provider_telemetry": {"status": "optional_unavailable"},
|
||||||
|
},
|
||||||
|
"next_actions": [],
|
||||||
|
}), encoding="utf-8")
|
||||||
|
for path in (
|
||||||
|
".specify/state",
|
||||||
|
".specify/logs/trace",
|
||||||
|
".specify/logs/trace-events",
|
||||||
|
".specify/logs/cost",
|
||||||
|
".specify/logs/level5",
|
||||||
|
".specify/agentops",
|
||||||
|
):
|
||||||
|
(self.root / path).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.temporary.cleanup()
|
||||||
|
|
||||||
|
def write_jsonl(self, relative, rows):
|
||||||
|
path = self.root / relative
|
||||||
|
path.write_text(
|
||||||
|
"".join(json.dumps(row, separators=(",", ":")) + "\n" for row in rows),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
def write_run(self, trace_id="trace-1", quality="partial"):
|
||||||
|
trace = {
|
||||||
|
"trace_id": trace_id,
|
||||||
|
"project_id": "project-one",
|
||||||
|
"certified": True,
|
||||||
|
"certification_strength": "project_hook",
|
||||||
|
"finalized_at": "2026-07-28T10:00:07Z",
|
||||||
|
"tool_calls": 2,
|
||||||
|
"failures": 0,
|
||||||
|
"evidence": [
|
||||||
|
{"h": f"H{index}", "at": f"2026-07-28T10:00:0{index}Z", "decision": "pass", "kind": "gate", "detail": f"H{index} passed"}
|
||||||
|
for index in range(1, 8)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
(self.root / f".specify/logs/trace/agentic-{trace_id}.json").write_text(
|
||||||
|
json.dumps(trace), encoding="utf-8")
|
||||||
|
metric = {
|
||||||
|
"timestamp": "2026-07-28T10:00:06Z",
|
||||||
|
"trace_id": trace_id,
|
||||||
|
"project_id": "project-one",
|
||||||
|
"step": "agentic-turn",
|
||||||
|
"status": "success",
|
||||||
|
"latency_ms": 1200,
|
||||||
|
"retry_count": 0,
|
||||||
|
"input_tokens": None,
|
||||||
|
"output_tokens": None,
|
||||||
|
"total_tokens": None,
|
||||||
|
"cost_estimate": None,
|
||||||
|
"telemetry_quality": quality,
|
||||||
|
"alerts": [],
|
||||||
|
}
|
||||||
|
self.write_jsonl(".specify/logs/cost/metrics.jsonl", [metric])
|
||||||
|
return metric
|
||||||
|
|
||||||
|
def test_core_empty_state_is_truthful(self):
|
||||||
|
registry = REPORT.runs(self.root)
|
||||||
|
h6 = REPORT.h6_report(self.root)
|
||||||
|
self.assertEqual(registry["count"], 0)
|
||||||
|
self.assertEqual(h6["verdict"], "no_data")
|
||||||
|
self.assertEqual(h6["summary"]["runs"], 0)
|
||||||
|
self.assertIsNone(h6["summary"]["tokens"]["total"])
|
||||||
|
self.assertTrue(any(item["code"] == "METRICS_MISSING" for item in h6["findings"]))
|
||||||
|
profile = REPORT.project_profile(self.root)
|
||||||
|
self.assertEqual(profile["readiness"]["dimensions"]["core"]["status"], "ready")
|
||||||
|
|
||||||
|
def test_run_reconstructs_all_gates_and_redacts_sensitive_fields(self):
|
||||||
|
self.write_run()
|
||||||
|
event = {
|
||||||
|
"timestamp": "2026-07-28T10:00:01Z",
|
||||||
|
"trace_id": "trace-1",
|
||||||
|
"gate_id": "H1-context",
|
||||||
|
"status": "pass",
|
||||||
|
"reason": "admitted",
|
||||||
|
"evidence": {"kind": "context", "prompt": "private customer prompt", "access_token": "secret"},
|
||||||
|
}
|
||||||
|
self.write_jsonl(".specify/logs/trace-events/trace-1.jsonl", [event])
|
||||||
|
report = REPORT.run_report(self.root, "trace-1")
|
||||||
|
self.assertEqual(report["verdict"], "certified")
|
||||||
|
self.assertEqual(report["summary"]["gates_observed"], 1)
|
||||||
|
self.assertEqual(report["gates"][0]["evidence"]["prompt"], "[redacted]")
|
||||||
|
self.assertEqual(report["gates"][0]["evidence"]["access_token"], "[redacted]")
|
||||||
|
self.assertIsNone(report["h6"]["total_tokens"])
|
||||||
|
|
||||||
|
def test_legacy_trace_reconstructs_h1_to_h7(self):
|
||||||
|
self.write_run()
|
||||||
|
graph = REPORT.trace_graph(self.root, "trace-1")
|
||||||
|
self.assertTrue(graph["terminal"])
|
||||||
|
self.assertEqual(graph["progress"], 7)
|
||||||
|
self.assertEqual([node["status"] for node in graph["nodes"]], [
|
||||||
|
"pass", "pass", "pass", "pass", "pass", "warning", "pass",
|
||||||
|
])
|
||||||
|
|
||||||
|
def test_h6_preserves_unknown_token_and_cost_as_null(self):
|
||||||
|
self.write_run()
|
||||||
|
report = REPORT.h6_report(self.root, run="trace-1")
|
||||||
|
self.assertEqual(report["summary"]["coverage"]["token_pct"], 0)
|
||||||
|
self.assertEqual(report["summary"]["coverage"]["cost_pct"], 0)
|
||||||
|
self.assertIsNone(report["summary"]["tokens"]["total"])
|
||||||
|
self.assertIsNone(report["summary"]["cost_usd"]["estimated"])
|
||||||
|
self.assertEqual(report["data_quality"]["status"], "insufficient")
|
||||||
|
self.assertIn("TELEMETRY_COVERAGE_GAP", report["verdict_reasons"])
|
||||||
|
|
||||||
|
def test_self_contained_html_escapes_evidence(self):
|
||||||
|
self.write_run()
|
||||||
|
report = REPORT.run_report(self.root, "trace-1")
|
||||||
|
report["gates"][0]["reason"] = "<script>alert(1)</script>"
|
||||||
|
html = REPORT.report_html(report, "run")
|
||||||
|
self.assertIn("<!doctype html>", html.lower())
|
||||||
|
self.assertNotIn("<script>alert(1)</script>", html)
|
||||||
|
self.assertIn("<script>alert(1)</script>", html)
|
||||||
|
self.assertIn("Machine-auditable contract", html)
|
||||||
|
|
||||||
|
def test_h6_html_visualizes_outcomes_and_hides_raw_contract(self):
|
||||||
|
self.write_run()
|
||||||
|
report = REPORT.h6_report(self.root, run="trace-1")
|
||||||
|
html = REPORT.report_html(report, "h6")
|
||||||
|
self.assertIn("Run distribution", html)
|
||||||
|
self.assertIn('class="distribution"', html)
|
||||||
|
self.assertIn("<details><summary>Open JSON evidence</summary>", html)
|
||||||
|
self.assertNotIn("None</strong>", html)
|
||||||
|
|
||||||
|
def test_unsafe_trace_id_never_reads_outside_project(self):
|
||||||
|
report = REPORT.run_report(self.root, "../../etc/passwd")
|
||||||
|
self.assertEqual(report["verdict"], "not_found")
|
||||||
|
self.assertFalse(report["source"]["trace_found"])
|
||||||
|
|
||||||
|
def test_trace_inspection_reveals_the_loaded_dossier(self):
|
||||||
|
script = APP_JS.read_text(encoding="utf-8")
|
||||||
|
self.assertIn('id="run-dossier"', script)
|
||||||
|
self.assertIn('tabindex="-1"', script)
|
||||||
|
self.assertIn('dossier.focus({ preventScroll: true })', script)
|
||||||
|
self.assertIn("dossier.scrollIntoView({", script)
|
||||||
|
self.assertIn("No operational KPIs are shown until evidence exists.", script)
|
||||||
|
self.assertIn("H6 begins with the first governed run.", script)
|
||||||
|
self.assertIn("No operational counters yet", script)
|
||||||
|
self.assertNotIn('"Ready for the first governed run."', script)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -40,6 +40,8 @@ print(d.get(sys.argv[1],""))'
|
|||||||
echo "===== C1: normal turn = one admission + one trace + one metric (single model) ====="
|
echo "===== C1: normal turn = one admission + one trace + one metric (single model) ====="
|
||||||
newstate
|
newstate
|
||||||
export CASAN_AGENTIC_ENFORCEMENT_MODE=enforce
|
export CASAN_AGENTIC_ENFORCEMENT_MODE=enforce
|
||||||
|
export CASAN_AGENT=boss
|
||||||
|
export CASAN_ACTOR=boss
|
||||||
B=$(bridge '{"op":"begin","client":"claude-code","project":"'"$PROJ"'","session":"c1","prompt":"add a helper","integration_mode":"project_hook"}')
|
B=$(bridge '{"op":"begin","client":"claude-code","project":"'"$PROJ"'","session":"c1","prompt":"add a helper","integration_mode":"project_hook"}')
|
||||||
DEC=$(printf '%s' "$B" | field decision)
|
DEC=$(printf '%s' "$B" | field decision)
|
||||||
AID=$(printf '%s' "$B" | field admission_id)
|
AID=$(printf '%s' "$B" | field admission_id)
|
||||||
@@ -76,7 +78,8 @@ B=$(bridge '{"op":"begin","client":"claude-code","project":"'"$PROJ"'","session"
|
|||||||
AID=$(printf '%s' "$B" | field admission_id); TID=$(printf '%s' "$B" | field trace_id)
|
AID=$(printf '%s' "$B" | field admission_id); TID=$(printf '%s' "$B" | field trace_id)
|
||||||
OK3=1
|
OK3=1
|
||||||
for tool in Bash Edit Write; do
|
for tool in Bash Edit Write; do
|
||||||
R=$(bridge '{"op":"pre-tool","admission_id":"'"$AID"'","tool":"'"$tool"'","tool_input":"x","project":"'"$PROJ"'"}')
|
INPUT_VALUE="x"; [[ "$tool" == "Bash" ]] && INPUT_VALUE="ls"
|
||||||
|
R=$(bridge '{"op":"pre-tool","admission_id":"'"$AID"'","tool":"'"$tool"'","tool_input":"'"$INPUT_VALUE"'","project":"'"$PROJ"'"}')
|
||||||
[[ "$(printf '%s' "$R" | field decision)" == "allow" ]] || OK3=0
|
[[ "$(printf '%s' "$R" | field decision)" == "allow" ]] || OK3=0
|
||||||
bridge '{"op":"post-tool","admission_id":"'"$AID"'","tool":"'"$tool"'","status":"success"}' >/dev/null
|
bridge '{"op":"post-tool","admission_id":"'"$AID"'","tool":"'"$tool"'","status":"success"}' >/dev/null
|
||||||
done
|
done
|
||||||
@@ -245,7 +248,7 @@ AID=$(printf '%s' "$B" | field admission_id)
|
|||||||
|| fail "no-bash begin did not degrade gracefully ($B)"
|
|| fail "no-bash begin did not degrade gracefully ($B)"
|
||||||
R=$(bridge '{"op":"pre-tool","admission_id":"'"$AID"'","tool":"Bash","tool_input":"ls","project":"'"$PROJ"'"}')
|
R=$(bridge '{"op":"pre-tool","admission_id":"'"$AID"'","tool":"Bash","tool_input":"ls","project":"'"$PROJ"'"}')
|
||||||
[[ "$(printf '%s' "$R" | field decision)" == "allow" ]] \
|
[[ "$(printf '%s' "$R" | field decision)" == "allow" ]] \
|
||||||
&& pass "no-bash: side-effect tool allowed (admission gate still governs)" \
|
&& pass "no-bash: classified read-only shell command remains available" \
|
||||||
|| fail "no-bash pre-tool blocked the developer ($R)"
|
|| fail "no-bash pre-tool blocked the developer ($R)"
|
||||||
F=$(bridge '{"op":"finalize","admission_id":"'"$AID"'","stop_reason":"completed"}')
|
F=$(bridge '{"op":"finalize","admission_id":"'"$AID"'","stop_reason":"completed"}')
|
||||||
[[ "$(printf '%s' "$F" | field decision)" == "non_certified" ]] \
|
[[ "$(printf '%s' "$F" | field decision)" == "non_certified" ]] \
|
||||||
|
|||||||