feat: chat + optmz control panel

This commit is contained in:
thanhnv
2026-07-10 16:26:30 +09:00
parent d882a9dc23
commit 7cea023dce
28 changed files with 1702 additions and 402 deletions
+1
View File
@@ -14,6 +14,7 @@ frontend/node_modules/.vite/
.env
.env.*
!.env.example
infra/local-prod/casan.local.env
backend/.env
frontend/.env
**/.env
+10
View File
@@ -82,3 +82,13 @@
{"timestamp":"2026-07-08T09:53:30Z","trace_id":"412d7566-4200-42a8-8772-b88bf395c1f1","harness":"H4-security","mode":"input","status":"pass","action":"allow","risk_level":"low","input_hash":"427aa6ae245aa0f05cc75c0aea61de8129aae1e0e589d5727873d4d7f9baf872","output_hash":"427aa6ae245aa0f05cc75c0aea61de8129aae1e0e589d5727873d4d7f9baf872"}
{"timestamp":"2026-07-08T09:53:32Z","trace_id":"506f4e4f-88f0-4a76-9561-b93acd65b026","harness":"H4-security","mode":"input","status":"pass","action":"allow","risk_level":"low","input_hash":"427aa6ae245aa0f05cc75c0aea61de8129aae1e0e589d5727873d4d7f9baf872","output_hash":"427aa6ae245aa0f05cc75c0aea61de8129aae1e0e589d5727873d4d7f9baf872"}
{"timestamp":"2026-07-08T09:53:33Z","trace_id":"4c96ed73-034e-4d15-b99b-12ef890372b0","harness":"H4-security","mode":"output","status":"pass","action":"allow","risk_level":"low","input_hash":"427aa6ae245aa0f05cc75c0aea61de8129aae1e0e589d5727873d4d7f9baf872","output_hash":"427aa6ae245aa0f05cc75c0aea61de8129aae1e0e589d5727873d4d7f9baf872"}
{"timestamp":"2026-07-10T06:11:24Z","trace_id":"trace-1783663884-37","harness":"H4-security","mode":"input","status":"blocked","action":"block","risk_level":"high","input_hash":"5da5739dfab4b282139a20d7272d0e676331eb09f86f562cc5e6d1f3b668a5ab","output_hash":"5da5739dfab4b282139a20d7272d0e676331eb09f86f562cc5e6d1f3b668a5ab"}
{"timestamp":"2026-07-10T06:12:28Z","trace_id":"trace-1783663948-37","harness":"H4-security","mode":"input","status":"blocked","action":"block","risk_level":"high","input_hash":"5da5739dfab4b282139a20d7272d0e676331eb09f86f562cc5e6d1f3b668a5ab","output_hash":"5da5739dfab4b282139a20d7272d0e676331eb09f86f562cc5e6d1f3b668a5ab"}
{"timestamp":"2026-07-10T06:13:42Z","trace_id":"trace-1783664022-37","harness":"H4-security","mode":"input","status":"pass","action":"allow","risk_level":"low","input_hash":"5da5739dfab4b282139a20d7272d0e676331eb09f86f562cc5e6d1f3b668a5ab","output_hash":"5da5739dfab4b282139a20d7272d0e676331eb09f86f562cc5e6d1f3b668a5ab"}
{"timestamp":"2026-07-10T06:14:39Z","trace_id":"trace-1783664079-613","harness":"H4-security","mode":"output","status":"pass","action":"allow","risk_level":"low","input_hash":"e756b35ca2e4c3326cada62e0a6d4b3d714c5afb0caab2268c86c63207228ed2","output_hash":"e756b35ca2e4c3326cada62e0a6d4b3d714c5afb0caab2268c86c63207228ed2"}
{"timestamp":"2026-07-10T06:18:36Z","trace_id":"trace-1783664316-37","harness":"H4-security","mode":"input","status":"pass","action":"allow","risk_level":"low","input_hash":"5da5739dfab4b282139a20d7272d0e676331eb09f86f562cc5e6d1f3b668a5ab","output_hash":"5da5739dfab4b282139a20d7272d0e676331eb09f86f562cc5e6d1f3b668a5ab"}
{"timestamp":"2026-07-10T06:19:34Z","trace_id":"trace-1783664374-636","harness":"H4-security","mode":"output","status":"pass","action":"allow","risk_level":"low","input_hash":"142404dfd1bd2e31f6bef54bb495685e059a9b5c4b2e3369c8b79f8c7bec67a9","output_hash":"142404dfd1bd2e31f6bef54bb495685e059a9b5c4b2e3369c8b79f8c7bec67a9"}
{"timestamp":"2026-07-10T06:19:37Z","trace_id":"trace-1783664377-747","harness":"H4-security","mode":"input","status":"pass","action":"allow","risk_level":"low","input_hash":"5da5739dfab4b282139a20d7272d0e676331eb09f86f562cc5e6d1f3b668a5ab","output_hash":"5da5739dfab4b282139a20d7272d0e676331eb09f86f562cc5e6d1f3b668a5ab"}
{"timestamp":"2026-07-10T06:20:20Z","trace_id":"trace-1783664420-1347","harness":"H4-security","mode":"output","status":"pass","action":"allow","risk_level":"low","input_hash":"d1c3c3a7ee70994d948443ba05bb0a9c2dd1f1852a994ab80d722730d282ae44","output_hash":"d1c3c3a7ee70994d948443ba05bb0a9c2dd1f1852a994ab80d722730d282ae44"}
{"timestamp":"2026-07-10T07:20:30Z","trace_id":"trace-1783668030-52","harness":"H4-security","mode":"input","status":"pass","action":"allow","risk_level":"low","input_hash":"8aaecd06d85c7f743d3545e7b865f3053c647591f95919c1c0b003046d4757aa","output_hash":"8aaecd06d85c7f743d3545e7b865f3053c647591f95919c1c0b003046d4757aa"}
{"timestamp":"2026-07-10T07:21:21Z","trace_id":"trace-1783668081-716","harness":"H4-security","mode":"output","status":"pass","action":"allow","risk_level":"low","input_hash":"280cadd0be725e6496a8614b12a98836bedb5c8a38fc6b2a8afe5f44b237158e","output_hash":"280cadd0be725e6496a8614b12a98836bedb5c8a38fc6b2a8afe5f44b237158e"}
+8
View File
@@ -15,3 +15,11 @@
{"timestamp":"2026-07-08T09:28:30Z","trace_id":"7622fc43-9b84-4216-a704-96384d9b9375","harness":"H6-agentops","agent":"unknown-agent","step":"agent_step","status":"success","exit_code":0,"latency_ms":224,"retry_count":0,"input_tokens":3,"output_tokens":3,"total_tokens":6,"cost_estimate":1.2e-05,"cost_source":"word_count_estimate","hallucination_signals":0,"alerts":[],"input_hash":"7df5d60319af2916ff4d99c086c43b3989be3420fffff872e1077a650ca528f4","output_hash":"7df5d60319af2916ff4d99c086c43b3989be3420fffff872e1077a650ca528f4"}
{"timestamp":"2026-07-08T09:53:28Z","trace_id":"65ede58f-6400-49c1-b09e-e723035f7251","harness":"H6-agentops","agent":"unknown-agent","step":"agent_step","status":"success","exit_code":0,"latency_ms":219,"retry_count":0,"input_tokens":3,"output_tokens":3,"total_tokens":6,"cost_estimate":1.2e-05,"cost_source":"word_count_estimate","hallucination_signals":0,"alerts":[],"input_hash":"7df5d60319af2916ff4d99c086c43b3989be3420fffff872e1077a650ca528f4","output_hash":"7df5d60319af2916ff4d99c086c43b3989be3420fffff872e1077a650ca528f4"}
{"timestamp":"2026-07-08T09:53:32Z","trace_id":"06a26538-e7c3-42c2-a202-fc90cf24924d","harness":"H6-agentops","agent":"unknown-agent","step":"agent_step","status":"success","exit_code":0,"latency_ms":243,"retry_count":0,"input_tokens":3,"output_tokens":3,"total_tokens":6,"cost_estimate":1.2e-05,"cost_source":"word_count_estimate","hallucination_signals":0,"alerts":[],"input_hash":"7df5d60319af2916ff4d99c086c43b3989be3420fffff872e1077a650ca528f4","output_hash":"7df5d60319af2916ff4d99c086c43b3989be3420fffff872e1077a650ca528f4"}
{"timestamp": "2026-07-10T05:42:31Z", "trace_id": "ce743020-b73a-474a-871f-c465fb6eb472", "harness": "H6-agentops", "agent": "chat.ask-casan", "step": "ask-casan-readonly", "status": "failed", "exit_code": 2, "latency_ms": 159, "retry_count": 0, "input_tokens": 5, "output_tokens": 5, "total_tokens": 10, "cost_estimate": 0.0, "cost_source": "readonly_word_count", "synthesis_mode": "deterministic", "hallucination_signals": 0, "alerts": [], "input_hash": "5da5739dfab4b282139a20d7272d0e676331eb09f86f562cc5e6d1f3b668a5ab", "output_hash": "4f16989d1b5e09312bbd78611adcdae1219a4b19ec40252a986369155f049f96"}
{"timestamp": "2026-07-10T06:10:20Z", "trace_id": "96b4c003-ecd4-4f2e-b861-8d48f085d645", "harness": "H6-agentops", "agent": "chat.ask-casan", "step": "ask-casan-readonly", "status": "failed", "exit_code": 2, "latency_ms": 141, "retry_count": 0, "input_tokens": 5, "output_tokens": 5, "total_tokens": 10, "cost_estimate": 0.0, "cost_source": "readonly_word_count", "synthesis_mode": "deterministic", "hallucination_signals": 0, "alerts": [], "input_hash": "5da5739dfab4b282139a20d7272d0e676331eb09f86f562cc5e6d1f3b668a5ab", "output_hash": "4f16989d1b5e09312bbd78611adcdae1219a4b19ec40252a986369155f049f96"}
{"timestamp": "2026-07-10T06:11:24Z", "trace_id": "c924568f-2816-4ce0-b718-645b0c42733e", "harness": "H6-agentops", "agent": "chat.ask-casan", "step": "ask-casan-readonly", "status": "failed", "exit_code": 2, "latency_ms": 219, "retry_count": 0, "input_tokens": 5, "output_tokens": 5, "total_tokens": 10, "cost_estimate": 0.0, "cost_source": "readonly_word_count", "synthesis_mode": "deterministic", "hallucination_signals": 0, "alerts": [], "input_hash": "5da5739dfab4b282139a20d7272d0e676331eb09f86f562cc5e6d1f3b668a5ab", "output_hash": "4f16989d1b5e09312bbd78611adcdae1219a4b19ec40252a986369155f049f96"}
{"timestamp": "2026-07-10T06:12:28Z", "trace_id": "10331f30-ce27-47c6-bc5a-9f9841ff8415", "harness": "H6-agentops", "agent": "chat.ask-casan", "step": "ask-casan-readonly", "status": "failed", "exit_code": 2, "latency_ms": 342, "retry_count": 0, "input_tokens": 5, "output_tokens": 5, "total_tokens": 10, "cost_estimate": 0.0, "cost_source": "readonly_word_count", "synthesis_mode": "deterministic", "hallucination_signals": 0, "alerts": [], "input_hash": "5da5739dfab4b282139a20d7272d0e676331eb09f86f562cc5e6d1f3b668a5ab", "output_hash": "4f16989d1b5e09312bbd78611adcdae1219a4b19ec40252a986369155f049f96"}
{"timestamp": "2026-07-10T06:14:39Z", "trace_id": "2e9b93cb-f3a7-4ceb-accd-4e8001562d0a", "harness": "H6-agentops", "agent": "chat.ask-casan", "step": "ask-casan-readonly", "status": "success", "exit_code": 0, "latency_ms": 57161, "retry_count": 0, "input_tokens": 5, "output_tokens": 52, "total_tokens": 57, "cost_estimate": 0.0, "cost_source": "readonly_word_count", "synthesis_mode": "deterministic", "hallucination_signals": 0, "alerts": [], "input_hash": "5da5739dfab4b282139a20d7272d0e676331eb09f86f562cc5e6d1f3b668a5ab", "output_hash": "e756b35ca2e4c3326cada62e0a6d4b3d714c5afb0caab2268c86c63207228ed2"}
{"timestamp": "2026-07-10T06:19:34Z", "trace_id": "d8796e51-f1bf-4950-b27d-5327ed69ee12", "harness": "H6-agentops", "agent": "chat.ask-casan", "step": "ask-casan-readonly", "status": "success", "exit_code": 0, "latency_ms": 57864, "retry_count": 0, "input_tokens": 427, "output_tokens": 406, "total_tokens": 833, "cost_estimate": 0.0, "cost_source": "ollama_local_real_tokens", "synthesis_mode": "model", "hallucination_signals": 0, "alerts": [], "input_hash": "5da5739dfab4b282139a20d7272d0e676331eb09f86f562cc5e6d1f3b668a5ab", "output_hash": "142404dfd1bd2e31f6bef54bb495685e059a9b5c4b2e3369c8b79f8c7bec67a9"}
{"timestamp": "2026-07-10T06:20:20Z", "trace_id": "b5ad414a-4662-49c5-a024-3f2bb51ada64", "harness": "H6-agentops", "agent": "chat.ask-casan", "step": "ask-casan-readonly", "status": "success", "exit_code": 0, "latency_ms": 43174, "retry_count": 0, "input_tokens": 511, "output_tokens": 288, "total_tokens": 799, "cost_estimate": 0.0, "cost_source": "ollama_local_real_tokens", "synthesis_mode": "model", "hallucination_signals": 0, "alerts": [], "input_hash": "5da5739dfab4b282139a20d7272d0e676331eb09f86f562cc5e6d1f3b668a5ab", "output_hash": "d1c3c3a7ee70994d948443ba05bb0a9c2dd1f1852a994ab80d722730d282ae44"}
{"timestamp": "2026-07-10T07:21:22Z", "trace_id": "1fd5d347-f21c-4370-a60d-30b11fd8c53e", "harness": "H6-agentops", "agent": "chat.ask-casan", "step": "ask-casan-readonly", "status": "success", "exit_code": 0, "latency_ms": 51212, "retry_count": 0, "input_tokens": 362, "output_tokens": 59, "total_tokens": 421, "cost_estimate": 0.0, "cost_source": "ollama_local_real_tokens", "synthesis_mode": "model", "hallucination_signals": 0, "alerts": [], "input_hash": "8aaecd06d85c7f743d3545e7b865f3053c647591f95919c1c0b003046d4757aa", "output_hash": "280cadd0be725e6496a8614b12a98836bedb5c8a38fc6b2a8afe5f44b237158e"}
+1
View File
@@ -1,2 +1,3 @@
{"timestamp":"2026-07-08T09:20:52Z","trace_id":"42b46ba8-3399-440e-aaee-da88dc06bc1c","harness":"L5-model-fallback","primary_exit":9,"route":"fallback","final_exit":0,"output":"/Users/thanhnguyen/Documents/AI/HarnessHkt/Harness_Hakathon/Output_CASAN5_REFINED/docs/output/casan/level5-evidence/10-fallback-output.txt"}
{"timestamp":"2026-07-08T09:21:54Z","trace_id":"bc29e2dd-0930-4ded-9cd3-a011b5d24c4d","harness":"L5-model-fallback","primary_exit":1,"route":"fallback","final_exit":0,"output":"/var/folders/zn/qn8sqwzn18g34ddftsxgyz6r0000gn/T/tmp.2HkWTSLEMD/fb.out"}
{"timestamp":"2026-07-10T06:09:41Z","trace_id":"ed6b7bc1-3030-4656-9097-69ba16f47e5e","harness":"L5-model-fallback","primary_exit":2,"route":"fallback","final_exit":0,"output":"/var/folders/zn/qn8sqwzn18g34ddftsxgyz6r0000gn/T/tmp.jBxa5B3jDQ/fb.out"}
+11
View File
@@ -2,3 +2,14 @@
{"timestamp": "2026-07-08T09:20:56Z", "harness": "L5-provider-telemetry", "provider": "sample-provider", "model": "sample-model-large", "run_id": "provider-run-001", "step": "speckit.implement", "input_tokens": 1842, "output_tokens": 936, "total_tokens": 2778, "cost_usd": 0.08334, "latency_ms": 4210, "status": "success"}
{"timestamp": "2026-07-08T09:22:30Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "judge", "role": "judge", "input_tokens": 168, "output_tokens": 3, "total_tokens": 171, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 1515, "status": "success"}
{"timestamp": "2026-07-08T09:22:36Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "t4-telemetry-test", "role": "classify", "input_tokens": 208, "output_tokens": 2, "total_tokens": 210, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 1417, "status": "success"}
{"timestamp": "2026-07-10T06:09:37Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "generate", "role": "generate", "input_tokens": 72, "output_tokens": 27, "total_tokens": 99, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 9972, "status": "success"}
{"timestamp": "2026-07-10T06:09:40Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "classify", "role": "classify", "input_tokens": 218, "output_tokens": 3, "total_tokens": 221, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 2660, "status": "success"}
{"timestamp": "2026-07-10T06:09:46Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "generate", "role": "generate", "input_tokens": 72, "output_tokens": 29, "total_tokens": 101, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 4034, "status": "success"}
{"timestamp": "2026-07-10T06:13:44Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "classify", "role": "classify", "input_tokens": 216, "output_tokens": 2, "total_tokens": 218, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 2476, "status": "success"}
{"timestamp": "2026-07-10T06:15:04Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "generate", "role": "generate", "input_tokens": 73, "output_tokens": 39, "total_tokens": 112, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 4967, "status": "success"}
{"timestamp": "2026-07-10T06:18:39Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "classify", "role": "classify", "input_tokens": 216, "output_tokens": 2, "total_tokens": 218, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 2503, "status": "success"}
{"timestamp": "2026-07-10T06:19:34Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "generate", "role": "generate", "input_tokens": 427, "output_tokens": 406, "total_tokens": 833, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 41620, "status": "success"}
{"timestamp": "2026-07-10T06:19:38Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "classify", "role": "classify", "input_tokens": 216, "output_tokens": 2, "total_tokens": 218, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 626, "status": "success"}
{"timestamp": "2026-07-10T06:20:20Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "generate", "role": "generate", "input_tokens": 511, "output_tokens": 288, "total_tokens": 799, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 31437, "status": "success"}
{"timestamp": "2026-07-10T07:20:36Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "classify", "role": "classify", "input_tokens": 207, "output_tokens": 2, "total_tokens": 209, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 5868, "status": "success"}
{"timestamp": "2026-07-10T07:21:21Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "generate", "role": "generate", "input_tokens": 362, "output_tokens": 59, "total_tokens": 421, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 4829, "status": "success"}
+517
View File
@@ -0,0 +1,517 @@
# Codex Instructions for "OKR Web Application" Project
You are an expert full-stack developer specializing in NestJS and a **master of React with Vite and Tailwind CSS**. Your primary goal is to generate code that is secure, efficient, and **strictly follows this project's architecture and design documents**. Do not invent features or logic. Every piece of code must be traceable to a design specification.
## 1. The Supreme Rule: Adhere to Architecture & Design
**Before generating any code, you must understand the project's structure.**
- **Architecture:** Refer to `docs/technical_architecture.md` for the overall system design, module responsibilities, and technology stack.
- **Requirements:** Refer to `apps/okr/domain/input/okr-requirement.md` for functional requirements, use cases, and UI mockups.
- **Your Task:** Your role is to translate these designs into code, not to be creative.
**Example Check:**
- **CORRECT:** Logic for key result progress updates belongs in the `key-results` module.
- **INCORRECT:** Placing key result logic directly within the `objectives` service.
- **Typescript Types:** Use TypeScript types and interfaces as defined in the design documents. Do not create new types unless explicitly required by the design.
## 2. Key Technologies & Libraries to Prioritize
⚠️ **ABSOLUTE LIBRARY RESTRICTION - ZERO TOLERANCE POLICY:**
- **FORBIDDEN:** Installing ANY new libraries beyond those already listed in package.json
- **MANDATORY:** Use ONLY existing libraries from architecture.md dependencies
- **EXCEPTION PROCESS:** If absolutely critical to add a new library, must:
1. Stop all code generation
2. Ask explicit permission from user
3. Provide detailed justification of purpose and necessity
4. Explain why existing libraries cannot fulfill the requirement
5. Wait for user approval before proceeding
- **FOCUS:** Maximize capabilities of existing libraries rather than seeking new ones
**Use only libraries from architecture.md, do not install new libraries.**
### Frontend Stack (React + Vite SPA):
| Library | Role |
|---------|------|
| **React 18.x** | UI framework |
| **Vite 5.x** | Build tool & dev server with HMR |
| **React Router DOM 6.x** | Client-side routing (`<Routes>`, `<Route>`, `<Link>`) |
| **TanStack Query 5.x** | Server state management, caching (`useQuery`, `useMutation`) |
| **Axios 1.x** | HTTP client — all API calls via `src/lib/api.ts` |
| **React Hook Form 7.x** | Form state and submission |
| **Zod 3.x** | Schema validation — schemas in `src/schemas/`, shared with backend DTOs |
| **Tailwind CSS 3.x** | Utility-first styling — **only** styling tool, no CSS-in-JS |
### Backend Stack (NestJS):
| Library | Role |
|---------|------|
| **NestJS 10.x** | Application framework (modules, controllers, services, decorators) |
| **Prisma 5.x** | ORM — `schema.prisma` is single source of truth, use Prisma client for all DB ops |
| **@nestjs/jwt** | JWT sign/verify (no Passport — Username/Password only, no SSO) |
| **bcrypt** | Password hashing (cost factor 12) |
| **class-validator + class-transformer** | DTO validation with `ValidationPipe` |
| **@nestjs/swagger** | OpenAPI/Swagger UI at `/api/docs` (dev only) |
## 2.1. Design Style Guidelines
**Design Style: Clean Modern Dashboard (Pure Tailwind CSS)**
This project uses **pure Tailwind CSS** — no external component library (no MUI, no Ant Design). The design is clean, professional, and matches the OKR dashboard wireframes defined in `apps/okr/domain/input/okr-requirement.md`.
### Layout Structure (matches OKR wireframes):
```
+------------------+----------------------------------------------+
| Sidebar (fixed) | Top Header (fixed) |
| - Year nav +----------------------------------------------+
| - My OKRs | Main Content Area (scrollable) |
| - Members | |
| - OKR - all | |
+------------------+----------------------------------------------+
```
### Color Palette:
| Color Name | Tailwind Class | Usage |
|------------|----------------|-------|
| **Background** | `bg-gray-50` | Page background |
| **White** | `bg-white` | Cards, sidebar, content areas |
| **Primary Text** | `text-gray-800` | Titles, important content |
| **Secondary Text** | `text-gray-500` | Labels, descriptions, metadata |
| **Primary Blue** | `bg-blue-600` / `text-blue-600` | Primary actions, active nav links |
| **Success Green** | `text-green-600` / `bg-green-100` | Success status (100% / Completed) |
| **Warning Orange** | `text-orange-500` / `bg-orange-100` | In-progress / pending status |
| **Border** | `border-gray-200` | Card borders, dividers |
| **Sidebar** | `bg-white border-r border-gray-200` | Left navigation panel |
### Component Style Rules:
1. **No CSS-in-JS** — no `sx` prop, no `styled()` — all styling via Tailwind utility classes.
2. **Cards:** `bg-white rounded-xl shadow-sm border border-gray-200 p-6`
3. **Primary button:** `bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-medium transition-colors`
4. **Secondary button:** `border border-gray-300 text-gray-700 hover:bg-gray-50 px-4 py-2 rounded-lg transition-colors`
5. **Input fields:** `w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500`
6. **Select/Dropdown:** `border border-gray-300 rounded-lg px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-blue-500`
7. **Status badges:**
- Not Started: `bg-gray-100 text-gray-600 px-2 py-1 rounded-full text-xs font-medium`
- In Progress: `bg-orange-100 text-orange-700 px-2 py-1 rounded-full text-xs font-medium`
- Completed: `bg-green-100 text-green-700 px-2 py-1 rounded-full text-xs font-medium`
8. **Progress bar:**
```tsx
<div className="w-full bg-gray-200 rounded-full h-2">
<div className="bg-blue-600 rounded-full h-2 transition-all" style={{ width: `${progress}%` }} />
</div>
```
9. **Sidebar nav item (active):** `bg-blue-50 text-blue-600 font-medium`
10. **Sidebar nav item (inactive):** `text-gray-600 hover:bg-gray-50 hover:text-gray-800`
### Consistent Visual Rules:
- All interactive elements must have hover effects (`hover:shadow-md`, `hover:-translate-y-px`, `transition-colors`).
- Use consistent spacing: multiples of 4px (`p-4`, `gap-4`, `mt-6`).
- Text must always use `gray` (not `grey`) — `text-gray-700`, `border-gray-200`.
## 2.2. Tailwind CSS Configuration Rules (Tailwind v3)
⚠️ **TAILWIND CSS v3 SPECIFIC REQUIREMENTS:**
### CSS Import Rules (v3):
```css
✅ CORRECT:
@tailwind base;
@tailwind components;
@tailwind utilities;
❌ WRONG: @import "tailwindcss";
❌ WRONG: @import "tailwindcss/base";
```
### Configuration File:
```javascript
// tailwind.config.ts
import type { Config } from 'tailwindcss';
const config: Config = {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [],
};
export default config;
```
### PostCSS Config:
```javascript
// postcss.config.js
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
```
### Color Class Rules:
```css
✅ CORRECT: text-gray-700, bg-gray-50, border-gray-200
❌ WRONG: text-grey-700, bg-grey-50, border-grey-200
```
### Layout Rules:
```tsx
✅ CORRECT: Use semantic HTML + Tailwind flex/grid
<div className="flex justify-between items-center">
<h1 className="text-xl font-semibold text-gray-800">Title</h1>
<button className="bg-blue-600 text-white px-4 py-2 rounded-lg">Action</button>
</div>
❌ WRONG: Use third-party layout components
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="h4">Title</Typography>
</Box>
```
### Hover State Rules:
```css
✅ CORRECT: hover:bg-gray-50, hover:bg-gray-100
❌ WRONG: hover:bg-gray-25 (doesn't exist in Tailwind)
```
### Custom Styles Rules:
```css
✅ CORRECT: Use standard CSS in index.css (no @apply with @layer)
.okr-card {
background: white;
border-radius: 0.75rem;
border: 1px solid #e5e7eb;
}
❌ WRONG: Use @apply with @layer
@layer components {
.okr-card { @apply bg-white rounded-xl border border-gray-200; }
}
```
## 3. Backend Generation Rules (NestJS + Prisma)
### Controller Rules:
- **Location:** Must be in `backend/src/[module-name]/[module-name].controller.ts`.
- **Responsibility:** Keep controllers "thin". They only receive requests, trigger guards, validate DTOs, and call a single service method.
- **Auth:** Use `@UseGuards(JwtAuthGuard)` and `@Roles()` decorator on all protected routes.
### Service Rules:
- **Location:** Must be in `backend/src/[module-name]/[module-name].service.ts`.
- **Responsibility:** All business logic lives here.
- **Key Logic:**
- Use Prisma client for all DB operations — **no raw SQL** in application code.
- Filter data by role: ADMIN sees all, MANAGER sees all, EMPLOYEE sees only own objectives.
- Throw specific NestJS exceptions (`NotFoundException`, `ForbiddenException`).
### Prisma Schema Rules:
- **Single source of truth:** `backend/prisma/schema.prisma` defines ALL tables.
- **Migrations:** Use `npx prisma migrate dev --name <migration-name>` — never edit migration files manually.
- **Types:** Always use Prisma-generated types (`Prisma.ObjectiveCreateInput`, `Prisma.KeyResultUpdateInput`).
### Module Structure (OKR Domain):
```
backend/src/
├── auth/ # JWT login, refresh token endpoints
├── users/ # User CRUD (Admin/Manager only)
├── objectives/ # Objective CRUD, filtering by quarter/owner/status
├── key-results/ # KR CRUD, progress update (PATCH /:id/progress)
└── common/ # Guards, filters, interceptors, decorators
```
### Database Seed Management:
- **CRITICAL:** After completing backend code with schema changes, **ALWAYS** update the seed file.
- **Seed file location:** `backend/prisma/seed.ts`
- **Execution:** `npx prisma db seed` (or automatically on container start — always seeded in workshop environment)
- **Idempotency:** Use Prisma `upsert` keyed on stable identifiers — running seed twice must produce no duplicates.
```typescript
// backend/prisma/seed.ts
import { PrismaClient } from '@prisma/client';
import * as bcrypt from 'bcrypt';
const prisma = new PrismaClient();
async function main() {
// Always seed — workshop environment, no env gate needed
const passwordHash = await bcrypt.hash('Password@123', 10);
// Users — upsert keyed on email
const admin = await prisma.user.upsert({
where: { email: 'admin@okr.local' },
update: {},
create: { name: 'System Admin', email: 'admin@okr.local', password: passwordHash, role: 'ADMIN' },
});
const manager = await prisma.user.upsert({
where: { email: 'manager@okr.local' },
update: {},
create: { name: 'Nguyen Van Manager', email: 'manager@okr.local', password: passwordHash, role: 'MANAGER' },
});
const employee = await prisma.user.upsert({
where: { email: 'employee@okr.local' },
update: {},
create: { name: 'Nguyen Van A', email: 'employee@okr.local', password: passwordHash, role: 'EMPLOYEE' },
});
// Objectives — upsert keyed on id
const obj1 = await prisma.objective.upsert({
where: { id: 1 },
update: {},
create: {
title: 'POC AI for SQL Injection prevention',
description: 'Evaluate AI tools for automated SQL injection detection',
ownerId: employee.id,
quarter: 'Q2/2026',
status: 'IN_PROGRESS',
},
});
// Key Results — upsert keyed on id
await prisma.keyResult.upsert({
where: { id: 1 },
update: {},
create: {
objectiveId: obj1.id,
title: 'Complete 3 POC sessions with security team',
progress: 0,
startValue: 0,
targetValue: 3,
deadline: new Date('2026-06-30'),
},
});
console.log('Seed completed successfully.');
}
main()
.catch((e) => { console.error(e); process.exit(1); })
.finally(() => prisma.$disconnect());
```
### Seed Data Requirements (OKR Domain):
| Category | Records | Notes |
|----------|---------|-------|
| Users | ≥ 3 | Admin, Manager, Employee — password `Password@123` hashed with bcrypt |
| Objectives | ≥ 2 | One per owner, varied statuses (`NOT_STARTED`, `IN_PROGRESS`) |
| Key Results | ≥ 4 | Linked to objectives, with `progress`, `startValue`, `targetValue`, `deadline` |
- **When to update seed:** After adding new Prisma model fields, after changing relations, after completing any backend module.
- **Run seed:** `docker-compose exec backend npx prisma db seed`
## 4. Frontend Generation Rules (React + Vite)
### Routing Rules (React Router DOM v6):
```tsx
// frontend/src/App.tsx — route structure
<Routes>
<Route path="/login" element={<Login />} />
<Route element={<ProtectedRoute />}>
<Route path="/" element={<AppLayout />}>
<Route index element={<Dashboard />} />
<Route path="objectives/new" element={<CreateObjective />} />
<Route path="objectives/:id" element={<OKRDetail />} />
<Route path="key-results/:id" element={<KeyResultDetail />} />
</Route>
</Route>
</Routes>
```
### Component & File Location Rules:
- **Route-level pages:** `frontend/src/pages/` (Login, Dashboard, OKRDetail, CreateObjective, KeyResultDetail)
- **Layout components:** `frontend/src/components/layout/` (Sidebar, Header, AppLayout)
- **Reusable UI components:** `frontend/src/components/ui/` (Button, ProgressBar, Badge, Table)
- **Custom hooks:** `frontend/src/hooks/` (useAuth, useObjectives, useKeyResults)
- **API client:** `frontend/src/lib/api.ts` — all Axios calls go here
- **Query client config:** `frontend/src/lib/queryClient.ts`
- **Zod schemas:** `frontend/src/schemas/`
- **TypeScript interfaces:** `frontend/src/types/`
### Layout Construction Rules:
- **ONLY** use semantic HTML elements (`<div>`, `<nav>`, `<aside>`, `<main>`, `<section>`) + Tailwind classes.
- **DO NOT** import any external component library for layout or UI.
- **Example:**
```tsx
// ✅ CORRECT: semantic HTML + Tailwind
<div className="flex justify-between items-center mb-6">
<h1 className="text-xl font-semibold text-gray-800">My OKRs</h1>
<button className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700">
+ New OKR
</button>
</div>
```
### Data Fetching & Mutation Rules:
- **`useQuery`:** Query key must be descriptive and include filter params.
```typescript
// Query key examples
['objectives', { quarter: 'Q2/2026', ownerId }]
['objective', id]
['key-results', objectiveId]
```
- **`useMutation`:** Always invalidate relevant queries `onSuccess`.
```typescript
const updateProgress = useMutation({
mutationFn: (data: UpdateProgressDto) => api.updateKRProgress(krId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['objective', objectiveId] });
queryClient.invalidateQueries({ queryKey: ['objectives'] });
},
});
```
### API Call Rules:
- All functions that make network requests must be in `frontend/src/lib/api.ts`.
- Components call functions from `lib/api.ts` — they **never** call Axios directly.
- Base URL: `import.meta.env.VITE_API_BASE_URL` (e.g., `http://localhost:3000/api/v1`).
- Auth tokens are in HttpOnly cookies — do **not** manually attach `Authorization` headers.
### Form Rules (React Hook Form + Zod):
```typescript
// frontend/src/schemas/objective.schema.ts
import { z } from 'zod';
export const createObjectiveSchema = z.object({
title: z.string().min(1, 'Title is required'),
description: z.string().optional(),
ownerId: z.number().int().positive('Owner is required'),
quarter: z.string().regex(/^Q[1-4]\/\d{4}$/, 'Format must be Q2/2026'),
});
export type CreateObjectiveFormData = z.infer<typeof createObjectiveSchema>;
// Usage in component
const { register, handleSubmit, formState: { errors } } = useForm<CreateObjectiveFormData>({
resolver: zodResolver(createObjectiveSchema),
});
```
## 5. TypeScript Type Safety Rules
**CRITICAL: Always verify types after generating code. Type safety is mandatory.**
### Type Checking Process:
1. **After generating any code, ALWAYS:**
- Run TypeScript checks to ensure no type errors
- Verify all imports have correct types
- Check component props interfaces match usage
- Ensure API response types align with frontend expectations
- Validate DTOs consistency between frontend and backend
2. **Forbidden Patterns:**
```typescript
// ❌ NEVER use 'any' type
const data: any = response.data;
// ❌ NEVER leave props untyped
function Component(props) { ... }
// ❌ NEVER call Axios directly in components
const response = await axios.get('/api/v1/objectives');
```
3. **Required Patterns:**
```typescript
// ✅ Typed component props
interface OKRCardProps {
objective: Objective;
onEdit?: (id: number) => void;
}
// ✅ Standard API response envelope (matches backend)
interface ApiResponse<T> {
success: boolean;
data: T;
meta?: { page: number; limit: number; total: number };
}
// ✅ OKR domain types (frontend/src/types/okr.types.ts)
interface Objective {
id: number;
title: string;
description?: string;
ownerId: number;
owner: User;
quarter: string;
status: 'NOT_STARTED' | 'IN_PROGRESS' | 'COMPLETED';
keyResults: KeyResult[];
}
interface KeyResult {
id: number;
objectiveId: number;
title: string;
progress: number;
startValue: number;
targetValue: number;
deadline: string; // ISO date string
}
// ✅ Backend DTO matching frontend interface
export class CreateObjectiveDto {
@IsString()
@IsNotEmpty()
title: string;
@IsString()
@IsOptional()
description?: string;
@IsInt()
@IsPositive()
ownerId: number;
@IsString()
@Matches(/^Q[1-4]\/\d{4}$/)
quarter: string;
}
```
### Type Consistency Rules:
- **Frontend-Backend Alignment:** DTOs in backend must have matching interfaces in `frontend/src/types/`.
- **Enum Consistency:** Role (`ADMIN | MANAGER | EMPLOYEE`) and Status (`NOT_STARTED | IN_PROGRESS | COMPLETED`) enums must be identical between frontend and backend.
- **API Response Types:** Every API endpoint must have typed response interfaces matching the standard envelope `{ success, data, meta? }`.
- **Component Props:** Every component must have a properly typed props interface.
- **Zod Schemas:** Schemas in `frontend/src/schemas/` must align with backend `class-validator` rules on the corresponding DTO.
### Type Verification Checklist:
Before submitting any code, verify:
- [ ] No `any` types used
- [ ] All component props properly typed
- [ ] API calls have typed parameters and responses
- [ ] DTOs match between frontend/backend
- [ ] Role/Status enum values consistent across codebase
- [ ] Optional vs required properties correctly defined
- [ ] Zod schemas align with backend `class-validator` rules
+3 -2
View File
@@ -2,7 +2,7 @@ FROM node:20-slim AS builder
WORKDIR /app
RUN apt-get update -qq && apt-get install -y -qq python3 openssl bash && rm -rf /var/lib/apt/lists/*
RUN apt-get update -qq && apt-get install -y -qq python3 python-is-python3 curl openssl bash && rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
COPY packages/casan-control-panel/backend/package.json ./packages/casan-control-panel/backend/
@@ -17,7 +17,7 @@ FROM node:20-slim AS runtime
WORKDIR /app
RUN apt-get update -qq && apt-get install -y -qq python3 openssl bash && rm -rf /var/lib/apt/lists/*
RUN apt-get update -qq && apt-get install -y -qq python3 python-is-python3 curl openssl bash && rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
COPY packages/casan-control-panel/backend/package.json ./packages/casan-control-panel/backend/
@@ -25,6 +25,7 @@ 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 packages/casan-harness/scripts ./packages/casan-harness/scripts
COPY packages/casan-harness/config ./packages/casan-harness/config
COPY packages/casan-harness/security ./packages/casan-harness/security
WORKDIR /app/packages/casan-control-panel/backend
+13
View File
@@ -16,6 +16,19 @@ services:
CP_PORT: "3010"
CASAN_APP_ROOT: /app
CASAN_DASHBOARD_STALE_S: "315360000"
# Optional local model gateway. Values come from infra/local-prod/casan.local.env
# via local-full.sh and are never committed with credentials.
# The Mac-hosted Ornith/Ollama model is the local default. If it is not
# reachable, the governed chat falls back safely to deterministic answers.
CASAN_CHAT_MODEL_MODE: ${CASAN_CHAT_MODEL_MODE:-model}
CASAN_CHAT_MODEL_PROVIDER: ${CASAN_CHAT_MODEL_PROVIDER:-local}
CASAN_OLLAMA_HOST: host.docker.internal:11434
CASAN_ALLOW_DOCKER_HOST_OLLAMA: "1"
OLLAMA_HOST: host.docker.internal:11434
CASAN_OPENAI_COMPATIBLE_BASE_URL: ${CASAN_OPENAI_COMPATIBLE_BASE_URL:-}
CASAN_OPENAI_COMPATIBLE_API_KEY: ${CASAN_OPENAI_COMPATIBLE_API_KEY:-}
CASAN_OPENAI_COMPATIBLE_ALLOWED_HOSTS: ${CASAN_OPENAI_COMPATIBLE_ALLOWED_HOSTS:-}
CASAN_PREFLIGHT: ${CASAN_PREFLIGHT:-0}
volumes:
- ./.specify:/app/.specify
- ./docs/output:/app/docs/output:ro
+203
View File
@@ -0,0 +1,203 @@
# CASAN local full stack (macOS)
This guide runs the entire currently implemented CASAN stack on one Mac. It is
a local production-like lab, not a production compliance claim. The Linux
server is deliberately not used in this phase.
## What runs locally
| Component | Address | Role |
|---|---|---|
| Vault dev / Transit | `http://127.0.0.1:18200` | KMS signing and rotation exercises |
| Mock OIDC / JWKS | `http://127.0.0.1:18081` | Approval JWT validation |
| MinIO S3 API | `http://127.0.0.1:19090` | S3-compatible object storage |
| MinIO Console | `http://127.0.0.1:19091` | S3 administration UI |
| MinIO WORM bucket | `casan-worm` | Object Lock / 1-day compliance retention |
| Alert mock | `http://127.0.0.1:19092` | Alert webhook target |
| Billing mock | `http://127.0.0.1:19093/usage` | Provider-usage import target |
| AgentOps dashboard | `http://127.0.0.1:18080` | Dashboard behind nginx basic auth |
| CASAN Control Panel | `https://localhost:18443` | TLS + mock OIDC + oauth2-proxy + nginx + NestJS + React |
| OmniRoute | `http://127.0.0.1:20128/v1` | Existing local OpenAI-compatible model gateway |
The Control Panel uses a different mock IdP port (`18082`) so it can run at the
same time as the approval IdP (`18081`).
## 1. Prerequisites
- Docker Desktop running, with at least 6 GB RAM allocated.
- Node.js 20+ and npm (needed only for host-side development and CASAN pipeline
execution; the local services run in Docker).
- OmniRoute already reachable at `http://127.0.0.1:20128/v1` when model mode is
required.
Confirm Docker first:
```bash
docker version
docker compose version
```
## 2. Start every local service
From the repository root:
```bash
bash packages/casan-harness/scripts/bash/local-full.sh start
```
The first run builds the mock services and Control Panel images. Open:
- Dashboard: `http://127.0.0.1:18080` — credentials `casan` / `casan`.
- MinIO Console: `http://127.0.0.1:19091` — credentials `casanadmin` /
`casanadmin123`.
- Control Panel: `https://localhost:18443` — accept the local self-signed
certificate. The mock OIDC flow signs in automatically as `oidc-ops` with
`org-admin` for local testing only.
Check running services at any time:
```bash
bash packages/casan-harness/scripts/bash/local-full.sh status
```
## 3. Verify infrastructure controls
```bash
bash packages/casan-harness/scripts/bash/local-full.sh verify
```
This performs live Vault Transit sign/verify, gets an RS256 JWT from the mock
OIDC IdP and validates it via JWKS, writes a retained object to MinIO, sends an
alert, imports billing telemetry, and accesses the authenticated dashboard.
For the stricter TLS/OIDC Control Panel browser smoke:
```bash
bash packages/casan-harness/scripts/bash/local-full.sh smoke
```
`smoke` is intentionally isolated and stops its temporary Control Panel stack
when complete. Run `start` again if you want the UI to remain up afterwards.
## 4. Connect CASAN to OmniRoute
CASAN now supports an explicit OpenAI-compatible gateway model syntax. It does
not relax the public OpenAI endpoint hardening: the custom URL is required and,
by default, may only be localhost. Keep secrets out of shell history and Git.
First load your key into the current shell using the mechanism you normally use
(for example a password manager or a non-tracked `.env.local`). Then list model
IDs that OmniRoute exposes:
```bash
curl -fsS http://127.0.0.1:20128/v1/models | python3 -m json.tool
```
Configure a returned model ID. `CASAN_OPENAI_COMPATIBLE_API_KEY` may be a
dedicated OmniRoute key; if omitted, the router falls back to `OPENAI_API_KEY`.
```bash
export CASAN_OPENAI_COMPATIBLE_BASE_URL=http://127.0.0.1:20128/v1
export CASAN_OPENAI_COMPATIBLE_API_KEY="$OPENAI_API_KEY"
export CASAN_MODEL_PRIMARY='openai-compatible:auto/best-coding'
export CASAN_CHAT_MODEL_MODE=model
export CASAN_CHAT_MODEL_PROVIDER=omniroute
export CASAN_PREFLIGHT=1
```
For the Dockerized Control Panel, copy the provided template and replace only
the key locally. Docker uses `host.docker.internal` so the Linux container can
reach OmniRoute on the Mac host.
```bash
cp infra/local-prod/casan.local.env.example infra/local-prod/casan.local.env
# Edit casan.local.env in your editor and replace the placeholder key.
bash packages/casan-harness/scripts/bash/local-full.sh start
```
Do not set a bare `localhost` URL in this file: inside the Control Panel
container, `localhost` means the container itself, not your Mac.
Run a non-destructive router smoke. A successful response records real token
usage in `.specify/logs/level5/provider-usage.jsonl`.
```bash
printf 'Return exactly: CASAN_OK\n' >/tmp/casan-prompt.txt
bash packages/casan-harness/scripts/bash/model-router.sh \
/tmp/casan-prompt.txt /tmp/casan-model-result.json --role generate \
--model "$CASAN_MODEL_PRIMARY"
python3 -m json.tool /tmp/casan-model-result.json
```
The configured `omniroute` chat provider is classified as a gateway and always
forces CASAN's preflight data-governance check before prompt content is sent.
This is intentional: OmniRoute could route a request to a cloud model even when
the first selected model is local.
To use a gateway on a private-LAN host later, explicitly name it; never use an
open allowlist:
```bash
export CASAN_OPENAI_COMPATIBLE_BASE_URL=http://192.168.1.5:20128/v1
export CASAN_OPENAI_COMPATIBLE_ALLOWED_HOSTS=192.168.1.5
```
Use HTTPS and a real certificate before moving this beyond a trusted LAN.
### Local Ornith default for the Control Panel
The local Docker Control Panel now defaults to the existing host Ollama model
`ornith:9b`. It reaches Docker Desktop's host bridge at
`host.docker.internal:11434`; CASAN permits only that exact bridge when the
local compose profile explicitly enables it. If Ollama is unavailable, chat
returns its deterministic evidence answer instead of fabricating a model result.
Use OmniRoute/OpenAI only when you deliberately want the gateway route. Create
`casan.local.env` as described above and set:
```bash
CASAN_CHAT_MODEL_PROVIDER=omniroute
CASAN_OPENAI_COMPATIBLE_BASE_URL=http://host.docker.internal:20128/v1
CASAN_OPENAI_COMPATIBLE_API_KEY=your-key
```
## 5. Run CASAN with local models
For model-backed source generation, use the same environment and enable it only
for the invocation:
```bash
CASAN_GEN_MODE=model CASAN_PREFLIGHT=1 \
node scripts/casan-step.mjs 01-srs 1
```
CASAN continues to fall back safely to deterministic templates if the gateway
is unavailable or the output is rejected by a harness gate. For governed chat,
start the Control Panel and select the `omniroute` provider through the existing
model configuration; the default remains deterministic/offline.
## 6. Stop and reset
Stop all containers while preserving MinIO data:
```bash
bash packages/casan-harness/scripts/bash/local-full.sh stop
```
To remove only the lab's persisted object-store data, first stop the lab, then:
```bash
docker volume rm casan-local-prod_minio-data
```
This is destructive and cannot be undone. Do not use it if the MinIO bucket has
evidence you need to retain.
## Current scope and next phase
This local environment includes every currently implemented CASAN component.
It intentionally uses Vault dev mode, self-signed TLS, mock IdPs, local MinIO,
and mock alert/billing endpoints. Production promotion to the Linux server
should replace those with managed Vault/KMS or HSM, real S3 Object Lock, real
OIDC, HTTPS certificates, a secret manager, backup/restore tests, and firewall
rules; it is a separate deployment exercise.
+8
View File
@@ -0,0 +1,8 @@
# Copy this file to casan.local.env, then replace the API-key value locally.
# It is consumed only by local-full.sh and is ignored by Git.
CASAN_CHAT_MODEL_MODE=model
CASAN_CHAT_MODEL_PROVIDER=omniroute
CASAN_OPENAI_COMPATIBLE_BASE_URL=http://host.docker.internal:20128/v1
CASAN_OPENAI_COMPATIBLE_API_KEY=replace-with-your-omniroute-or-openai-key
CASAN_PREFLIGHT=1
@@ -27,6 +27,15 @@ export class ChatController {
return ok(this.svc.verifyAudit());
}
@Get('history')
history(
@Headers() headers: Record<string, string | string[] | undefined>,
@Query('chatId') chatId?: string,
@Query('limit') limit?: string,
) {
return ok(this.svc.history(actorFromHeaders(headers), chatId || '', Number(limit ?? 50)));
}
@Get('replay')
replay(@Query('chatId') chatId?: string, @Query('turnId') turnId?: string, @Query('tenant') tenant?: string) {
return ok(this.svc.replay(chatId || '', turnId || '', tenant || ''));
@@ -95,6 +95,17 @@ export class ChatService {
return { ok: res.status === 0, output: res.stdout || res.stderr };
}
history(actor: SettingsActor, chatId = '', limit = 50) {
this.requireRead(actor);
const safeLimit = Math.max(1, Math.min(Number.isFinite(limit) ? Math.trunc(limit) : 50, 100));
const args = ['history', '--actor', actor.actor, '--tenant', actor.tenant, '--limit', String(safeLimit)];
if (chatId) args.push('--chat-id', chatId);
const res = runPython(CHAT_CLI, args);
const parsed = parseJson<Record<string, unknown>>(res.stdout);
if (res.status === 0 && parsed?.ok === true) return parsed;
throw new InternalServerErrorException(res.stderr || res.stdout || 'CHAT_HISTORY_FAILED');
}
/**
* Item 3: streaming read-only/analysis turns. The harness emits two NDJSON
* phases — an UNCERTIFIED deterministic draft, then the certified final. We
@@ -47,6 +47,23 @@ test('chat ask returns certified read-only answer with evidence sources', () =>
});
});
test('chat history returns only integrity-checked previews for the requesting actor', () => {
withTempChatState(() => {
const svc = new ChatService();
svc.ask({ message: 'Summarize Plan 18 MVP-0 status', chatId: 'history-chat' }, viewer);
svc.ask({ message: 'Summarize Plan 18 MVP-2 status', chatId: 'history-chat' }, viewer);
svc.ask({ message: 'Summarize Plan 18 MVP-0 status', chatId: 'other-actor' }, { ...viewer, actor: 'someone-else' });
const history = svc.history(viewer, 'history-chat') as any;
assert.equal(history.ok, true);
assert.equal(history.turns.length, 2);
assert.equal(history.turns[0].chat_id, 'history-chat');
assert.ok(history.turns.every((turn: any) => turn.prompt_preview && turn.audit_hash));
assert.equal(history.conversations.length, 1);
assert.equal(history.conversations[0].chat_id, 'history-chat');
});
});
test('chat ask denies prompt injection and returns governed block response', () => {
withTempChatState(() => {
const svc = new ChatService();
@@ -3,11 +3,13 @@ import { Sidebar } from './Sidebar';
import { Header } from './Header';
export function AppLayout({ children }: { children: ReactNode }) {
return (
<div className="flex h-screen bg-gray-50">
<div className="flex min-h-screen bg-slate-100">
<Sidebar />
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex min-w-0 flex-1 flex-col">
<Header />
<main className="flex-1 overflow-auto p-6 space-y-6">{children}</main>
<main className="flex-1 overflow-auto px-4 py-5 pb-24 sm:px-6 lg:px-8 lg:py-7">
<div className="mx-auto max-w-[1600px] space-y-5">{children}</div>
</main>
</div>
</div>
);
@@ -1,16 +1,42 @@
import { useQuery } from '@tanstack/react-query';
import { useLocation } from 'react-router-dom';
import { health } from '../../lib/api';
const PAGE_COPY: Record<string, { title: string; eyebrow: string }> = {
'/': { title: 'Operations overview', eyebrow: 'System posture' },
'/command': { title: 'Command center', eyebrow: 'Executive view' },
'/chat': { title: 'Governed workspace', eyebrow: 'Ask CASAN' },
'/runs': { title: 'Run observability', eyebrow: 'Execution ledger' },
'/governance': { title: 'Governance ledger', eyebrow: 'Policy decisions' },
'/security': { title: 'Security signals', eyebrow: 'H4 protection' },
'/incidents': { title: 'Incident response', eyebrow: 'Containment' },
'/traceability': { title: 'Traceability', eyebrow: 'Evidence graph' },
'/finops': { title: 'FinOps & SLO', eyebrow: 'Model economics' },
'/approvals': { title: 'Approval inbox', eyebrow: 'Human-in-the-loop' },
'/settings': { title: 'Governed settings', eyebrow: 'Control plane' },
};
export function Header() {
const { data } = useQuery({ queryKey: ['health'], queryFn: health });
const { pathname } = useLocation();
const stale = data ? !data.ok : true;
const page = PAGE_COPY[pathname] ?? PAGE_COPY['/'];
return (
<header className="h-14 bg-white border-b border-gray-200 flex items-center justify-between px-6">
<h1 className="text-base font-semibold text-gray-800">CASAN Ops Console <span className="text-gray-400 font-normal">· read-only</span></h1>
<div className="flex items-center gap-3 text-xs">
<span className="text-gray-500">runs: {data?.runs ?? '—'}</span>
<span className={`px-2 py-1 rounded-full font-medium ${stale ? 'bg-orange-100 text-orange-700' : 'bg-green-100 text-green-700'}`}>
{stale ? `STALE${data?.metrics_age_s != null ? ` (${data.metrics_age_s}s)` : ''}` : 'LIVE'}
</span>
<header className="sticky top-0 z-20 border-b border-slate-200/80 bg-white/85 px-4 py-3 backdrop-blur-xl sm:px-6 lg:px-8">
<div className="mx-auto flex max-w-[1600px] items-center justify-between gap-4">
<div>
<div className="text-[10px] font-bold uppercase tracking-[0.18em] text-indigo-600">{page.eyebrow}</div>
<h1 className="mt-0.5 text-lg font-semibold tracking-tight text-slate-900">{page.title}</h1>
</div>
<div className="flex items-center gap-2.5 text-xs">
<div className="hidden rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-slate-500 sm:block">
<span className="font-medium text-slate-700">{data?.runs ?? '—'}</span> governed runs
</div>
<div className={`flex items-center gap-2 rounded-xl border px-3 py-2 font-semibold ${stale ? 'border-amber-200 bg-amber-50 text-amber-700' : 'border-emerald-200 bg-emerald-50 text-emerald-700'}`}>
<span className={`h-1.5 w-1.5 rounded-full ${stale ? 'bg-amber-500' : 'bg-emerald-500'}`} />
{stale ? `STALE${data?.metrics_age_s != null ? ` · ${data.metrics_age_s}s` : ''}` : 'LIVE TRUST'}
</div>
</div>
</div>
</header>
);
@@ -1,21 +1,93 @@
import type { ReactNode } from 'react';
import { NavLink } from 'react-router-dom';
const NAV = [
['/', 'Overview'], ['/runs', 'Runs'], ['/governance', 'Governance'],
['/security', 'Security'], ['/incidents', 'Incidents'], ['/traceability', 'Traceability'],
['/finops', 'FinOps'], ['/approvals', 'Approvals'], ['/settings', 'Settings'], ['/command', 'Command'], ['/chat', 'Chat'],
type IconName = 'grid' | 'command' | 'chat' | 'runs' | 'shield' | 'governance' | 'incident' | 'trace' | 'coins' | 'approval' | 'settings';
interface NavItem { to: string; label: string; icon: IconName; }
const NAVIGATION: Array<{ label: string; items: NavItem[] }> = [
{ label: 'Observe', items: [
{ to: '/', label: 'Overview', icon: 'grid' },
{ to: '/command', label: 'Command center', icon: 'command' },
{ to: '/chat', label: 'Ask CASAN', icon: 'chat' },
{ to: '/runs', label: 'Run observability', icon: 'runs' },
] },
{ label: 'Assure', items: [
{ to: '/governance', label: 'Governance', icon: 'governance' },
{ to: '/security', label: 'Security', icon: 'shield' },
{ to: '/incidents', label: 'Incidents', icon: 'incident' },
{ to: '/traceability', label: 'Traceability', icon: 'trace' },
] },
{ label: 'Control', items: [
{ to: '/finops', label: 'FinOps & SLO', icon: 'coins' },
{ to: '/approvals', label: 'Approvals', icon: 'approval' },
{ to: '/settings', label: 'Settings', icon: 'settings' },
] },
];
function Icon({ name }: { name: IconName }) {
const paths: Record<IconName, ReactNode> = {
grid: <><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></>,
command: <><path d="M5 12h14M12 5l7 7-7 7" /><path d="M5 5v14" /></>,
chat: <><path d="M20 11.5a7.5 7.5 0 0 1-8 7.5 8.4 8.4 0 0 1-3.7-.9L4 19l1.2-3.5A7.5 7.5 0 1 1 20 11.5Z" /><path d="M8.5 11.5h.01M12 11.5h.01M15.5 11.5h.01" /></>,
runs: <><path d="M4 19V9M10 19V5M16 19v-7M22 19H2" /><path d="M3 9h2M9 5h2M15 12h2" /></>,
shield: <path d="M12 3 20 6v5c0 5.2-3.4 8.9-8 10-4.6-1.1-8-4.8-8-10V6l8-3Z" />,
governance: <><path d="M4 20h16M6 17V9M10 17V5M14 17V9M18 17V5" /><path d="M3 5h18l-9-3-9 3Z" /></>,
incident: <><path d="M10.3 3.3 2.7 17a2 2 0 0 0 1.7 3h15.2a2 2 0 0 0 1.7-3L13.7 3.3a2 2 0 0 0-3.4 0Z" /><path d="M12 9v4M12 17h.01" /></>,
trace: <><circle cx="6" cy="6" r="3" /><circle cx="18" cy="18" r="3" /><circle cx="18" cy="6" r="3" /><path d="m8.6 7.5 6.8 3M9 6h6" /></>,
coins: <><ellipse cx="12" cy="5" rx="7" ry="3" /><path d="M5 5v7c0 1.7 3.1 3 7 3s7-1.3 7-3V5M5 12v7c0 1.7 3.1 3 7 3s7-1.3 7-3v-7" /></>,
approval: <><path d="M9 11 11 13l4-4" /><path d="M12 22c5-2.1 8-5.3 8-10V5l-8-3-8 3v7c0 4.7 3 7.9 8 10Z" /></>,
settings: <><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2 2-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.5v.2h-2.8v-.2a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.9.3l-.1.1-2-2 .1-.1A1.7 1.7 0 0 0 7.4 15a1.7 1.7 0 0 0-1.5-1H5.7v-2.8h.2a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.9L7 8.2l2-2 .1.1a1.7 1.7 0 0 0 1.9.3 1.7 1.7 0 0 0 1-1.5v-.2h2.8v.2a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.9-.3l.1-.1 2 2-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.5 1h.2V14h-.2a1.7 1.7 0 0 0-1.5 1Z" /></>,
};
return <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className="h-[18px] w-[18px]">{paths[name]}</svg>;
}
export function Sidebar() {
return (
<aside className="w-56 bg-white border-r border-gray-200 flex-shrink-0">
<div className="h-14 flex items-center px-6 font-bold text-blue-600 border-b border-gray-200">CASAN</div>
<nav className="p-3 space-y-1">
{NAV.map(([to, label]) => (
<NavLink key={to} to={to} end={to === '/'}
className={({ isActive }) => `block px-3 py-2 rounded-lg text-sm ${isActive ? 'bg-blue-50 text-blue-600 font-medium' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-800'}`}>
{label}
<>
<aside className="sticky top-0 hidden h-screen w-[252px] shrink-0 flex-col border-r border-slate-800 bg-[#111827] text-slate-300 lg:flex">
<div className="flex h-[76px] items-center border-b border-slate-800 px-6">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-400 to-indigo-600 font-bold text-white shadow-lg shadow-indigo-950/40">C</div>
<div className="ml-3">
<div className="text-sm font-semibold tracking-[0.18em] text-white">CASAN</div>
<div className="text-[10px] font-medium uppercase tracking-[0.13em] text-slate-500">Control plane</div>
</div>
</div>
<nav className="flex-1 overflow-y-auto px-3 py-5">
{NAVIGATION.map((group) => (
<div key={group.label} className="mb-6 last:mb-0">
<div className="px-3 pb-2 text-[10px] font-bold uppercase tracking-[0.16em] text-slate-500">{group.label}</div>
<div className="space-y-1">
{group.items.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) => `group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition ${isActive ? 'bg-indigo-500/15 text-indigo-200 shadow-[inset_0_0_0_1px_rgba(129,140,248,0.16)]' : 'text-slate-400 hover:bg-slate-800/80 hover:text-slate-100'}`}
>
<span className="text-slate-500 transition group-hover:text-slate-300"><Icon name={item.icon} /></span>
{item.label}
</NavLink>
))}
</div>
</div>
))}
</nav>
<div className="m-3 rounded-2xl border border-slate-800 bg-slate-900/70 p-3.5">
<div className="flex items-center gap-2 text-xs font-semibold text-emerald-300"><span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />Governed runtime</div>
<p className="mt-1.5 text-[11px] leading-4 text-slate-500">Every action is policy checked and audit anchored.</p>
</div>
</aside>
<nav className="fixed inset-x-3 bottom-3 z-30 flex items-center justify-around rounded-2xl border border-slate-200 bg-white/95 p-2 shadow-xl shadow-slate-900/10 backdrop-blur lg:hidden">
{NAVIGATION[0].items.slice(0, 3).map((item) => (
<NavLink key={item.to} to={item.to} end={item.to === '/'} className={({ isActive }) => `flex min-w-16 flex-col items-center gap-1 rounded-xl px-2 py-1.5 text-[10px] font-semibold ${isActive ? 'bg-indigo-50 text-indigo-700' : 'text-slate-500'}`}>
<Icon name={item.icon} />{item.label.split(' ')[0]}
</NavLink>
))}
<NavLink to="/settings" className={({ isActive }) => `flex min-w-16 flex-col items-center gap-1 rounded-xl px-2 py-1.5 text-[10px] font-semibold ${isActive ? 'bg-indigo-50 text-indigo-700' : 'text-slate-500'}`}>
<Icon name="settings" />More
</NavLink>
</nav>
</>
);
}
@@ -1,32 +1,32 @@
import type { ReactNode } from 'react';
export function Card({ title, children, right }: { title?: string; children: ReactNode; right?: ReactNode }) {
export function Card({ title, children, right, className = '' }: { title?: string; children: ReactNode; right?: ReactNode; className?: string }) {
return (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<section className={`rounded-2xl border border-slate-200/80 bg-white/95 p-5 shadow-[0_12px_30px_rgba(15,23,42,0.045)] backdrop-blur-sm sm:p-6 ${className}`}>
{title && (
<div className="flex justify-between items-center mb-4">
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">{title}</h2>
<div className="mb-5 flex items-center justify-between gap-4">
<h2 className="text-sm font-semibold uppercase tracking-[0.13em] text-slate-500">{title}</h2>
{right}
</div>
)}
{children}
</div>
</section>
);
}
export function StatTile({ label, value, sub }: { label: string; value: ReactNode; sub?: string }) {
return (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<div className="text-xs text-gray-500">{label}</div>
<div className="text-2xl font-semibold text-gray-800 mt-1">{value}</div>
{sub && <div className="text-xs text-gray-400 mt-1">{sub}</div>}
<div className="group rounded-2xl border border-slate-200/80 bg-white/90 p-4 shadow-[0_10px_24px_rgba(15,23,42,0.035)] transition duration-200 hover:-translate-y-0.5 hover:shadow-[0_16px_30px_rgba(15,23,42,0.07)]">
<div className="text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-500">{label}</div>
<div className="mt-2 text-2xl font-semibold tracking-tight text-slate-900">{value}</div>
{sub && <div className="mt-1.5 text-xs leading-5 text-slate-400">{sub}</div>}
</div>
);
}
const TONE: Record<string, string> = {
ok: 'bg-green-100 text-green-700', pass: 'bg-green-100 text-green-700', success: 'bg-green-100 text-green-700', allow: 'bg-green-100 text-green-700',
warn: 'bg-orange-100 text-orange-700', stale: 'bg-orange-100 text-orange-700',
fail: 'bg-red-100 text-red-700', failed: 'bg-red-100 text-red-700', denied: 'bg-red-100 text-red-700', blocked: 'bg-red-100 text-red-700', deny: 'bg-red-100 text-red-700', block: 'bg-red-100 text-red-700', crit: 'bg-red-100 text-red-700',
ok: 'border-emerald-200 bg-emerald-50 text-emerald-700', pass: 'border-emerald-200 bg-emerald-50 text-emerald-700', success: 'border-emerald-200 bg-emerald-50 text-emerald-700', allow: 'border-emerald-200 bg-emerald-50 text-emerald-700', approved: 'border-emerald-200 bg-emerald-50 text-emerald-700', answered: 'border-emerald-200 bg-emerald-50 text-emerald-700', certified: 'border-emerald-200 bg-emerald-50 text-emerald-700', verified: 'border-emerald-200 bg-emerald-50 text-emerald-700',
warn: 'border-amber-200 bg-amber-50 text-amber-700', stale: 'border-amber-200 bg-amber-50 text-amber-700', draft: 'border-amber-200 bg-amber-50 text-amber-700', pending: 'border-amber-200 bg-amber-50 text-amber-700', attention: 'border-amber-200 bg-amber-50 text-amber-700',
fail: 'border-rose-200 bg-rose-50 text-rose-700', failed: 'border-rose-200 bg-rose-50 text-rose-700', denied: 'border-rose-200 bg-rose-50 text-rose-700', blocked: 'border-rose-200 bg-rose-50 text-rose-700', deny: 'border-rose-200 bg-rose-50 text-rose-700', block: 'border-rose-200 bg-rose-50 text-rose-700', crit: 'border-rose-200 bg-rose-50 text-rose-700', breach: 'border-rose-200 bg-rose-50 text-rose-700', halted: 'border-rose-200 bg-rose-50 text-rose-700',
};
export function StatusBadge({ value }: { value: string }) {
const tone = TONE[String(value).toLowerCase()] ?? 'bg-gray-100 text-gray-600';
return <span className={`px-2 py-1 rounded-full text-xs font-medium ${tone}`}>{value}</span>;
const tone = TONE[String(value).toLowerCase()] ?? 'border-slate-200 bg-slate-50 text-slate-600';
return <span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-semibold leading-none ${tone}`}>{value}</span>;
}
@@ -1,3 +1,29 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #162033;
background: #edf1f6;
font-synthesis: none;
}
html, body, #root { min-height: 100%; }
body {
margin: 0;
background:
radial-gradient(circle at 72% -10%, rgba(90, 130, 255, 0.13), transparent 30rem),
#edf1f6;
}
* { box-sizing: border-box; }
button, input, select, textarea { font: inherit; }
::selection { background: #c8d7ff; color: #14234b; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: #c8d0dd; border: 3px solid transparent; border-radius: 999px; background-clip: padding-box; }
::-webkit-scrollbar-track { background: transparent; }
@@ -91,6 +91,9 @@ export interface ChatSource {
export interface ChatAnswer {
success: boolean;
chat_id?: string;
turn_id?: string;
trace_id?: string;
mode: 'READ_ONLY' | 'OPERATOR' | 'BLOCK' | 'NOT_SUPPORTED' | string;
risk: string;
decision: 'ANSWERED' | 'ACTION_COMPLETED' | 'ACTION_FAILED' | 'REQUIRES_APPROVAL' | 'DENIED' | 'NOT_SUPPORTED' | string;
@@ -148,6 +151,7 @@ export interface ChatAnswer {
input_tokens?: number;
output_tokens?: number;
cost_source?: string;
fallback_from?: string | null;
};
actor: SettingsActor;
}
@@ -190,6 +194,34 @@ export interface ChatReplay {
audit_path?: string;
}
export interface ChatConversation {
chat_id: string;
title: string;
updated_at: string;
turns: number;
last_decision: string;
last_mode: string;
}
export interface ChatHistoryTurn {
chat_id: string;
turn_id: string;
timestamp: string;
mode: string;
risk: string;
decision: string;
prompt_preview: string;
answer_preview: string;
certified: boolean;
audit_hash: string;
}
export interface ChatHistory {
ok: boolean;
conversations: ChatConversation[];
turns: ChatHistoryTurn[];
}
export interface SettingsState {
actor: SettingsActor;
capabilities: {
@@ -277,6 +309,8 @@ export const api = {
flush(buf);
},
verifyChatAudit: () => get<{ ok: boolean; output: string }>('chat/audit/verify'),
chatHistory: (actor: SettingsActor, chatId = '', limit = 50) =>
getWithHeaders<ChatHistory>(`chat/history?chatId=${encodeURIComponent(chatId)}&limit=${limit}`, actorHeaders(actor)),
replayChat: (chatId = '', turnId = '', tenant = '') => get<ChatReplay>(`chat/replay?chatId=${encodeURIComponent(chatId)}&turnId=${encodeURIComponent(turnId)}&tenant=${encodeURIComponent(tenant)}`),
chatActions: (actor: SettingsActor) => getWithHeaders<{ success: boolean; actions: ChatAction[] }>('chat/actions', actorHeaders(actor)),
chatAgents: (actor: SettingsActor) => getWithHeaders<{ success: boolean; agents: ChatAgent[] }>('chat/agents', actorHeaders(actor)),
@@ -1,78 +1,118 @@
import { useState } from 'react';
import { type KeyboardEvent, useMemo, useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { api, ChatAction, ChatAgent, ChatAnswer, ChatStreamPhase, SettingsActor } from '../lib/api';
import { api, ChatAction, ChatAgent, ChatAnswer, ChatHistoryTurn, ChatStreamPhase, SettingsActor } from '../lib/api';
import { Card, StatusBadge } from '../components/ui/Card';
const ROLES = ['viewer', 'auditor', 'operator', 'project-admin', 'org-admin'];
function badgeValue(res: ChatAnswer | undefined, fallback = 'idle') {
if (!res) return fallback;
return `${res.mode} / ${res.risk}`;
type MessageKind = 'user' | 'assistant' | 'draft';
interface WorkspaceMessage {
id: string;
kind: MessageKind;
body: string;
timestamp: string;
mode?: string;
decision?: string;
certified?: boolean;
preview?: boolean;
}
function finalToAnswer(p: ChatStreamPhase, actor: SettingsActor): ChatAnswer {
function finalToAnswer(phase: ChatStreamPhase, actor: SettingsActor): ChatAnswer {
return {
...(p as Partial<ChatAnswer>),
success: p.decision === 'ANSWERED',
mode: p.mode,
risk: (p.risk as string) ?? 'low',
decision: p.decision,
answer: p.answer,
sources: p.sources ?? [],
certified: p.certified,
audit: p.audit ?? {},
audit_verify: p.audit_verify ?? { ok: true, output: '' },
router: p.router ?? {},
...(phase as Partial<ChatAnswer>),
success: phase.decision === 'ANSWERED',
mode: phase.mode,
risk: phase.risk ?? 'low',
decision: phase.decision,
answer: phase.answer,
sources: phase.sources ?? [],
certified: phase.certified,
audit: phase.audit ?? {},
audit_verify: phase.audit_verify ?? { ok: true, output: '' },
router: phase.router ?? {},
actor,
} as ChatAnswer;
}
function auditHash(res: ChatAnswer) {
return res.audit?.hash || res.audit?.record_hash || res.audit?.head || 'n/a';
function errorMessage(error: unknown): string {
if (typeof error === 'object' && error !== null) {
const candidate = error as { message?: string; response?: { data?: { message?: string } } };
return candidate.response?.data?.message || candidate.message || 'The governed chat request could not be completed.';
}
return 'The governed chat request could not be completed.';
}
function sourceExcerpt(source: { preview?: string; excerpt?: string }) {
return source.preview || source.excerpt || '';
function formatTime(value: string) {
if (!value) return 'now';
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : new Intl.DateTimeFormat('en', { hour: '2-digit', minute: '2-digit' }).format(parsed);
}
function auditHash(answer: ChatAnswer | null) {
return answer?.audit?.hash || answer?.audit?.record_hash || answer?.audit?.head || 'n/a';
}
function turnMessages(turn: ChatHistoryTurn): WorkspaceMessage[] {
const messages: WorkspaceMessage[] = [];
if (turn.prompt_preview) {
messages.push({ id: `${turn.turn_id}-user`, kind: 'user', body: turn.prompt_preview, timestamp: turn.timestamp, mode: turn.mode, decision: turn.decision, preview: true });
}
if (turn.answer_preview) {
messages.push({ id: `${turn.turn_id}-assistant`, kind: 'assistant', body: turn.answer_preview, timestamp: turn.timestamp, mode: turn.mode, decision: turn.decision, certified: turn.certified, preview: true });
}
return messages;
}
function Glyph({ name }: { name: 'add' | 'send' | 'spark' | 'lock' | 'chevron' | 'bolt' | 'history' }) {
const paths = {
add: <><path d="M12 5v14M5 12h14" /></>,
send: <><path d="m21 3-7.5 18-3.8-7.7L2 9.5 21 3Z" /><path d="m9.7 13.3 4.6-4.6" /></>,
spark: <><path d="m12 3 1.7 5.3L19 10l-5.3 1.7L12 17l-1.7-5.3L5 10l5.3-1.7L12 3Z" /><path d="m19 15 .8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8L19 15Z" /></>,
lock: <><rect x="5" y="10" width="14" height="11" rx="2" /><path d="M8 10V7a4 4 0 0 1 8 0v3" /></>,
chevron: <path d="m9 18 6-6-6-6" />,
bolt: <path d="m13 2-9 12h7l-1 8 10-13h-7l0-7Z" />,
history: <><path d="M3 12a9 9 0 1 0 3-6.7" /><path d="M3 4v5h5M12 7v5l3 2" /></>,
};
return <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">{paths[name]}</svg>;
}
export function Chat() {
const [actor, setActor] = useState<SettingsActor>({
actor: 'local-operator',
role: 'viewer',
project: 'default',
tenant: 'default',
});
const [actor, setActor] = useState<SettingsActor>({ actor: 'local-operator', role: 'viewer', project: 'default', tenant: 'default' });
const [chatId, setChatId] = useState('chat-default');
const [agentId, setAgentId] = useState('evidence-reader');
const [skillId, setSkillId] = useState('evidence-summary');
const [delegationLevel, setDelegationLevel] = useState(0);
const [message, setMessage] = useState('Summarize Plan 18 MVP-0 status');
const [message, setMessage] = useState('');
const [last, setLast] = useState<ChatAnswer | null>(null);
const [error, setError] = useState<string | null>(null);
const [streaming, setStreaming] = useState(false);
const [pendingMessage, setPendingMessage] = useState<string | null>(null);
const [draftText, setDraftText] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [streaming, setStreaming] = useState(true);
const [streamBusy, setStreamBusy] = useState(false);
const auditQuery = useQuery({
queryKey: ['chat-audit'],
queryFn: api.verifyChatAudit,
retry: false,
});
const actionsQuery = useQuery({
queryKey: ['chat-actions', actor],
queryFn: () => api.chatActions(actor),
retry: false,
});
const agentsQuery = useQuery({
queryKey: ['chat-agents', actor],
queryFn: () => api.chatAgents(actor),
retry: false,
});
const auditQuery = useQuery({ queryKey: ['chat-audit'], queryFn: api.verifyChatAudit, retry: false });
const conversationsQuery = useQuery({ queryKey: ['chat-history', actor], queryFn: () => api.chatHistory(actor), retry: false });
const historyQuery = useQuery({ queryKey: ['chat-history', actor, chatId], queryFn: () => api.chatHistory(actor, chatId, 100), retry: false });
const actionsQuery = useQuery({ queryKey: ['chat-actions', actor], queryFn: () => api.chatActions(actor), retry: false });
const agentsQuery = useQuery({ queryKey: ['chat-agents', actor], queryFn: () => api.chatAgents(actor), retry: false });
const agents = agentsQuery.data?.agents ?? [];
const selectedAgent = agents.find((a) => a.id === agentId) ?? agents.find((a) => a.allowed_for_role) ?? agents[0];
const selectedSkill = selectedAgent?.skills_allowed.includes(skillId)
? skillId
: (selectedAgent?.skills_allowed[0] ?? '');
const selectedAgent = agents.find((agent) => agent.id === agentId) ?? agents.find((agent) => agent.allowed_for_role) ?? agents[0];
const selectedSkill = selectedAgent?.skills_allowed.includes(skillId) ? skillId : (selectedAgent?.skills_allowed[0] ?? '');
const persistedMessages = useMemo(() => (historyQuery.data?.turns ?? []).flatMap(turnMessages), [historyQuery.data]);
const liveAlreadyStored = Boolean(last?.turn_id && historyQuery.data?.turns.some((turn) => turn.turn_id === last.turn_id));
const liveMessages: WorkspaceMessage[] = [];
if (pendingMessage) liveMessages.push({ id: 'pending-user', kind: 'user', body: pendingMessage, timestamp: '', mode: 'READ_ONLY' });
if (draftText) liveMessages.push({ id: 'draft', kind: 'draft', body: draftText, timestamp: '', mode: 'DRAFTING', certified: false });
if (last && !liveAlreadyStored && !pendingMessage) liveMessages.push({ id: last.turn_id ?? 'latest-answer', kind: 'assistant', body: last.answer, timestamp: '', mode: last.mode, decision: last.decision, certified: last.certified });
const messages = [...persistedMessages, ...liveMessages];
const refreshChat = () => {
void auditQuery.refetch();
void conversationsQuery.refetch();
void historyQuery.refetch();
};
const ask = useMutation({
mutationFn: (override?: { message?: string; agentId?: string; skillId?: string }) => api.askChat(actor, {
@@ -82,296 +122,165 @@ export function Chat() {
skillId: override?.skillId ?? selectedSkill,
delegationLevel,
}),
onSuccess: (res) => {
setLast(res);
onSuccess: (answer) => {
setLast(answer);
setPendingMessage(null);
setError(null);
void auditQuery.refetch();
setMessage('');
refreshChat();
},
onError: (err: any) => {
setError(err?.response?.data?.message || err.message || 'Ask CASAN failed');
onError: (reason: unknown) => {
setPendingMessage(null);
setError(errorMessage(reason));
},
});
const runStream = async () => {
const busy = ask.isPending || streamBusy;
const runStream = async (text: string) => {
setStreamBusy(true);
setError(null);
setDraftText(null);
setPendingMessage(text);
try {
await api.askChatStream(
actor,
{ message, chatId, agentId: selectedAgent?.id ?? agentId, skillId: selectedSkill, delegationLevel },
(phase: ChatStreamPhase) => {
if (phase.phase === 'draft') {
setDraftText(phase.answer);
} else {
await api.askChatStream(actor, { message: text, chatId, agentId: selectedAgent?.id ?? agentId, skillId: selectedSkill, delegationLevel }, (phase) => {
if (phase.phase === 'draft') setDraftText(phase.answer);
if (phase.phase === 'final') {
setDraftText(null);
setLast(finalToAnswer(phase, actor));
setPendingMessage(null);
setMessage('');
}
},
);
void auditQuery.refetch();
} catch (err: any) {
setError(err?.message || 'Stream failed');
});
refreshChat();
} catch (reason: unknown) {
setPendingMessage(null);
setDraftText(null);
setError(errorMessage(reason));
} finally {
setStreamBusy(false);
}
};
const onAsk = () => {
if (streaming) void runStream();
else ask.mutate({});
const submit = (override?: { message?: string; agentId?: string; skillId?: string }) => {
const text = (override?.message ?? message).trim();
if (!text || busy) return;
if (streaming && !override) void runStream(text);
else {
setPendingMessage(text);
ask.mutate(override ?? {});
}
};
const onComposerKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
submit();
}
};
const newConversation = () => {
const id = `chat-${Date.now().toString(36)}`;
setChatId(id);
setLast(null);
setMessage('');
setPendingMessage(null);
setDraftText(null);
setError(null);
};
const busy = ask.isPending || streamBusy;
return (
<>
<Card
title="Governed Chat"
right={<StatusBadge value={last ? badgeValue(last) : (auditQuery.data?.ok ? 'audit ok' : 'ready')} />}
>
<div className="grid grid-cols-1 xl:grid-cols-5 gap-4">
<div className="xl:col-span-3 space-y-3">
<label className="block space-y-1 text-sm">
<span className="text-gray-500">Ask CASAN</span>
<textarea
className="min-h-[132px] w-full rounded border border-gray-300 px-3 py-2 text-gray-800 focus:border-blue-400 focus:outline-none"
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
</label>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white disabled:bg-gray-300"
disabled={!message.trim() || busy}
onClick={onAsk}
>
{busy ? 'Asking...' : 'Ask'}
<div className="space-y-5">
<section className="relative overflow-hidden rounded-2xl border border-indigo-200/70 bg-gradient-to-br from-[#172554] via-[#1e2f68] to-[#334aa0] px-5 py-5 text-white shadow-[0_20px_40px_rgba(30,41,89,0.22)] sm:px-6">
<div className="absolute -right-12 -top-16 h-52 w-52 rounded-full bg-indigo-300/20 blur-3xl" />
<div className="relative flex flex-wrap items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2 text-[11px] font-bold uppercase tracking-[0.16em] text-indigo-200"><Glyph name="spark" />Evidence-first assistant</div>
<h2 className="mt-2 text-2xl font-semibold tracking-tight">Ask with context. Act only with proof.</h2>
<p className="mt-1.5 max-w-2xl text-sm leading-6 text-indigo-100/80">Every response is routed, checked and anchored to an auditable evidence trail before it reaches this workspace.</p>
</div>
<div className="flex items-center gap-2 rounded-xl border border-white/15 bg-white/10 px-3 py-2 text-xs font-semibold text-indigo-50 backdrop-blur">
<span className={`h-2 w-2 rounded-full ${auditQuery.data?.ok ? 'bg-emerald-300' : 'bg-amber-300'}`} />
{auditQuery.data?.ok ? 'Audit chain verified' : 'Checking audit chain'}
</div>
</div>
</section>
<div className="grid min-h-[680px] grid-cols-1 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-[0_18px_45px_rgba(15,23,42,0.07)] xl:grid-cols-[260px_minmax(0,1fr)_300px]">
<aside className="border-b border-slate-200 bg-slate-50/80 p-4 xl:border-b-0 xl:border-r">
<button type="button" onClick={newConversation} className="flex w-full items-center justify-center gap-2 rounded-xl bg-slate-900 px-3 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-slate-700 disabled:bg-slate-400" disabled={busy}>
<Glyph name="add" />New conversation
</button>
<div className="mt-5 flex items-center justify-between text-[10px] font-bold uppercase tracking-[0.15em] text-slate-400"><span>Recent threads</span><span>{conversationsQuery.data?.conversations.length ?? 0}</span></div>
<div className="mt-2 space-y-1.5">
{(conversationsQuery.data?.conversations ?? []).map((conversation) => (
<button key={conversation.chat_id} type="button" onClick={() => { setChatId(conversation.chat_id); setLast(null); setError(null); }} className={`w-full rounded-xl p-3 text-left transition ${chatId === conversation.chat_id ? 'bg-white shadow-sm ring-1 ring-indigo-200' : 'hover:bg-white/70'}`}>
<div className="flex items-start justify-between gap-2"><div className="line-clamp-2 text-sm font-medium leading-5 text-slate-800">{conversation.title}</div><StatusBadge value={conversation.last_decision} /></div>
<div className="mt-2 flex items-center justify-between text-[11px] text-slate-400"><span>{conversation.turns} turn{conversation.turns === 1 ? '' : 's'}</span><span>{formatTime(conversation.updated_at)}</span></div>
</button>
<label className="flex items-center gap-1 text-xs text-gray-600">
<input type="checkbox" checked={streaming} onChange={(e) => setStreaming(e.target.checked)} />
Stream
</label>
<StatusBadge value="read-only" />
</div>
{draftText && (
<div className="rounded border border-orange-200 bg-orange-50 p-3 text-sm text-gray-700">
<div className="mb-1 flex items-center gap-2">
<StatusBadge value="draft" />
<span className="text-xs text-orange-700">UNCERTIFIED — awaiting H4/certify</span>
</div>
<div className="whitespace-pre-wrap leading-6">{draftText}</div>
</div>
)}
{error && <div className="rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">{error}</div>}
</div>
<div className="xl:col-span-2 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-1 gap-3 text-sm">
<label className="space-y-1">
<span className="text-gray-500">Actor</span>
<input className="w-full rounded border border-gray-300 px-3 py-2" value={actor.actor}
onChange={(e) => setActor({ ...actor, actor: e.target.value })} />
</label>
<label className="space-y-1">
<span className="text-gray-500">Role</span>
<select className="w-full rounded border border-gray-300 px-3 py-2" value={actor.role}
onChange={(e) => setActor({ ...actor, role: e.target.value })}>
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</label>
<label className="space-y-1">
<span className="text-gray-500">Project</span>
<input className="w-full rounded border border-gray-300 px-3 py-2" value={actor.project}
onChange={(e) => setActor({ ...actor, project: e.target.value })} />
</label>
<label className="space-y-1">
<span className="text-gray-500">Tenant</span>
<input className="w-full rounded border border-gray-300 px-3 py-2" value={actor.tenant}
onChange={(e) => setActor({ ...actor, tenant: e.target.value })} />
</label>
<label className="space-y-1 md:col-span-2 xl:col-span-1">
<span className="text-gray-500">Chat ID</span>
<input className="w-full rounded border border-gray-300 px-3 py-2" value={chatId}
onChange={(e) => setChatId(e.target.value)} />
</label>
<label className="space-y-1 md:col-span-2 xl:col-span-1">
<span className="text-gray-500">Agent</span>
<select className="w-full rounded border border-gray-300 px-3 py-2" value={selectedAgent?.id ?? agentId}
onChange={(e) => {
const next = agents.find((a) => a.id === e.target.value);
setAgentId(e.target.value);
setSkillId(next?.skills_allowed[0] ?? '');
}}>
{agents.map((a: ChatAgent) => (
<option key={a.id} value={a.id}>
{a.label}{a.allowed_for_role ? '' : ' (locked)'}
</option>
))}
</select>
</label>
<label className="space-y-1">
<span className="text-gray-500">Skill</span>
<select className="w-full rounded border border-gray-300 px-3 py-2" value={selectedSkill}
onChange={(e) => setSkillId(e.target.value)}>
{(selectedAgent?.skills_allowed ?? []).map((s) => <option key={s} value={s}>{s}</option>)}
</select>
</label>
<label className="space-y-1">
<span className="text-gray-500">Delegation</span>
<input className="w-full rounded border border-gray-300 px-3 py-2" type="number" min={0} max={5}
value={delegationLevel} onChange={(e) => setDelegationLevel(Number(e.target.value || 0))} />
</label>
{!conversationsQuery.isLoading && (conversationsQuery.data?.conversations.length ?? 0) === 0 && <div className="rounded-xl border border-dashed border-slate-200 p-4 text-xs leading-5 text-slate-500">Start a conversation to create an immutable, audit-safe thread.</div>}
</div>
</div>
</Card>
<div className="mt-6 rounded-xl border border-indigo-100 bg-indigo-50/70 p-3 text-xs leading-5 text-indigo-800"><div className="flex items-center gap-1.5 font-semibold"><Glyph name="history" />Memory boundary</div><p className="mt-1 text-indigo-700/80">Only H4-scanned previews are restored. Raw prompts remain outside the UI history.</p></div>
</aside>
{last && (
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
<Card
title="Answer"
right={<StatusBadge value={last.certified ? 'certified' : 'uncertified'} />}
>
<div className="flex flex-wrap gap-2 mb-4">
<StatusBadge value={last.mode} />
<StatusBadge value={last.risk} />
<StatusBadge value={last.decision} />
{last.synthesis && (
<StatusBadge value={last.synthesis.mode === 'model'
? `model: ${last.synthesis.provider ?? 'provider'}`
: 'deterministic'} />
)}
{last.agent_binding && <StatusBadge value={last.agent_binding.agent_selected} />}
{last.agent_binding && <StatusBadge value={`L${last.agent_binding.delegation_level}`} />}
{last.loop_run && <StatusBadge value={last.loop_run.draft_certified ? 'loop certified' : 'loop held'} />}
<StatusBadge value={last.audit_verify.ok ? 'audit ok' : 'audit fail'} />
<section className="flex min-h-[620px] min-w-0 flex-col">
<div className="flex items-center justify-between border-b border-slate-200 px-5 py-3.5">
<div><div className="text-[10px] font-bold uppercase tracking-[0.15em] text-slate-400">Active thread</div><div className="mt-0.5 font-mono text-xs text-slate-700">{chatId}</div></div>
<div className="flex items-center gap-2"><StatusBadge value={streaming ? 'streaming' : 'certified only'} /><StatusBadge value={selectedAgent?.mode ?? 'READ_ONLY'} /></div>
</div>
<div className="whitespace-pre-wrap text-sm leading-6 text-gray-800">{last.answer}</div>
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
<div className="rounded border border-gray-200 p-3">
<div className="text-gray-400">audit hash</div>
<div className="font-medium text-gray-700 break-all">{auditHash(last)}</div>
<div className="flex-1 space-y-5 overflow-y-auto bg-[linear-gradient(180deg,#fff_0%,#fafcff_100%)] px-5 py-6">
{historyQuery.isLoading && <div className="text-sm text-slate-400">Loading audit-safe conversation history…</div>}
{messages.map((item) => (
<div key={item.id} className={`flex ${item.kind === 'user' ? 'justify-end' : 'justify-start'}`}>
<article className={`max-w-[92%] rounded-2xl px-4 py-3 sm:max-w-[78%] ${item.kind === 'user' ? 'rounded-br-md bg-slate-900 text-white shadow-md shadow-slate-900/10' : item.kind === 'draft' ? 'rounded-bl-md border border-amber-200 bg-amber-50 text-slate-700' : 'rounded-bl-md border border-slate-200 bg-white text-slate-800 shadow-sm'}`}>
<div className={`mb-2 flex items-center gap-2 text-[10px] font-bold uppercase tracking-[0.12em] ${item.kind === 'user' ? 'text-slate-300' : item.kind === 'draft' ? 'text-amber-700' : 'text-slate-400'}`}>
{item.kind === 'user' ? 'You' : item.kind === 'draft' ? 'Uncertified draft' : 'CASAN'}
{item.mode && <span className="font-medium normal-case tracking-normal">· {item.mode}</span>}
{item.preview && <span className="font-medium normal-case tracking-normal">· audit preview</span>}
</div>
<div className="rounded border border-gray-200 p-3">
<div className="text-gray-400">router</div>
<div className="font-medium text-gray-700">{last.router?.reason ?? 'n/a'}</div>
</div>
</div>
</Card>
<Card title="Evidence">
<div className="space-y-3">
{last.sources.map((s) => (
<div key={`${s.path}-${s.line ?? s.hash ?? s.score}`} className="rounded border border-gray-200 p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="font-medium text-gray-800 truncate">{s.title || s.path}</div>
<div className="text-xs text-gray-400 break-all">{s.path}{s.line ? `:${s.line}` : ''}</div>
</div>
<StatusBadge value={`score ${s.score}`} />
</div>
<div className="mt-2 text-xs leading-5 text-gray-600">{sourceExcerpt(s)}</div>
<div className="mt-2 text-xs text-gray-400 break-all">
{s.hash ? `hash ${s.hash}` : s.envelope?.verified ? 'verified source' : 'source'}
<div className="whitespace-pre-wrap text-sm leading-6">{item.body}</div>
<div className={`mt-3 flex items-center gap-2 text-[10px] ${item.kind === 'user' ? 'text-slate-400' : 'text-slate-400'}`}>
<span>{formatTime(item.timestamp)}</span>
{item.decision && <span>· {item.decision}</span>}
{item.certified !== undefined && <span>· {item.certified ? 'certified' : 'held'}</span>}
</div>
</article>
</div>
))}
{last.sources.length === 0 && <div className="text-sm text-gray-500">No evidence source returned.</div>}
{!historyQuery.isLoading && messages.length === 0 && <div className="mx-auto flex max-w-md flex-col items-center py-20 text-center"><div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-indigo-50 text-indigo-600"><Glyph name="spark" /></div><h3 className="mt-4 font-semibold text-slate-800">A governed empty state</h3><p className="mt-2 text-sm leading-6 text-slate-500">Ask for a plan, a security posture, or an evidence-backed comparison. CASAN will cite what it knows and decline what it cannot govern.</p></div>}
</div>
</Card>
<div className="border-t border-slate-200 bg-white p-4">
{error && <div role="alert" className="mb-3 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>}
<div className="rounded-2xl border border-slate-300 bg-white p-2 shadow-[0_8px_20px_rgba(15,23,42,0.05)] transition focus-within:border-indigo-400 focus-within:ring-4 focus-within:ring-indigo-100">
<textarea aria-label="Ask CASAN" value={message} onChange={(event) => setMessage(event.target.value)} onKeyDown={onComposerKeyDown} placeholder="Ask CASAN about your evidence, plans or governed actions…" className="min-h-[82px] w-full resize-none bg-transparent px-2 py-1.5 text-sm leading-6 text-slate-800 outline-none placeholder:text-slate-400" disabled={busy} />
<div className="flex items-center justify-between gap-3 px-1 pt-1">
<label className="flex cursor-pointer items-center gap-2 text-xs text-slate-500"><input type="checkbox" checked={streaming} onChange={(event) => setStreaming(event.target.checked)} className="h-3.5 w-3.5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" />Show safe draft first</label>
<button type="button" onClick={() => submit()} disabled={!message.trim() || busy} className="inline-flex items-center gap-2 rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-indigo-700 disabled:cursor-not-allowed disabled:bg-slate-300"><Glyph name="send" />{busy ? 'Working…' : 'Ask CASAN'}</button>
</div>
</div>
<p className="mt-2 flex items-center gap-1.5 text-[11px] text-slate-400"><Glyph name="lock" />Enter sends · Shift + Enter adds a line · outputs pass governance before certification.</p>
</div>
</section>
<Card title="Router">
<div className="space-y-3 text-sm">
{last.action && (
<div className="rounded border border-gray-200 p-3">
<div className="text-xs font-semibold uppercase text-gray-400">Operator action</div>
<div className="mt-1 font-medium text-gray-800">{last.action.label}</div>
<div className="mt-1 text-xs text-gray-500">{last.action.description}</div>
<div className="mt-2 flex flex-wrap gap-2">
<StatusBadge value={last.action.id} />
<StatusBadge value={last.action_gate?.outcome ?? 'gate'} />
<aside className="border-t border-slate-200 bg-slate-50/70 p-4 xl:border-l xl:border-t-0">
<div className="text-[10px] font-bold uppercase tracking-[0.15em] text-slate-400">Governance context</div>
<div className="mt-3 rounded-xl border border-slate-200 bg-white p-3.5">
<div className="flex items-center justify-between gap-2"><div className="text-sm font-semibold text-slate-800">{selectedAgent?.label ?? 'Evidence reader'}</div><StatusBadge value={selectedAgent?.allowed_for_role ? 'allowed' : 'locked'} /></div>
<div className="mt-1 text-xs text-slate-500">{selectedAgent?.model_role ?? 'read_only'} · {selectedSkill || 'no skill selected'}</div>
<details className="mt-3 border-t border-slate-100 pt-3 text-xs text-slate-600"><summary className="cursor-pointer font-medium text-slate-700">Session scope</summary><div className="mt-3 grid grid-cols-2 gap-2"><label className="col-span-2">Actor<input value={actor.actor} onChange={(event) => setActor({ ...actor, actor: event.target.value })} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs" /></label><label>Role<select value={actor.role} onChange={(event) => setActor({ ...actor, role: event.target.value })} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs">{ROLES.map((role) => <option key={role}>{role}</option>)}</select></label><label>Delegate<input type="number" min={0} max={5} value={delegationLevel} onChange={(event) => setDelegationLevel(Number(event.target.value || 0))} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs" /></label><label>Project<input value={actor.project} onChange={(event) => setActor({ ...actor, project: event.target.value })} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs" /></label><label>Tenant<input value={actor.tenant} onChange={(event) => setActor({ ...actor, tenant: event.target.value })} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs" /></label><label className="col-span-2">Agent<select value={selectedAgent?.id ?? agentId} onChange={(event) => { const next = agents.find((agent) => agent.id === event.target.value); setAgentId(event.target.value); setSkillId(next?.skills_allowed[0] ?? ''); }} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs">{agents.map((agent: ChatAgent) => <option key={agent.id} value={agent.id}>{agent.label}{agent.allowed_for_role ? '' : ' (locked)'}</option>)}</select></label><label className="col-span-2">Skill<select value={selectedSkill} onChange={(event) => setSkillId(event.target.value)} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs">{(selectedAgent?.skills_allowed ?? []).map((skill) => <option key={skill}>{skill}</option>)}</select></label></div></details>
</div>
</div>
)}
{last.agent_binding && (
<div className="rounded border border-gray-200 p-3">
<div className="text-xs font-semibold uppercase text-gray-400">Agent binding</div>
<div className="mt-1 font-medium text-gray-800">{last.agent_binding.agent_selected}</div>
<div className="mt-1 text-xs text-gray-500">{last.agent_binding.skill_selected || 'no skill'}</div>
<div className="mt-2 flex flex-wrap gap-2">
<StatusBadge value={last.agent_binding.decision} />
<StatusBadge value={last.agent_binding.model_role ?? 'model'} />
<StatusBadge value={`tools ${last.agent_binding.tool_allowlist.length}`} />
</div>
</div>
)}
{last.loop_run && (
<div className="rounded border border-gray-200 p-3">
<div className="text-xs font-semibold uppercase text-gray-400">Loop run</div>
<div className="mt-1 font-medium text-gray-800 break-all">{last.loop_run.run_id}</div>
<div className="mt-2 flex flex-wrap gap-2">
<StatusBadge value={last.loop_run.decision} />
<StatusBadge value={last.loop_run.draft_certified ? 'draft certified' : 'draft held'} />
<StatusBadge value={last.loop_run.side_effect_released ? 'released' : 'held'} />
<StatusBadge value={last.loop_run.replay?.ok ? 'replay ok' : 'replay pending'} />
</div>
</div>
)}
{last.codegen && (
<div className="rounded border border-gray-200 p-3">
<div className="text-xs font-semibold uppercase text-gray-400">Codegen draft</div>
<div className="mt-1 font-medium text-gray-800 break-all">{last.codegen.artifact ?? 'n/a'}</div>
<div className="mt-2 flex flex-wrap gap-2">
<StatusBadge value={last.codegen.artifact_scan?.ok ? 'artifact scan ok' : 'artifact scan held'} />
<StatusBadge value={last.codegen.tool_output_scan?.ok ? 'output scan ok' : 'output scan held'} />
</div>
</div>
)}
<div>
<div className="text-xs font-semibold uppercase text-gray-400">Matched rules</div>
<div className="mt-1 flex flex-wrap gap-2">
{(last.router?.matched_rules ?? []).map((r) => <StatusBadge key={r} value={r} />)}
{(last.router?.matched_rules ?? []).length === 0 && <span className="text-gray-500">none</span>}
</div>
</div>
<div>
<div className="text-xs font-semibold uppercase text-gray-400">Gates</div>
<div className="mt-1 text-gray-700">{(last.router?.gates ?? []).join(', ') || 'n/a'}</div>
</div>
<pre className="max-h-72 overflow-auto rounded bg-gray-950 p-3 text-xs text-gray-100">{JSON.stringify(last.router, null, 2)}</pre>
</div>
</Card>
</div>
)}
<Card title="Registered operator actions">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{(actionsQuery.data?.actions ?? []).map((action: ChatAction) => {
const trigger = action.triggers[0] || action.id;
return (
<button
key={action.id}
type="button"
disabled={ask.isPending}
onClick={() => {
setMessage(trigger);
ask.mutate({ message: trigger, agentId: 'ops-operator', skillId: 'registered-actions' });
}}
className="text-left rounded border border-gray-200 p-3 hover:border-blue-300 hover:bg-blue-50 disabled:opacity-50"
>
<div className="font-medium text-gray-800">{action.label}</div>
<div className="mt-1 text-xs leading-5 text-gray-500">{action.description}</div>
<div className="mt-2 flex flex-wrap gap-1">
{action.triggers.slice(0, 2).map((t) => <StatusBadge key={t} value={t} />)}
<div className="mt-5 text-[10px] font-bold uppercase tracking-[0.15em] text-slate-400">Latest verification</div>
<div className="mt-2 space-y-2">
<div className="rounded-xl border border-slate-200 bg-white p-3"><div className="text-xs text-slate-400">Audit anchor</div><div className="mt-1 break-all font-mono text-[11px] text-slate-700">{auditHash(last).slice(0, 22)}{auditHash(last) !== 'n/a' ? '…' : ''}</div></div>
{last && <><div className="rounded-xl border border-slate-200 bg-white p-3"><div className="flex flex-wrap gap-1.5"><StatusBadge value={last.mode} /><StatusBadge value={last.risk} /><StatusBadge value={last.decision} /><StatusBadge value={last.certified ? 'certified' : 'held'} /></div><div className="mt-2 text-xs text-slate-500">{last.router?.reason ?? 'Policy route verified'}{last.synthesis?.fallback_from ? ` · fell back from ${last.synthesis.fallback_from} to local` : ''}</div></div><Card title="Evidence" className="p-3.5"><div className="space-y-2">{last.sources.slice(0, 3).map((source) => <div key={`${source.path}-${source.line ?? ''}`} className="rounded-lg bg-slate-50 p-2.5"><div className="truncate text-xs font-medium text-slate-700">{source.title || source.path}</div><div className="mt-1 text-[11px] leading-4 text-slate-500">{source.preview || source.excerpt || 'Verified source'}</div></div>)}{last.sources.length === 0 && <div className="text-xs text-slate-500">No source was returned for this decision.</div>}</div></Card></>}
</div>
<div className="mt-5 text-[10px] font-bold uppercase tracking-[0.15em] text-slate-400">Registered actions</div>
<div className="mt-2 space-y-2">{(actionsQuery.data?.actions ?? []).slice(0, 3).map((action: ChatAction) => <button key={action.id} type="button" disabled={busy} onClick={() => { const trigger = action.triggers[0] || action.id; setMessage(trigger); setPendingMessage(trigger); ask.mutate({ message: trigger, agentId: 'ops-operator', skillId: 'registered-actions' }); }} className="w-full rounded-xl border border-slate-200 bg-white p-3 text-left transition hover:border-indigo-200 hover:bg-indigo-50/50 disabled:opacity-50"><div className="flex items-center gap-2 text-sm font-semibold text-slate-800"><Glyph name="bolt" />{action.label}</div><p className="mt-1 text-xs leading-5 text-slate-500">{action.description}</p></button>)}{actionsQuery.isError && <div className="text-xs text-rose-600">Registered actions are unavailable.</div>}</div>
</aside>
</div>
</button>
);
})}
{actionsQuery.isError && <div className="text-sm text-red-600">Cannot load registered operator actions.</div>}
{!actionsQuery.isLoading && !actionsQuery.isError && (actionsQuery.data?.actions ?? []).length === 0 && (
<div className="text-sm text-gray-500">No operator actions registered.</div>
)}
</div>
</Card>
</>
);
}
@@ -2,36 +2,71 @@ import { useQuery } from '@tanstack/react-query';
import { api } from '../lib/api';
import { Card, StatTile, StatusBadge } from '../components/ui/Card';
function SignalMark({ tone }: { tone: 'indigo' | 'emerald' | 'amber' }) {
const color = { indigo: 'bg-indigo-500', emerald: 'bg-emerald-500', amber: 'bg-amber-500' }[tone];
return <span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${color}`} />;
}
export function Overview() {
const { data, isLoading, isError } = useQuery({ queryKey: ['overview'], queryFn: api.overview });
if (isLoading) return <div className="text-gray-500">Loading…</div>;
if (isError || !data) return <div className="text-red-600">Cannot reach Ops Console API.</div>;
const t = data.totals;
if (isLoading) return <div className="rounded-2xl border border-slate-200 bg-white p-8 text-sm text-slate-500">Loading operational signals…</div>;
if (isError || !data) return <div role="alert" className="rounded-2xl border border-rose-200 bg-rose-50 p-5 text-sm text-rose-700">Cannot reach the Ops Console API.</div>;
const totals = data.totals;
const signalEntries = Object.entries(data.harness_signals);
const posture = totals.failures > 0 || totals.action_blocks > 0 ? 'attention' : 'verified';
return (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatTile label="Runs" value={t.runs} />
<StatTile label="Failures" value={t.failures} />
<StatTile label="Total cost (est)" value={`$${t.total_cost.toFixed(4)}`} sub={`${t.provider_tokens} provider tokens`} />
<StatTile label="Avg latency" value={`${t.avg_latency_ms} ms`} />
<StatTile label="Fallback routes" value={t.fallback_routes} />
<StatTile label="Tool denies" value={t.tool_denies} />
<StatTile label="Action blocks" value={t.action_blocks} />
<StatTile label="Hallucination signals" value={t.hallucination_signals} />
<div className="space-y-5">
<section className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-[0_15px_35px_rgba(15,23,42,0.055)]">
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_300px]">
<div className="relative overflow-hidden px-5 py-6 sm:px-7">
<div className="absolute right-0 top-0 h-40 w-40 translate-x-1/3 -translate-y-1/3 rounded-full bg-indigo-100 blur-2xl" />
<div className="relative">
<div className="text-[11px] font-bold uppercase tracking-[0.16em] text-indigo-600">CASAN system posture</div>
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-slate-900">Governance is visible. Evidence is actionable.</h2>
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-500">Monitor the health of every controlled run, policy decision and model interaction from one evidence-backed control plane.</p>
<div className="mt-5 flex flex-wrap items-center gap-2"><StatusBadge value={posture} /><span className="text-xs text-slate-500">{data.audit_chain.records} audit records · head anchored {data.audit_chain.head ? 'now' : 'pending'}</span></div>
</div>
<Card title="Harness signals (real counts)">
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
{Object.entries(data.harness_signals).map(([h, sig]) => (
<div key={h} className="border border-gray-200 rounded-lg p-3">
<div className="font-medium text-gray-700">{h}</div>
<div className="text-gray-500 mt-1">{Object.entries(sig).map(([k, v]) => `${k}: ${v}`).join(' · ')}</div>
</div>
<div className="border-t border-slate-200 bg-slate-50/80 p-5 lg:border-l lg:border-t-0">
<div className="text-[10px] font-bold uppercase tracking-[0.14em] text-slate-400">Trust ribbon</div>
<div className="mt-4 space-y-3">
<div className="flex gap-3"><SignalMark tone="emerald" /><div><div className="text-sm font-semibold text-slate-800">Audit chain</div><div className="text-xs text-slate-500">{data.audit_chain.records ? 'Verified telemetry present' : 'Awaiting first record'}</div></div></div>
<div className="flex gap-3"><SignalMark tone={totals.provider_tokens > 0 ? 'indigo' : 'amber'} /><div><div className="text-sm font-semibold text-slate-800">Model telemetry</div><div className="text-xs text-slate-500">{totals.provider_tokens.toLocaleString()} provider tokens observed</div></div></div>
<div className="flex gap-3"><SignalMark tone={totals.failures > 0 ? 'amber' : 'emerald'} /><div><div className="text-sm font-semibold text-slate-800">Execution gate</div><div className="text-xs text-slate-500">{totals.failures ? `${totals.failures} run(s) need review` : 'No failed runs reported'}</div></div></div>
</div>
</div>
</div>
</section>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-8">
<StatTile label="Runs" value={totals.runs} />
<StatTile label="Failures" value={totals.failures} />
<StatTile label="Model cost" value={`$${totals.total_cost.toFixed(4)}`} sub={`${totals.provider_tokens.toLocaleString()} tokens`} />
<StatTile label="Latency" value={`${totals.avg_latency_ms}ms`} sub="average" />
<StatTile label="Fallbacks" value={totals.fallback_routes} />
<StatTile label="Tool denies" value={totals.tool_denies} />
<StatTile label="Action blocks" value={totals.action_blocks} />
<StatTile label="H-signal flags" value={totals.hallucination_signals} />
</div>
<div className="grid grid-cols-1 gap-5 xl:grid-cols-[minmax(0,1fr)_330px]">
<Card title="Harness signal map" right={<span className="text-xs text-slate-400">Live aggregate</span>}>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{signalEntries.map(([harness, signal], index) => (
<div key={harness} className="group rounded-xl border border-slate-200 bg-slate-50/70 p-4 transition hover:border-indigo-200 hover:bg-white hover:shadow-sm">
<div className="flex items-center justify-between gap-3"><span className="font-mono text-xs font-semibold text-indigo-600">{harness}</span><span className="text-[11px] text-slate-400">0{index + 1}</span></div>
<div className="mt-3 text-sm font-medium leading-6 text-slate-700">{Object.entries(signal).map(([key, value]) => `${key.replaceAll('_', ' ')}: ${value}`).join(' · ')}</div>
</div>
))}
{signalEntries.length === 0 && <div className="text-sm text-slate-500">No harness signal aggregate has been recorded yet.</div>}
</div>
</Card>
<Card title="Audit chain" right={<StatusBadge value={data.audit_chain.last_decision ?? 'n/a'} />}>
<div className="text-sm text-gray-600">records: {data.audit_chain.records} · head: <code className="text-xs">{data.audit_chain.head?.slice(0, 16) ?? '—'}…</code></div>
<Card title="Audit anchor" right={<StatusBadge value={data.audit_chain.last_decision ?? 'pending'} />}>
<div className="rounded-xl bg-slate-950 p-4 text-slate-100"><div className="text-[10px] font-bold uppercase tracking-[0.14em] text-slate-400">Current ledger head</div><div className="mt-2 break-all font-mono text-xs leading-5">{data.audit_chain.head ?? 'No audit head yet'}</div></div>
<p className="mt-4 text-sm leading-6 text-slate-500">This value changes only when the governed ledger accepts a new event. Use the Governance view to inspect the decision path.</p>
</Card>
</>
</div>
</div>
);
}
@@ -19,6 +19,13 @@
"class": "cloud",
"requires_key": true,
"key_env": "OPENAI_API_KEY"
},
"omniroute": {
"model": "openai-compatible:auto/best-coding",
"class": "gateway",
"requires_key": true,
"key_env": "CASAN_OPENAI_COMPATIBLE_API_KEY",
"requires_preflight": true
}
},
"role_bindings": {
@@ -284,17 +284,17 @@ def synthesize_answer(message: str, sources, role: str = "read_only", history: s
return deterministic, {"mode": "deterministic", "reason": "provider_unresolved", "provider": provider_id}
pclass = provider.get("class", "local")
if pclass == "cloud" and provider.get("requires_key"):
if provider.get("requires_key"):
key_env = provider.get("key_env", "")
if key_env and not os.environ.get(key_env):
# Honest: do not silently downgrade a cloud request to a fake answer.
return deterministic, {"mode": "deterministic", "reason": "cloud_key_unset", "provider": provider_id}
return deterministic, {"mode": "deterministic", "reason": "provider_key_unset", "provider": provider_id}
router = os.environ.get("CASAN_CHAT_MODEL_ROUTER") or MODEL_ROUTER
env = os.environ.copy()
if pclass == "cloud":
# Data policy 18.M.2: PII/secret must not reach a cloud model without the
# C3 guard. Force the model-router preflight for any cloud-class provider.
if pclass == "cloud" or provider.get("requires_preflight"):
# Data policy 18.M.2: PII/secret must not reach a cloud or gateway model
# without the C3 guard. Force the model-router preflight for either route.
env["CASAN_PREFLIGHT"] = "1"
with tempfile.TemporaryDirectory() as td:
@@ -306,6 +306,22 @@ def synthesize_answer(message: str, sources, role: str = "read_only", history: s
["bash", router, pf, oj, "--role", "generate", "--model", model_spec],
cwd=ROOT, capture_output=True, text=True, env=env,
)
# A locally available model is the safe operational fallback when a
# configured cloud/gateway route is unavailable. The input is already
# H4-scanned and the output still passes H4 below; we never fall back to
# another network provider or bypass the router.
fallback_from = ""
if (r.returncode != 0 or not os.path.isfile(oj)) and provider_id != "local":
fallback = providers.get(os.environ.get("CASAN_CHAT_LOCAL_FALLBACK_PROVIDER", "local"), {})
fallback_model = fallback.get("model")
if fallback_model and fallback.get("class", "local") == "local":
fallback_from = provider_id
r = subprocess.run(
["bash", router, pf, oj, "--role", "generate", "--model", fallback_model],
cwd=ROOT, capture_output=True, text=True, env=os.environ.copy(),
)
if r.returncode == 0 and os.path.isfile(oj):
provider_id, provider, model_spec, pclass = "local", fallback, fallback_model, "local"
if r.returncode != 0 or not os.path.isfile(oj):
return deterministic, {
"mode": "deterministic",
@@ -328,6 +344,7 @@ def synthesize_answer(message: str, sources, role: str = "read_only", history: s
"ollama": "ollama_local_real_tokens",
"openai": "openai_api_real_tokens",
"anthropic": "anthropic_api_real_tokens",
"openai-compatible": "openai_compatible_api_real_tokens",
}.get(_backend_of(model_spec), "model_real_tokens")
return answer, {
"mode": "model",
@@ -338,6 +355,7 @@ def synthesize_answer(message: str, sources, role: str = "read_only", history: s
"input_tokens": int(out.get("input_tokens") or 0),
"output_tokens": int(out.get("output_tokens") or 0),
"cost_source": cost_source,
"fallback_from": fallback_from or None,
}
@@ -585,6 +603,76 @@ def verify_audit() -> int:
return 0
def history(args) -> int:
"""Return a privacy-minimised, integrity-checked view of one actor's chats.
The Control Panel never reads the audit file itself. This harness command
keeps tenant-path resolution, chain verification and field minimisation in
the same trust boundary as chat writes. It exposes H4-scanned prompt
previews and bounded governed-output previews only, never a raw user
message or full audit record.
"""
if args.tenant and args.tenant != "default":
os.environ["CASAN_TENANT_ID"] = args.tenant
tenant_id = args.tenant or "default"
records = []
prev = GENESIS_HASH
try:
with open(audit_path(), encoding="utf-8") as fh:
for line in fh:
if not line.strip():
continue
rec = json.loads(line)
rest = {k: v for k, v in rec.items() if k != "record_hash"}
if rest.get("prev_hash") != prev or sha(json.dumps(rest, sort_keys=True, ensure_ascii=False)) != rec.get("record_hash"):
print(json.dumps({"ok": False, "reason": "chat_chain_broken"}, ensure_ascii=False))
return 3
prev = rec["record_hash"]
if rec.get("tenant_id", "default") == tenant_id and rec.get("actor") == args.actor:
records.append(rec)
except OSError:
records = []
conversations = {}
for rec in records:
chat_id = rec.get("chat_id") or "chat-default"
current = conversations.get(chat_id)
item = {
"chat_id": chat_id,
"title": (rec.get("safe_preview") or rec.get("answer_preview") or "Governed chat")[:80],
"updated_at": rec.get("timestamp") or "",
"turns": 1,
"last_decision": rec.get("decision") or "UNKNOWN",
"last_mode": rec.get("mode") or "READ_ONLY",
}
if current:
item["turns"] = current["turns"] + 1
if current.get("updated_at", "") > item["updated_at"]:
item = current
conversations[chat_id] = item
selected = records if not args.chat_id else [r for r in records if r.get("chat_id") == args.chat_id]
selected = selected[-max(1, min(args.limit, 100)):]
turns = [{
"chat_id": rec.get("chat_id") or "chat-default",
"turn_id": rec.get("turn_id") or "",
"timestamp": rec.get("timestamp") or "",
"mode": rec.get("mode") or "READ_ONLY",
"risk": rec.get("risk") or "low",
"decision": rec.get("decision") or "UNKNOWN",
"prompt_preview": rec.get("safe_preview") or "",
"answer_preview": (rec.get("answer_preview") or rec.get("answer") or "")[:180],
"certified": rec.get("decision") == "ANSWERED",
"audit_hash": rec.get("record_hash") or "",
} for rec in selected]
print(json.dumps({
"ok": True,
"conversations": sorted(conversations.values(), key=lambda item: item.get("updated_at", ""), reverse=True),
"turns": turns,
}, ensure_ascii=False))
return 0
def main() -> int:
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest="cmd", required=True)
@@ -596,11 +684,18 @@ def main() -> int:
askp.add_argument("--tenant", default="default")
askp.add_argument("--stream", action="store_true")
sub.add_parser("verify-audit")
hp = sub.add_parser("history")
hp.add_argument("--actor", required=True)
hp.add_argument("--chat-id", default="")
hp.add_argument("--tenant", default="default")
hp.add_argument("--limit", type=int, default=50)
args = ap.parse_args()
if args.cmd == "ask":
return ask(args)
if args.cmd == "verify-audit":
return verify_audit()
if args.cmd == "history":
return history(args)
return 2
@@ -398,13 +398,13 @@ def _model_codegen_body(args):
if not model_spec:
return None, {"mode": "deterministic", "reason": "provider_unresolved"}
pclass = provider.get("class", "local")
if pclass == "cloud" and provider.get("requires_key"):
if provider.get("requires_key"):
key_env = provider.get("key_env", "")
if key_env and not os.environ.get(key_env):
return None, {"mode": "deterministic", "reason": "cloud_key_unset"}
return None, {"mode": "deterministic", "reason": "provider_key_unset"}
router = os.environ.get("CASAN_CHAT_MODEL_ROUTER") or MODEL_ROUTER
env = os.environ.copy()
if pclass == "cloud":
if pclass == "cloud" or provider.get("requires_preflight"):
env["CASAN_PREFLIGHT"] = "1"
prompt = "\n".join([
"You are CASAN's governed codegen assistant. Produce a SMALL Python draft",
@@ -417,6 +417,16 @@ def _model_codegen_body(args):
write_text(pf, prompt)
r = subprocess.run(["bash", router, pf, oj, "--role", "generate", "--model", model_spec],
cwd=ROOT, capture_output=True, text=True, env=env)
fallback_from = ""
if (r.returncode != 0 or not os.path.isfile(oj)) and provider_id != "local":
fallback = providers.get(os.environ.get("CASAN_CHAT_LOCAL_FALLBACK_PROVIDER", "local"), {})
fallback_model = fallback.get("model")
if fallback_model and fallback.get("class", "local") == "local":
fallback_from = provider_id
r = subprocess.run(["bash", router, pf, oj, "--role", "generate", "--model", fallback_model],
cwd=ROOT, capture_output=True, text=True, env=os.environ.copy())
if r.returncode == 0 and os.path.isfile(oj):
provider_id, model_spec = "local", fallback_model
if r.returncode != 0 or not os.path.isfile(oj):
return None, {"mode": "deterministic", "reason": "model_unavailable"}
try:
@@ -432,6 +442,7 @@ def _model_codegen_body(args):
"model": model_spec,
"input_tokens": int(out.get("input_tokens") or 0),
"output_tokens": int(out.get("output_tokens") or 0),
"fallback_from": fallback_from or None,
}
@@ -709,6 +720,13 @@ def verify_audit() -> int:
return run_and_passthrough(["python3", READONLY, "verify-audit"])
def history(args) -> int:
command = ["python3", READONLY, "history", "--actor", args.actor, "--tenant", args.tenant, "--limit", str(args.limit)]
if args.chat_id:
command += ["--chat-id", args.chat_id]
return run_and_passthrough(command)
def main() -> int:
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest="cmd", required=True)
@@ -726,6 +744,12 @@ def main() -> int:
askp.add_argument("--stream", action="store_true")
askp.set_defaults(func=ask)
sub.add_parser("verify-audit").set_defaults(func=lambda _args: verify_audit())
hp = sub.add_parser("history")
hp.add_argument("--actor", required=True)
hp.add_argument("--chat-id", default="")
hp.add_argument("--tenant", default="default")
hp.add_argument("--limit", type=int, default=50)
hp.set_defaults(func=history)
args = ap.parse_args()
return args.func(args)
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
set -euo pipefail
# One entry point for the complete local CASAN lab. It keeps the infrastructure
# lab and the authenticated Control Panel as separate Compose projects so their
# lifecycle can be managed without port/network collisions.
#
# Usage: local-full.sh start|stop|status|verify|smoke|env
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/casan-paths.sh"
ROOT="$CASAN_APP_ROOT"
INFRA="$SCRIPT_DIR/infra-lab.sh"
CP_COMPOSE="$ROOT/docker-compose.control-panel.local.yml"
LOCAL_ENV="$ROOT/infra/local-prod/casan.local.env"
TLS_DIR="$ROOT/tmp/control-panel-local/tls"
CMD="${1:-status}"
cp_compose() {
if [[ -f "$LOCAL_ENV" ]]; then
docker compose --env-file "$LOCAL_ENV" -f "$CP_COMPOSE" "$@"
else
docker compose -f "$CP_COMPOSE" "$@"
fi
}
need_docker() {
command -v docker >/dev/null 2>&1 || { echo "CASAN_LOCAL_DOCKER_MISSING" >&2; exit 1; }
docker compose version >/dev/null 2>&1 || { echo "CASAN_LOCAL_COMPOSE_MISSING" >&2; exit 1; }
}
ensure_tls() {
mkdir -p "$TLS_DIR"
if [[ ! -f "$TLS_DIR/tls.crt" || ! -f "$TLS_DIR/tls.key" ]]; then
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout "$TLS_DIR/tls.key" -out "$TLS_DIR/tls.crt" \
-subj "/CN=localhost" -days 30 >/dev/null 2>&1
fi
}
wait_url() {
local url="$1"
for _ in $(seq 1 60); do
curl -k -fsS -m 3 "$url" >/dev/null 2>&1 && return 0
sleep 1
done
echo "CASAN_LOCAL_WAIT_TIMEOUT url=$url" >&2
return 1
}
case "$CMD" in
start)
need_docker
bash "$INFRA" start
ensure_tls
cp_compose up -d --build
wait_url "http://127.0.0.1:18082/healthz"
# The unauthenticated console intentionally redirects, so test the OIDC IdP
# here; `smoke` performs the complete logged-in browser flow.
echo "CASAN_LOCAL_FULL_STARTED"
echo "dashboard=http://127.0.0.1:18080 (basic auth: casan / casan)"
echo "minio_console=http://127.0.0.1:19091 (casanadmin / casanadmin123)"
echo "control_panel=https://localhost:18443 (self-signed TLS; mock OIDC login)"
;;
stop)
need_docker
cp_compose down --remove-orphans
bash "$INFRA" stop
echo "CASAN_LOCAL_FULL_STOPPED"
;;
status)
need_docker
echo "=== infrastructure ==="
bash "$INFRA" status
echo "=== control panel ==="
cp_compose ps
;;
verify)
need_docker
bash "$INFRA" verify
cp_compose ps
wait_url "http://127.0.0.1:18082/healthz"
echo "CASAN_LOCAL_FULL_VERIFY_PASS"
;;
smoke)
need_docker
bash "$ROOT/packages/casan-control-panel/scripts/local-prod-smoke.sh"
;;
env)
bash "$INFRA" env
printf '%s\n' \
'# OmniRoute is opt-in. Replace <model-id> with an ID returned by /v1/models.' \
'export CASAN_OPENAI_COMPATIBLE_BASE_URL=http://127.0.0.1:20128/v1' \
'export CASAN_OPENAI_COMPATIBLE_API_KEY="$OPENAI_API_KEY"' \
'export CASAN_MODEL_PRIMARY=openai-compatible:<model-id>' \
'export CASAN_CHAT_MODEL_MODE=model' \
'export CASAN_CHAT_MODEL_PROVIDER=omniroute' \
'export CASAN_PREFLIGHT=1'
;;
*)
echo "Usage: $0 start|stop|status|verify|smoke|env" >&2
exit 64
;;
esac
@@ -23,6 +23,8 @@ Usage:
import argparse
import json
import os
import ipaddress
from urllib.parse import urlparse
def _casan_app_root():
@@ -43,6 +45,7 @@ import urllib.request
from datetime import datetime, timezone
OLLAMA_HOST = "127.0.0.1:11434" # the only allowed ollama endpoint
DOCKER_OLLAMA_HOST = "host.docker.internal:11434"
ALLOWED_CLOUD = {"api.anthropic.com", "api.openai.com"}
REPO_ROOT = _casan_app_root()
PROVIDER_LOG = os.path.join(REPO_ROOT, ".specify/logs/level5/provider-usage.jsonl")
@@ -131,11 +134,20 @@ def extract_verdict(role, text):
return None, False
def ollama_host_allowed(host: str) -> bool:
return host == OLLAMA_HOST or (
os.environ.get("CASAN_ALLOW_DOCKER_HOST_OLLAMA") == "1"
and host == DOCKER_OLLAMA_HOST
)
def call_ollama(model_name, prompt, role):
# SSRF guard: hard-pinned loopback endpoint, no env override of host.
# SSRF guard: hard-pinned loopback endpoint by default. Docker Desktop has
# one explicit, opt-in bridge to the Mac host's Ollama daemon; arbitrary
# LAN, metadata and user-supplied hosts remain blocked.
host = os.environ.get("CASAN_OLLAMA_HOST", OLLAMA_HOST)
if host != OLLAMA_HOST:
fail(f"endpoint_not_allowed ollama host={host} (only {OLLAMA_HOST})")
if not ollama_host_allowed(host):
fail(f"endpoint_not_allowed ollama host={host} (only {OLLAMA_HOST}; Docker bridge requires explicit opt-in)")
digest_gate = os.path.join(os.path.dirname(__file__), "model-digest-check.sh")
if os.path.isfile(digest_gate):
check = subprocess.run(
@@ -211,6 +223,73 @@ def call_openai(model_name, prompt, role):
}
def openai_compatible_url():
"""Return a vetted OpenAI-compatible chat-completions endpoint.
This is opt-in, preserving the existing hard-pinned public OpenAI route.
The default allowlist permits only local development hosts; another host
must be explicitly approved by the operator.
"""
raw = os.environ.get("CASAN_OPENAI_COMPATIBLE_BASE_URL", "").strip()
if not raw:
fail("openai_compatible_base_url_unset")
parsed = urlparse(raw)
if parsed.scheme not in {"http", "https"} or not parsed.hostname \
or parsed.username or parsed.password or parsed.query or parsed.fragment:
fail("endpoint_not_allowed openai-compatible invalid_base_url")
configured = os.environ.get("CASAN_OPENAI_COMPATIBLE_ALLOWED_HOSTS", "")
allowed_hosts = {host.strip().lower() for host in configured.split(",") if host.strip()} \
or {"127.0.0.1", "localhost", "host.docker.internal"}
host = parsed.hostname.lower()
if host not in allowed_hosts:
fail(f"endpoint_not_allowed openai-compatible host={host}")
# HTTP is acceptable only for a loopback gateway or an explicitly named
# private-LAN IP. Public HTTP and arbitrary SSRF targets remain blocked.
if parsed.scheme == "http" and host not in {"127.0.0.1", "localhost", "host.docker.internal"}:
try:
is_private = ipaddress.ip_address(host).is_private
except ValueError:
is_private = False
if not is_private:
fail(f"endpoint_not_allowed openai-compatible insecure_host={host}")
base_path = parsed.path.rstrip("/")
if base_path not in {"", "/v1"}:
fail("endpoint_not_allowed openai-compatible base_path_must_be_v1")
return f"{parsed.scheme}://{parsed.netloc}{base_path}/chat/completions"
def call_openai_compatible(model_name, prompt, role):
"""Call an explicitly allowlisted OpenAI-compatible gateway (e.g. OmniRoute)."""
key = os.environ.get("CASAN_OPENAI_COMPATIBLE_API_KEY") or os.environ.get("OPENAI_API_KEY")
if not key:
fail("openai-compatible_backend_unavailable (API key unset)")
body = {
"model": model_name,
"messages": [{"role": "user", "content": prompt}],
"temperature": 0 if role in ("classify", "judge") else 0.2,
"max_tokens": 16 if role in ("classify", "judge") else 512,
}
data = json.dumps(body).encode()
req = urllib.request.Request(
openai_compatible_url(), data=data,
headers={"Content-Type": "application/json", "Authorization": f"Bearer {key}"},
)
t0 = time.time()
try:
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) as resp:
payload = json.loads(resp.read().decode())
except Exception as exc:
fail(f"backend_unreachable {type(exc).__name__}: {str(exc)[:120]}")
latency_ms = int((time.time() - t0) * 1000)
text, input_tokens, output_tokens = parse_openai_payload(payload)
return {
"text": text,
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"latency_ms": latency_ms,
}
def parse_openai_payload(payload):
try:
text = (payload["choices"][0]["message"]["content"] or "").strip()
@@ -297,11 +376,18 @@ def main():
model_spec = args.model
if model_spec.startswith("ollama:"):
backend, model_name = "ollama", model_spec[len("ollama:"):]
elif model_spec.startswith(("anthropic:", "openai:")):
elif model_spec.startswith(("anthropic:", "openai:", "openai-compatible:")):
backend, model_name = model_spec.split(":", 1)
key = os.environ.get("ANTHROPIC_API_KEY" if backend == "anthropic" else "OPENAI_API_KEY", "")
if backend == "anthropic":
key = os.environ.get("ANTHROPIC_API_KEY", "")
elif backend == "openai-compatible":
key = os.environ.get("CASAN_OPENAI_COMPATIBLE_API_KEY") or os.environ.get("OPENAI_API_KEY", "")
else:
key = os.environ.get("OPENAI_API_KEY", "")
if not key:
# honest: cloud backend unavailable while key unset (do NOT fake)
if backend == "openai-compatible":
fail("openai-compatible_backend_unavailable (API key unset)")
# Preserve the existing public-cloud unavailable contract.
fail(f"cloud_backend_unavailable {backend} (API key unset)")
else:
fail(f"unknown_model_spec {model_spec}")
@@ -311,6 +397,8 @@ def main():
result = call_ollama(model_name, prompt, args.role)
elif backend == "openai":
result = call_openai(model_name, prompt, args.role)
elif backend == "openai-compatible":
result = call_openai_compatible(model_name, prompt, args.role)
else: # anthropic
result = call_anthropic(model_name, prompt, args.role)
@@ -343,6 +431,7 @@ def main():
"ollama": "ollama_local_real_tokens",
"openai": "openai_api_real_tokens",
"anthropic": "anthropic_api_real_tokens",
"openai-compatible": "openai_compatible_api_real_tokens",
}.get(backend, f"{backend}_real_tokens")
os.makedirs(os.path.dirname(PROVIDER_LOG), exist_ok=True)
usage = {
@@ -106,7 +106,38 @@ assert d["decision"] == "DENIED"
assert d["mode"] == "BLOCK"
PY
# 5) Model OUTPUT still flows through the H4 output scan (no governance bypass):
# 5) Gateway/cloud model failure falls back to the configured local model before
# CASAN uses deterministic synthesis. This keeps an OmniRoute outage from taking
# down an otherwise healthy local Ollama deployment.
FALLBACK_CFG="$WORK/fallback-providers.json"
cat > "$FALLBACK_CFG" <<'EOF'
{"providers":{"gateway":{"model":"fake:gateway","class":"gateway","requires_preflight":true},"local":{"model":"fake:local","class":"local"}},"role_bindings":{"read_only":"gateway"}}
EOF
FALLBACK_STUB="$WORK/fallback-router.sh"
cat > "$FALLBACK_STUB" <<'EOF'
#!/usr/bin/env bash
OUT="$2"
MODEL="${6:-}"
if [[ "$MODEL" == "fake:gateway" ]]; then
echo "gateway unavailable" >&2
exit 2
fi
printf '{"text":"Local fallback answer.","input_tokens":31,"output_tokens":9}\n' > "$OUT"
EOF
chmod +x "$FALLBACK_STUB"
CASAN_CHAT_MODEL_MODE=model CASAN_MODEL_PROVIDERS_FILE="$FALLBACK_CFG" CASAN_CHAT_MODEL_ROUTER="$FALLBACK_STUB" \
python3 "$CHAT" ask --message "Summarize Plan 18 MVP-0 evidence" --actor alice --chat-id m5 > "$WORK/local-fallback.json"
python3 - "$WORK/local-fallback.json" <<'PY' \
&& pass "gateway failure falls back to local model" || fail "gateway-to-local fallback failed"
import json, sys
d = json.load(open(sys.argv[1]))
assert d["success"] is True and d["synthesis"]["mode"] == "model", d
assert d["synthesis"]["provider"] == "local", d["synthesis"]
assert d["synthesis"]["fallback_from"] == "gateway", d["synthesis"]
assert "Local fallback answer" in d["answer"], d["answer"]
PY
# 6) Model OUTPUT still flows through the H4 output scan (no governance bypass):
# a planted AWS key in the model text must be caught — the turn is DENIED
# fail-closed and the raw secret never reaches the user.
CASAN_CHAT_MODEL_MODE=model CASAN_CHAT_MODEL_ROUTER="$STUB" \
@@ -60,6 +60,27 @@ CASAN_OLLAMA_HOST="169.254.169.254:80" bash "$ROUTER" "$WORK/s.txt" "$WORK/s.jso
RC=$?; set -e 2>/dev/null || true
[[ "$RC" -ne 0 ]] && grep -q "endpoint_not_allowed" "$WORK/s.err" && pass "SSRF endpoint (metadata IP) rejected" || fail "SSRF endpoint not rejected (rc=$RC)"
# Docker Desktop may reach the operator's Mac-hosted Ollama only through one
# explicit opt-in hostname. Other environment overrides must remain blocked.
python - "$SCRIPTS/model-call.py" <<'PY'
import importlib.util
import os
import sys
spec = importlib.util.spec_from_file_location("mc", sys.argv[1])
mc = importlib.util.module_from_spec(spec); spec.loader.exec_module(mc)
assert mc.OLLAMA_HOST == "127.0.0.1:11434"
assert mc.DOCKER_OLLAMA_HOST == "host.docker.internal:11434"
assert mc.ollama_host_allowed(mc.OLLAMA_HOST) is True
assert mc.ollama_host_allowed(mc.DOCKER_OLLAMA_HOST) is False
os.environ["CASAN_OLLAMA_HOST"] = "169.254.169.254:80"
os.environ["CASAN_ALLOW_DOCKER_HOST_OLLAMA"] = "1"
assert mc.ollama_host_allowed(os.environ["CASAN_OLLAMA_HOST"]) is False
assert mc.ollama_host_allowed(mc.DOCKER_OLLAMA_HOST) is True
print("ok")
PY
[[ $? -eq 0 ]] && pass "Docker Ollama bridge is a single explicit host" || fail "Docker Ollama bridge guard coverage failed"
# 5: no API-key / secret pattern leaked into logs.
if grep -rEq 'sk-[A-Za-z0-9]{20}|Authorization: Bearer|AKIA[0-9A-Z]{16}' "$CASAN_STATE_ROOT/logs" 2>/dev/null; then
fail "a secret/key pattern appears in .specify/logs"
@@ -98,7 +119,8 @@ else
skip "cloud-unavailable test (ANTHROPIC_API_KEY is set)"
fi
# 8: cloud response parsers use provider token usage and fail closed on malformed
# 8: cloud and explicitly allowlisted OpenAI-compatible gateway response parsers
# use provider token usage and fail closed on malformed
# payloads. This is deterministic: urllib is monkeypatched, so no API key or
# network call is needed.
python - "$SCRIPTS/model-call.py" <<'PY'
@@ -113,6 +135,8 @@ spec = importlib.util.spec_from_file_location("mc", sys.argv[1])
mc = importlib.util.module_from_spec(spec); spec.loader.exec_module(mc)
os.environ["OPENAI_API_KEY"] = "test-openai-key"
os.environ["ANTHROPIC_API_KEY"] = "test-anthropic-key"
os.environ["CASAN_OPENAI_COMPATIBLE_API_KEY"] = "test-gateway-key"
os.environ["CASAN_OPENAI_COMPATIBLE_BASE_URL"] = "http://127.0.0.1:20128/v1"
seen = []
@@ -128,7 +152,7 @@ class FakeResp:
def fake_urlopen(req, timeout):
seen.append((req.full_url, dict(req.header_items()), json.loads(req.data.decode())))
if "openai.com" in req.full_url:
if "openai.com" in req.full_url or "127.0.0.1:20128" in req.full_url:
return FakeResp({
"choices": [{"message": {"content": "SAFE"}}],
"usage": {"prompt_tokens": 11, "completion_tokens": 3},
@@ -142,13 +166,25 @@ def fake_urlopen(req, timeout):
mc.urllib.request.urlopen = fake_urlopen
op = mc.call_openai("gpt-test", "hello", "classify")
gw = mc.call_openai_compatible("local-test", "hello", "classify")
an = mc.call_anthropic("claude-test", "hello", "judge")
assert op["text"] == "SAFE" and op["input_tokens"] == 11 and op["output_tokens"] == 3, op
assert gw["text"] == "SAFE" and gw["input_tokens"] == 11 and gw["output_tokens"] == 3, gw
assert an["text"] == "APPROVED" and an["input_tokens"] == 17 and an["output_tokens"] == 5, an
assert seen[0][0] == "https://api.openai.com/v1/chat/completions", seen[0]
assert seen[1][0] == "https://api.anthropic.com/v1/messages", seen[1]
assert seen[1][0] == "http://127.0.0.1:20128/v1/chat/completions", seen[1]
assert seen[2][0] == "https://api.anthropic.com/v1/messages", seen[2]
assert seen[0][2]["temperature"] == 0 and seen[0][2]["max_tokens"] == 16, seen[0][2]
assert "temperature" not in seen[1][2] and seen[1][2]["max_tokens"] == 16, seen[1][2]
assert seen[1][2]["temperature"] == 0 and seen[1][2]["max_tokens"] == 16, seen[1][2]
assert "temperature" not in seen[2][2] and seen[2][2]["max_tokens"] == 16, seen[2][2]
os.environ["CASAN_OPENAI_COMPATIBLE_BASE_URL"] = "http://169.254.169.254/v1"
try:
mc.openai_compatible_url()
except SystemExit as exc:
assert exc.code == 2, exc.code
else:
raise AssertionError("gateway metadata endpoint was accepted")
for fn, bad in (
(mc.parse_openai_payload, {"choices": [{"message": {"content": "SAFE"}}]}),
@@ -163,7 +199,7 @@ for fn, bad in (
raise AssertionError(f"{fn.__name__} accepted malformed provider payload")
print("ok")
PY
[[ $? -eq 0 ]] && pass "cloud provider responses parse real usage and reject malformed payloads" || fail "cloud provider parser coverage failed"
[[ $? -eq 0 ]] && pass "cloud/gateway provider responses parse real usage and reject malformed payloads" || fail "cloud/gateway provider parser coverage failed"
# 9: deliberate failing primary route -> fallback through the REAL router (not exit 9).
if [[ "$TUNNEL_UP" -eq 1 ]]; then