diff --git a/.gitignore b/.gitignore index 85200f5..290ab2e 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ pnpm-debug.log* .specify/logs/trace-events/ .specify/logs/audit/goal-orchestrator.jsonl .specify/logs/audit/goal-orchestrator-head.txt +.specify/logs/audit/goal-orchestrator.lock .specify/logs/chat/ .specify/logs/idempotency/ .specify/logs/level5/rollback-backups/ diff --git a/docker-compose.control-panel.local.yml b/docker-compose.control-panel.local.yml index 49098f5..50fb07c 100644 --- a/docker-compose.control-panel.local.yml +++ b/docker-compose.control-panel.local.yml @@ -31,6 +31,7 @@ services: CASAN_PREFLIGHT: ${CASAN_PREFLIGHT:-0} CASAN_AUTH_BRIDGE_URL: http://host.docker.internal:20130 CASAN_AUTH_BRIDGE_TOKEN: ${CASAN_AUTH_BRIDGE_TOKEN:-} + CASAN_PROVIDER_ACCOUNT_AUTH_ENABLED: "1" volumes: - ./.specify:/app/.specify - ./docs/output:/app/docs/output:ro @@ -73,6 +74,10 @@ services: - --whitelist-domain=localhost:18443 - --cookie-secret=0123456789abcdef0123456789abcdef - --cookie-secure=true + - --cookie-httponly=true + - --cookie-samesite=lax + - --cookie-expire=8h + - --cookie-refresh=1h - --email-domain=* - --scope=openid email profile groups - --oidc-groups-claim=groups diff --git a/docker-compose.control-panel.yml b/docker-compose.control-panel.yml index c3c855d..ddb8a80 100644 --- a/docker-compose.control-panel.yml +++ b/docker-compose.control-panel.yml @@ -23,6 +23,9 @@ services: CP_BIND: 0.0.0.0 CP_PORT: "3010" CASAN_APP_ROOT: /app + # Browser-login CLIs are workstation-only. Production uses managed API + # credentials/workload identity and must not call a developer host bridge. + CASAN_PROVIDER_ACCOUNT_AUTH_ENABLED: "0" volumes: - ${CASAN_CP_STATE_DIR:?Set CASAN_CP_STATE_DIR}:/app/.specify - ${CASAN_CP_OUTPUT_DIR:?Set CASAN_CP_OUTPUT_DIR}:/app/docs/output:ro @@ -45,7 +48,7 @@ services: - casan-control oauth2-proxy: - image: quay.io/oauth2-proxy/oauth2-proxy:v7.6.0 + image: ${CASAN_CP_OAUTH2_PROXY_IMAGE:?Set a digest-pinned CASAN_CP_OAUTH2_PROXY_IMAGE} restart: unless-stopped env_file: - path: ${CASAN_CP_OAUTH_ENV:?Set CASAN_CP_OAUTH_ENV} @@ -57,6 +60,11 @@ services: - --pass-access-token=false - --pass-authorization-header=false - --skip-provider-button=true + - --cookie-secure=true + - --cookie-httponly=true + - --cookie-samesite=lax + - --cookie-expire=8h + - --cookie-refresh=1h expose: - "4180" networks: diff --git a/docs/guides/CASAN_LOCAL_PROJECT_WORKFLOW.md b/docs/guides/CASAN_LOCAL_PROJECT_WORKFLOW.md new file mode 100644 index 0000000..ee3da53 --- /dev/null +++ b/docs/guides/CASAN_LOCAL_PROJECT_WORKFLOW.md @@ -0,0 +1,253 @@ +# Dùng CASAN local để làm dự án — hướng dẫn từng bước + +Tài liệu này dành cho môi trường local hiện tại: macOS chạy Control Panel và model local, Docker chạy hạ tầng, máy Linux `thanhnv@192.168.1.5` là tài nguyên mở rộng khi cần. Production là một giai đoạn riêng. + +## 1. Hiểu CASAN trong một phút + +CASAN không phải là một model AI mới. CASAN là lớp điều phối và kiểm soát nằm giữa người dùng, model và công cụ. + +```mermaid +flowchart LR + U["Mục tiêu / câu hỏi"] --> H1["H1 Context"] + H1 --> H2["H2 Local worker / tools"] + H2 --> H3["H3 Cloud review / eval"] + H3 --> H4["H4 Security"] + H4 --> H5["H5 Governance + audit"] + H5 --> H6["H6 Runtime + cost"] + H6 --> H7["H7 Governed outcome"] + H2 -. private first pass .-> L["Ollama local"] + H3 -. independent review .-> C["Codex / Claude / cloud API"] +``` + +Control Panel hiện có bốn bề mặt chính: + +| Màn hình | Dùng khi nào | Có ghi/sửa source code không? | +|---|---|---| +| **Ask CASAN** | Hỏi tài liệu, kiến trúc, bằng chứng, trạng thái hệ thống | Không; read-only | +| **Goal orchestrator** | Đưa một mục tiêu để local worker và cloud reviewer cùng lập lời giải | Không; tạo kết quả và audit, chưa tự sửa workspace | +| **Run observability** | Xem một input đã đi qua H1–H7 và dừng/cảnh báo ở đâu | Không | +| **Settings / Approvals** | Quản trị cấu hình và quyết định có kiểm soát | Chỉ các action đã đăng ký và đủ quyền | + +Điểm quan trọng: bạn có thể dùng ba luồng trên hoàn toàn trong trình duyệt, không cần mở Codex, VS Code hay Claude Code. Tuy nhiên, phiên bản hiện tại chưa có “workspace executor” được cấp quyền tự tạo/sửa source code từ Goal orchestrator. Kết quả của Goal orchestrator là kế hoạch/lời giải đã được hai model kiểm tra. Bước ghi code vẫn phải đi qua một action có approval hoặc một coding agent bên ngoài. + +## 2. Chuẩn bị một lần + +Kiểm tra các thành phần sau: + +```bash +docker version +docker compose version +codex --version +claude --version +curl -fsS http://localhost:20128/v1/models +``` + +Model local mặc định hiện dùng Ollama ở `host.docker.internal:11434`. OmniRoute có Dashboard tại `http://localhost:20128` và API base `http://localhost:20128/v1`. + +Không ghi API key vào Git, file Markdown hay biến build frontend. API key chỉ nhập qua màn hình Model connections; backend mã hóa theo tenant trước khi lưu. + +## 3. Khởi động toàn bộ local stack + +Từ thư mục gốc repository: + +```bash +packages/casan-harness/scripts/bash/local-full.sh start +packages/casan-harness/scripts/bash/local-full.sh status +packages/casan-harness/scripts/bash/local-full.sh verify +``` + +Các địa chỉ chính: + +- Control Panel: `https://localhost:18443` +- Dashboard hạ tầng: `http://127.0.0.1:18080` +- MinIO Console: `http://127.0.0.1:19091` +- OmniRoute: `http://localhost:20128` + +Local Control Panel dùng chứng chỉ self-signed. Lần đầu, mở URL bằng trình duyệt và chấp nhận chứng chỉ local thủ công. Không dùng cách bỏ qua cảnh báo chứng chỉ cho production. + +Khi cần dừng: + +```bash +packages/casan-harness/scripts/bash/local-full.sh stop +``` + +Lệnh `stop` xóa bridge token. Lần `start` tiếp theo sinh token mới. + +## 4. Kết nối model + +### 4.1 Tài khoản Codex hoặc Claude + +1. Mở **Ask CASAN**. +2. Chọn **Manage model connections**. +3. Trong **Local account connector**, xem trạng thái Codex và Claude. +4. Nếu chưa đăng nhập, chọn **Login**. Browser login chính thức của CLI sẽ mở và redirect theo cơ chế của Codex/Claude. +5. Control Panel chỉ nhận trạng thái đã được làm sạch. Credential OAuth vẫn thuộc CLI trên Mac, không nằm trong browser hoặc container. + +Goal orchestrator ưu tiên một tài khoản cloud đã đăng nhập. Nếu cả hai sẵn sàng, Claude được ưu tiên làm reviewer; nếu Claude chưa đăng nhập thì dùng Codex. Nếu account connector không sẵn sàng, hệ thống mới dùng cloud/gateway connection đã cấu hình. + +### 4.2 Ollama local + +Trong **Model connections**: + +1. Chọn **Ollama Local**. +2. Endpoint local Docker: `http://host.docker.internal:11434`. +3. Chọn **Refresh** để tải danh sách model. +4. Đặt model mặc định, ví dụ `ornith:9b`. + +### 4.3 OmniRoute hoặc cloud API key + +Với OmniRoute: + +- Endpoint: `http://host.docker.internal:20128/v1` +- Model được lấy từ `/models`. +- OmniRoute là fallback khi account connector không dùng được. + +Với OpenAI/Anthropic API: + +- Chỉ dùng endpoint chính thức được allowlist. +- API key được gửi thẳng tới backend qua HTTPS local và lưu mã hóa theo tenant. +- UI không đọc lại hoặc hiển thị key đã lưu. + +## 5. Case 1 — Hỏi hệ thống mà không cần IDE + +Mở **Ask CASAN**, chọn model và hỏi: + +> Hạ tầng local hiện có những thành phần nào, phần nào chưa có bằng chứng chạy thật? + +CASAN sẽ: + +1. phân loại prompt; +2. chỉ đọc nguồn nằm trong allowlist; +3. chạy security scan input/output; +4. trả câu trả lời có source; +5. ghi audit và telemetry. + +Sau khi có câu trả lời, chọn **Open live H1–H7 trace** để xem đường đi. + +## 6. Case 2 — Chỉ đưa mục tiêu, local và cloud cùng giải quyết + +Mở **Goal orchestrator** và nhập một mục tiêu, ví dụ: + +> Lập kế hoạch đưa ứng dụng OKR hiện tại lên production, có rollback, theo dõi lỗi và tiêu chí nghiệm thu rõ ràng. + +Luồng thực tế: + +1. H1 kiểm tra hợp đồng mục tiêu và độ dài. +2. H4 quét mục tiêu trước khi đưa cho model. +3. Ollama local tạo phương án đầu tiên. +4. Output local được quét lại. +5. Codex/Claude account hoặc cloud connection phản biện và viết kết quả cuối. +6. Kết quả cuối được H4 quét lần nữa. +7. H5 tạo audit hash; H6 ghi runtime/token; H7 kết thúc. + +Trạng thái có ý nghĩa như sau: + +- `completed`: local và cloud đều đóng góp thành công; +- `degraded`: local thành công nhưng cloud/gateway không sẵn sàng; kết quả local được giữ lại và cảnh báo rõ; +- `failed`: security hoặc local worker không cho phép tiếp tục. + +## 7. Case 3 — Tìm chính xác input bị dừng ở đâu + +Có hai cách mở Trace Explorer: + +- từ nút **Open live H1–H7 trace** sau câu trả lời; +- từ **Run observability**, chọn **Open H1–H7** ở một run. + +Mỗi H có thể click để xem: + +- trạng thái mới nhất; +- lý do pass/warning/blocked/error; +- thời gian; +- evidence an toàn như model, token, latency, hash và decision. + +Trace không đưa raw prompt, API key hoặc credential vào evidence. + +## 8. Thêm một app mới trong cùng workspace + +Không cần di chuyển app OKR đang có. Với app độc lập, tạo domain pack riêng thay vì trộn yêu cầu vào `apps/okr`: + +```text +apps// +├── domain/ +│ ├── input/ # yêu cầu nguồn +│ ├── corpus/ # tài liệu dùng làm evidence +│ └── golden-runs/ # case chuẩn để đánh giá +├── config/ # cấu hình riêng của app +├── src/ # code riêng nếu app dùng cấu trúc package +└── test/ # test riêng +``` + +Sau đó thêm project vào `packages/casan-harness/level5/project-registry.json` với `domain_root` trỏ tới `apps//domain`, rồi chạy: + +```bash +packages/casan-harness/scripts/bash/verify-harness-reuse.sh +``` + +Không làm các việc sau: + +- đặt yêu cầu app mới trong `apps/okr/domain/input`; +- dùng chung secret hoặc state giữa hai tenant/project; +- sửa gate H1–H7 chỉ để một app mới pass; +- copy toàn bộ harness vào app mới. + +## 9. Khi nào cần máy Linux `192.168.1.5` + +Giữ Control Panel và account connector trên Mac trong giai đoạn local. Dùng máy Linux khi cần: + +- chạy model hoặc workload nặng; +- thử Docker/Kubernetes gần production hơn; +- chạy worker riêng trong LAN; +- kiểm tra backup/restore hoặc benchmark. + +Không copy credential Codex/Claude từ Mac sang Linux. Với production, dùng API credential trong Vault/KMS hoặc workload identity, không dùng developer browser login. + +## 10. Troubleshooting nhanh + +### Control Panel không mở + +```bash +packages/casan-harness/scripts/bash/local-full.sh status +docker compose -f docker-compose.control-panel.local.yml ps +docker logs --tail 100 output_casan5_refined-control-panel-api-1 +``` + +### Codex/Claude không hiện đã login + +```bash +codex login status +claude auth status --json +curl -fsS http://127.0.0.1:20130/healthz +``` + +Nếu CLI chưa login, thực hiện login từ Control Panel hoặc CLI chính thức. Không copy token vào cấu hình CASAN. + +### Goal dừng ở `degraded` + +Mở H3 trong Trace Explorer. Các nguyên nhân thường gặp: + +- account CLI chưa login; +- account model bridge bận hoặc rate limited; +- OmniRoute/provider đang unavailable; +- cloud preflight chặn dữ liệu nhạy cảm. + +Kết quả `degraded` không được trình bày như kết quả hai-model hoàn chỉnh. + +### Trace cũ chỉ có một H + +Đó là legacy trace được sinh trước khi event schema H1–H7 được thêm. Các chat/goal mới tự động có event chi tiết. + +## 11. Checklist trước khi dùng hàng ngày + +- [ ] `local-full.sh verify` pass. +- [ ] Ollama connected và có default model. +- [ ] Ít nhất Codex/Claude account hoặc một cloud connection sẵn sàng. +- [ ] Một Goal smoke có local worker và cloud reviewer đều `pass`. +- [ ] Trace của smoke đạt 7/7. +- [ ] Không có API key trong Git diff. +- [ ] Chưa dùng cấu hình local này như production. + +## 12. Tài liệu chính thức về đăng nhập CLI + +- [OpenAI Codex authentication](https://developers.openai.com/codex/auth) +- [Claude Code getting started and authentication](https://docs.anthropic.com/en/docs/claude-code/getting-started) +- [Claude Code CLI reference](https://docs.anthropic.com/en/docs/claude-code/cli-usage) diff --git a/docs/security/CONTROL_PANEL_AUTH_HARDENING.md b/docs/security/CONTROL_PANEL_AUTH_HARDENING.md new file mode 100644 index 0000000..1cd5c50 --- /dev/null +++ b/docs/security/CONTROL_PANEL_AUTH_HARDENING.md @@ -0,0 +1,86 @@ +# Control Panel authentication hardening + +## Scope + +This document defines the boundary between the local workstation experience and a production deployment. Local browser login to Codex/Claude is a developer convenience; it is not a production identity or secret distribution mechanism. + +## Trust boundaries + +```mermaid +flowchart LR + B["Browser"] -->|TLS + OIDC cookie| N["Nginx"] + N -->|auth_request| O["oauth2-proxy"] + N -->|overwritten identity headers| A["Control Panel API"] + A -->|tenant-scoped encrypted store| S["CASAN state"] + A -->|random bridge token, local only| H["Mac host bridge"] + H -->|official CLI auth| P["Codex / Claude"] +``` + +The API must not be published directly. Nginx is the only ingress and overwrites `X-CASAN-Actor`, `X-CASAN-Groups`, and `X-CASAN-Role`. The API refuses a non-loopback strict bind unless `CASAN_CP_TRUST_AUTH_PROXY=1` is explicit. + +## Implemented controls + +- TLS at Nginx; production accepts only TLS 1.2/1.3. +- OIDC authentication through oauth2-proxy. +- Secure, HttpOnly, SameSite=Lax session cookies with bounded expiry/refresh. +- Browser-supplied identity headers are overwritten at Nginx. +- CSP, frame denial, MIME sniffing protection, referrer and browser permission restrictions. +- API request body capped at 1 MiB. +- SSE buffering disabled for trace streams; no intermediate proxy cache. +- Provider account bridge is disabled unless `CASAN_PROVIDER_ACCOUNT_AUTH_ENABLED=1`. +- Production Compose pins account auth to disabled. +- Bridge requests require a random 256-bit token, valid Host header, bounded JSON body and fixed provider/command allowlist. +- Account model calls run in a temporary directory with tools disabled or a read-only sandbox and ephemeral sessions. +- One concurrent account model call per provider and ten calls per ten minutes. +- Bridge audit stores only timestamp, provider, status, prompt hash, prompt length and latency. It never stores prompt text or credentials. +- Bridge token file is mode 0600 and rotates after a clean local stop/start. +- Goal starts are limited to five per actor per ten minutes and two concurrent jobs per actor. +- Goal state is tenant-scoped; IDs are validated before file access. +- Goal audit is hash-linked and serialized under a file lock to prevent concurrent writers from forking the chain. +- Trace evidence excludes raw prompts and secrets. + +## Local-only controls + +The host bridge listens on a workstation port because Docker Desktop must reach the Mac host. Its token is the primary authorization barrier. Keep macOS firewall enabled and do not port-forward `20130`. + +The local TLS certificate is self-signed. Manual trust is acceptable only for localhost testing. Never reuse this certificate or the mock IdP in production. + +## Production requirements + +Before production deployment: + +1. Keep `CASAN_PROVIDER_ACCOUNT_AUTH_ENABLED=0`. +2. Do not deploy `provider-auth-bridge.py`. +3. Use managed OpenAI/Anthropic credentials from Vault/KMS or workload identity. +4. Use an enterprise IdP and explicit group-to-role mapping. +5. Use a CA-issued certificate and a fixed production hostname. +6. Set a digest-pinned `CASAN_CP_API_IMAGE`, `CASAN_CP_UI_IMAGE`, and `CASAN_CP_OAUTH2_PROXY_IMAGE`. +7. Keep the API on a private container/network segment with Nginx as its only caller. +8. Restrict egress from the API to allowlisted model providers, Vault/KMS, object storage and required observability endpoints. +9. Store state on encrypted storage; send audit heads to KMS/WORM/Object Lock. +10. Export rate-limit, auth failure, bridge-disabled and audit-chain metrics to alerting. +11. Back up and restore-test tenant state before enabling write actions. +12. Run the production preflight and security suites against the exact deployment images. + +## Known residual risks + +- Local bridge authorization is bearer-token based; a process with access to the token file can call it. +- In-memory rate windows reset when the API or bridge restarts. Production should also rate-limit at ingress. +- Goal output is stored in tenant-scoped local state but is not currently envelope-encrypted as a whole. +- A compromised container on the private Control Panel network may attempt to forge proxy headers. Production network policy must keep unrelated workloads off that network. +- Developer account subscriptions have provider-specific quotas and are not an availability SLA. +- The local mock IdP and self-signed TLS do not prove enterprise SSO readiness. + +## Incident response + +If account bridge token exposure is suspected: + +```bash +packages/casan-harness/scripts/bash/local-full.sh stop +packages/casan-harness/scripts/bash/local-full.sh start +``` + +This terminates the bridge, deletes the old token and creates a new one. Then inspect `tmp/control-panel-local/auth-bridge/model-audit.jsonl` for unexpected provider/status/hash activity. The log contains no raw prompts. + +If a goal audit chain is suspected of tampering, stop new goal submissions, preserve `.specify/logs/audit/goal-orchestrator.jsonl` and its head, and compare each `prev_hash`/`record_hash` before restoring service. + diff --git a/nginx/control-panel.conf b/nginx/control-panel.conf index 0cddee4..bb2cc33 100644 --- a/nginx/control-panel.conf +++ b/nginx/control-panel.conf @@ -1,9 +1,18 @@ server { listen 443 ssl; server_name _; + server_tokens off; + client_max_body_size 1m; ssl_certificate /etc/nginx/tls/tls.crt; ssl_certificate_key /etc/nginx/tls/tls.key; + ssl_protocols TLSv1.2 TLSv1.3; + + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none'; form-action 'self'; connect-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always; root /usr/share/nginx/html; index index.html; @@ -37,6 +46,10 @@ server { proxy_pass http://control-panel-api:3010/api/v1/; proxy_http_version 1.1; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 360s; + proxy_send_timeout 360s; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/packages/casan-control-panel/backend/src/goals/goals.service.ts b/packages/casan-control-panel/backend/src/goals/goals.service.ts index b0f4898..c819d3e 100644 --- a/packages/casan-control-panel/backend/src/goals/goals.service.ts +++ b/packages/casan-control-panel/backend/src/goals/goals.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, ForbiddenException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, HttpException, HttpStatus, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; import { execFileSync, spawn } from 'node:child_process'; import { randomUUID } from 'node:crypto'; @@ -83,12 +83,15 @@ function safeTenant(value: string): string { @Injectable() export class GoalsService { + private readonly startWindows = new Map(); + async start(input: GoalStartInput, actor: SettingsActor): Promise { this.requireRead(actor); const goal = String(input.goal ?? '').trim(); if (goal.length < 10 || goal.length > 8000) { throw new BadRequestException('GOAL_LENGTH_INVALID'); } + this.enforceStartLimit(actor); const connections = this.connections(actor); const local = connections.find((connection) => connection.connected && connection.kind === 'local'); @@ -200,6 +203,7 @@ export class GoalsService { } private async accountReviewer(): Promise<'claude' | 'codex' | ''> { + if (process.env.CASAN_PROVIDER_ACCOUNT_AUTH_ENABLED !== '1') return ''; const bridgeUrl = (process.env.CASAN_AUTH_BRIDGE_URL || '').replace(/\/$/, ''); const bridgeToken = process.env.CASAN_AUTH_BRIDGE_TOKEN || ''; if (!bridgeUrl || !bridgeToken) return ''; @@ -219,6 +223,27 @@ export class GoalsService { return ''; } + private enforceStartLimit(actor: SettingsActor): void { + const key = `${safeTenant(actor.tenant)}:${actor.actor}`; + const timestamp = Date.now(); + const recent = (this.startWindows.get(key) ?? []).filter((value) => timestamp - value < 10 * 60_000); + if (recent.length >= 5) { + throw new HttpException('GOAL_RATE_LIMITED', HttpStatus.TOO_MANY_REQUESTS); + } + const directory = join(APP_ROOT, '.specify', 'state', 'goals', safeTenant(actor.tenant)); + if (existsSync(directory)) { + const active = readdirSync(directory) + .filter((name) => /^[a-f0-9-]{36}\.json$/.test(name)) + .map((name) => parseJson(readFileSync(join(directory, name), 'utf8'))) + .filter((job) => job?.actor === actor.actor && (job.status === 'queued' || job.status === 'running')); + if (active.length >= 2) { + throw new HttpException('GOAL_CONCURRENCY_LIMITED', HttpStatus.TOO_MANY_REQUESTS); + } + } + recent.push(timestamp); + this.startWindows.set(key, recent); + } + private runPython(script: string, args: string[], environment: NodeJS.ProcessEnv): string { try { return execFileSync('python3', [script, ...args], { diff --git a/packages/casan-control-panel/backend/src/provider-auth/provider-auth.service.ts b/packages/casan-control-panel/backend/src/provider-auth/provider-auth.service.ts index 62d740f..b3ce28c 100644 --- a/packages/casan-control-panel/backend/src/provider-auth/provider-auth.service.ts +++ b/packages/casan-control-panel/backend/src/provider-auth/provider-auth.service.ts @@ -27,6 +27,7 @@ const PROVIDERS = new Set(['codex', 'claude']); export class ProviderAuthService { private readonly bridgeUrl = (process.env.CASAN_AUTH_BRIDGE_URL || 'http://host.docker.internal:20130').replace(/\/$/, ''); private readonly bridgeToken = process.env.CASAN_AUTH_BRIDGE_TOKEN || ''; + private readonly enabled = process.env.CASAN_PROVIDER_ACCOUNT_AUTH_ENABLED === '1'; async status(actor: SettingsActor): Promise { this.requireRead(actor); @@ -40,7 +41,7 @@ export class ProviderAuthService { } private async bridgeRequest(path: string, method: 'GET' | 'POST'): Promise { - if (!this.bridgeToken) throw new ServiceUnavailableException('PROVIDER_AUTH_BRIDGE_NOT_CONFIGURED'); + if (!this.enabled || !this.bridgeToken) throw new ServiceUnavailableException('PROVIDER_AUTH_BRIDGE_NOT_CONFIGURED'); try { const response = await fetch(`${this.bridgeUrl}${path}`, { method, diff --git a/packages/casan-control-panel/scripts/provider-auth-bridge.py b/packages/casan-control-panel/scripts/provider-auth-bridge.py index afec9d5..70795d3 100644 --- a/packages/casan-control-panel/scripts/provider-auth-bridge.py +++ b/packages/casan-control-panel/scripts/provider-auth-bridge.py @@ -7,6 +7,7 @@ Only fixed commands are allowed and every request requires a generated bearer token supplied to the backend container by local-full.sh. """ import argparse +import hashlib import hmac import json import os @@ -15,6 +16,8 @@ import subprocess import tempfile import threading import time +from collections import deque +from datetime import datetime, timezone from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Dict, Optional @@ -35,7 +38,40 @@ PROVIDERS = { } RUNNING: Dict[str, subprocess.Popen] = {} LOCK = threading.Lock() -MODEL_LOCKS = {provider: threading.Lock() for provider in PROVIDERS} +MODEL_GATES = {provider: threading.Semaphore(1) for provider in PROVIDERS} +MODEL_WINDOWS = {provider: deque() for provider in PROVIDERS} +RATE_LOCK = threading.Lock() +AUDIT_LOCK = threading.Lock() + + +def model_call_allowed(provider: str) -> bool: + timestamp = time.monotonic() + with RATE_LOCK: + window = MODEL_WINDOWS[provider] + while window and timestamp - window[0] > 600: + window.popleft() + if len(window) >= 10: + return False + window.append(timestamp) + return True + + +def audit_model_call(path: str, provider: str, status: str, prompt: str, latency_ms: int) -> None: + if not path: + return + record = { + "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "provider": provider, + "status": status, + "prompt_hash": hashlib.sha256(prompt.encode("utf-8")).hexdigest(), + "prompt_characters": len(prompt), + "latency_ms": latency_ms, + } + os.makedirs(os.path.dirname(path), exist_ok=True) + with AUDIT_LOCK, open(path, "a", encoding="utf-8") as handle: + handle.write(json.dumps(record, ensure_ascii=False) + "\n") + handle.flush() + os.chmod(path, 0o600) def command_status(provider: str) -> dict: @@ -112,56 +148,73 @@ def start_login(provider: str) -> dict: return {"success": True, "reason": "browser_login_started", "provider": command_status(provider)} -def generate_with_account(provider: str, prompt: str) -> dict: +def run_account_command(provider: str, prompt: str, directory: str): + if provider == "codex": + output_path = os.path.join(directory, "last-message.txt") + command = [ + "codex", "exec", "--ephemeral", "--ignore-user-config", "--ignore-rules", + "--skip-git-repo-check", "--sandbox", "read-only", "--color", "never", + "--cd", directory, "--output-last-message", output_path, "-", + ] + result = subprocess.run( + command, input=prompt, capture_output=True, text=True, timeout=300, + env={**os.environ, "NO_COLOR": "1"}, + ) + text = "" + if result.returncode == 0 and os.path.isfile(output_path): + with open(output_path, encoding="utf-8") as handle: + text = handle.read().strip() + return result, text, {}, "codex-account-default" + + command = [ + "claude", "--print", "--output-format", "json", "--permission-mode", "plan", + "--tools", "", "--safe-mode", "--no-session-persistence", + ] + result = subprocess.run( + command, input=prompt, capture_output=True, text=True, timeout=300, cwd=directory, + env={**os.environ, "NO_COLOR": "1"}, + ) + try: + payload = json.loads(result.stdout or "{}") + except ValueError: + payload = {} + text = str(payload.get("result") or "").strip() + raw_usage = payload.get("usage") if isinstance(payload.get("usage"), dict) else {} + usage = { + "input_tokens": int(raw_usage.get("input_tokens") or 0), + "output_tokens": int(raw_usage.get("output_tokens") or 0), + } + return result, text, usage, str(payload.get("model") or "claude-account-default") + + +def generate_with_account(provider: str, prompt: str, audit_path: str) -> dict: + started = time.monotonic() status = command_status(provider) if not status["available"] or not status["loggedIn"]: + audit_model_call(audit_path, provider, "provider_not_logged_in", prompt, 0) return {"success": False, "reason": "provider_not_logged_in"} if not prompt or len(prompt) > 24000: + audit_model_call(audit_path, provider, "prompt_length_invalid", prompt, 0) return {"success": False, "reason": "prompt_length_invalid"} - started = time.monotonic() - with MODEL_LOCKS[provider], tempfile.TemporaryDirectory(prefix="casan-account-model-") as directory: - try: - if provider == "codex": - output_path = os.path.join(directory, "last-message.txt") - command = [ - "codex", "exec", "--ephemeral", "--ignore-user-config", "--ignore-rules", - "--skip-git-repo-check", "--sandbox", "read-only", "--color", "never", - "--cd", directory, "--output-last-message", output_path, "-", - ] - result = subprocess.run( - command, input=prompt, capture_output=True, text=True, timeout=300, - env={**os.environ, "NO_COLOR": "1"}, - ) - text = "" - if result.returncode == 0 and os.path.isfile(output_path): - with open(output_path, encoding="utf-8") as handle: - text = handle.read().strip() - usage = {} - model = "codex-account-default" - else: - command = [ - "claude", "--print", "--output-format", "json", "--permission-mode", "plan", - "--tools", "", "--safe-mode", "--no-session-persistence", - ] - result = subprocess.run( - command, input=prompt, capture_output=True, text=True, timeout=300, cwd=directory, - env={**os.environ, "NO_COLOR": "1"}, - ) - try: - payload = json.loads(result.stdout or "{}") - except ValueError: - payload = {} - text = str(payload.get("result") or "").strip() - raw_usage = payload.get("usage") if isinstance(payload.get("usage"), dict) else {} - usage = { - "input_tokens": int(raw_usage.get("input_tokens") or 0), - "output_tokens": int(raw_usage.get("output_tokens") or 0), - } - model = str(payload.get("model") or "claude-account-default") - except (OSError, subprocess.TimeoutExpired): - return {"success": False, "reason": "account_model_unreachable"} + if not model_call_allowed(provider): + audit_model_call(audit_path, provider, "rate_limited", prompt, 0) + return {"success": False, "reason": "account_model_rate_limited"} + gate = MODEL_GATES[provider] + if not gate.acquire(blocking=False): + audit_model_call(audit_path, provider, "busy", prompt, 0) + return {"success": False, "reason": "account_model_busy"} + try: + with tempfile.TemporaryDirectory(prefix="casan-account-model-") as directory: + result, text, usage, model = run_account_command(provider, prompt, directory) + except (OSError, subprocess.TimeoutExpired): + audit_model_call(audit_path, provider, "unreachable", prompt, int((time.monotonic() - started) * 1000)) + return {"success": False, "reason": "account_model_unreachable"} + finally: + gate.release() if result.returncode != 0 or not text: + audit_model_call(audit_path, provider, "failed", prompt, int((time.monotonic() - started) * 1000)) return {"success": False, "reason": "account_model_failed"} + audit_model_call(audit_path, provider, "success", prompt, int((time.monotonic() - started) * 1000)) return { "success": True, "provider": provider, @@ -185,15 +238,23 @@ class BridgeHandler(BaseHTTPRequestHandler): self.send_header("Content-Length", str(len(encoded))) self.send_header("Cache-Control", "no-store") self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("X-Frame-Options", "DENY") + self.send_header("Referrer-Policy", "no-referrer") + self.send_header("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'") self.end_headers() self.wfile.write(encoded) def authorized(self) -> bool: + host = self.headers.get("Host", "").split(":", 1)[0].lower() + if host not in {"127.0.0.1", "localhost", "host.docker.internal"}: + return False expected = self.server.bridge_token # type: ignore[attr-defined] supplied = self.headers.get("X-CASAN-Bridge-Token", "") return bool(expected) and hmac.compare_digest(expected, supplied) def read_json(self) -> dict: + if self.headers.get("Content-Type", "").split(";", 1)[0].strip().lower() != "application/json": + return {} try: length = int(self.headers.get("Content-Length", "0")) except ValueError: @@ -229,7 +290,7 @@ class BridgeHandler(BaseHTTPRequestHandler): return if len(parts) == 4 and parts[:2] == ["v1", "models"] and parts[3] == "generate" and parts[2] in PROVIDERS: body = self.read_json() - result = generate_with_account(parts[2], str(body.get("prompt") or "")) + result = generate_with_account(parts[2], str(body.get("prompt") or ""), self.server.audit_log) # type: ignore[attr-defined] self.send_json(200 if result["success"] else 503, result) return self.send_json(404, {"success": False, "reason": "not_found"}) @@ -251,9 +312,12 @@ def main() -> int: parser.add_argument("--bind", default="127.0.0.1") parser.add_argument("--port", type=int, default=20130) parser.add_argument("--token-file", required=True) + parser.add_argument("--audit-log", default="") args = parser.parse_args() server = ThreadingHTTPServer((args.bind, args.port), BridgeHandler) server.bridge_token = read_token(args.token_file) # type: ignore[attr-defined] + server.audit_log = os.path.abspath(args.audit_log) if args.audit_log else "" # type: ignore[attr-defined] + server.daemon_threads = True try: server.serve_forever() except KeyboardInterrupt: diff --git a/packages/casan-harness/scripts/bash/goal-orchestrator.py b/packages/casan-harness/scripts/bash/goal-orchestrator.py index 6c413f7..825d946 100644 --- a/packages/casan-harness/scripts/bash/goal-orchestrator.py +++ b/packages/casan-harness/scripts/bash/goal-orchestrator.py @@ -204,29 +204,36 @@ def call_account_model(provider: str, prompt: str): def audit(job: dict, status: str) -> str: path = os.path.join(STATE_ROOT, "logs", "audit", "goal-orchestrator.jsonl") head_path = os.path.join(STATE_ROOT, "logs", "audit", "goal-orchestrator-head.txt") + lock_path = os.path.join(STATE_ROOT, "logs", "audit", "goal-orchestrator.lock") os.makedirs(os.path.dirname(path), exist_ok=True) - previous = "0" * 64 - try: - with open(head_path, encoding="utf-8") as handle: - previous = handle.read().strip() or previous - except OSError: - pass - core = { - "timestamp": now(), - "harness": "H5-governance", - "goal_id": job["id"], - "tenant": job.get("tenant", "default"), - "actor": job.get("actor", "unknown"), - "goal_hash": sha(str(job.get("goal", ""))), - "status": status, - "local_provider": job.get("local_provider", ""), - "cloud_provider": job.get("cloud_provider", ""), - "prev_hash": previous, - } - record_hash = sha(json.dumps(core, sort_keys=True, ensure_ascii=False)) - append_jsonl(path, {**core, "record_hash": record_hash}) - with open(head_path, "w", encoding="utf-8") as handle: - handle.write(record_hash + "\n") + with open(lock_path, "a", encoding="utf-8") as lock: + fcntl.flock(lock.fileno(), fcntl.LOCK_EX) + previous = "0" * 64 + try: + with open(head_path, encoding="utf-8") as handle: + previous = handle.read().strip() or previous + except OSError: + pass + core = { + "timestamp": now(), + "harness": "H5-governance", + "goal_id": job["id"], + "tenant": job.get("tenant", "default"), + "actor": job.get("actor", "unknown"), + "goal_hash": sha(str(job.get("goal", ""))), + "status": status, + "local_provider": job.get("local_provider", ""), + "cloud_provider": job.get("cloud_provider", ""), + "prev_hash": previous, + } + record_hash = sha(json.dumps(core, sort_keys=True, ensure_ascii=False)) + append_jsonl(path, {**core, "record_hash": record_hash}) + with open(head_path, "w", encoding="utf-8") as handle: + handle.write(record_hash + "\n") + handle.flush() + os.fsync(handle.fileno()) + os.chmod(head_path, 0o600) + fcntl.flock(lock.fileno(), fcntl.LOCK_UN) return record_hash @@ -351,10 +358,43 @@ def run(job_path: str) -> int: return 2 +def verify_audit() -> int: + path = os.path.join(STATE_ROOT, "logs", "audit", "goal-orchestrator.jsonl") + previous = "0" * 64 + records = 0 + try: + handle = open(path, encoding="utf-8") + except OSError: + print(json.dumps({"ok": True, "records": 0, "head": previous})) + return 0 + with handle: + for line in handle: + if not line.strip(): + continue + records += 1 + try: + record = json.loads(line) + except ValueError: + print(json.dumps({"ok": False, "records": records, "reason": "invalid_json"})) + return 3 + record_hash = str(record.pop("record_hash", "")) + expected = sha(json.dumps(record, sort_keys=True, ensure_ascii=False)) + if record.get("prev_hash") != previous or record_hash != expected: + print(json.dumps({"ok": False, "records": records, "reason": "chain_break"})) + return 3 + previous = record_hash + print(json.dumps({"ok": True, "records": records, "head": previous})) + return 0 + + def main() -> int: parser = argparse.ArgumentParser() - parser.add_argument("--job-file", required=True) + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--job-file") + group.add_argument("--verify-audit", action="store_true") args = parser.parse_args() + if args.verify_audit: + return verify_audit() path = os.path.abspath(args.job_file) state = os.path.abspath(STATE_ROOT) + os.sep if not path.startswith(state): diff --git a/packages/casan-harness/scripts/bash/local-full.sh b/packages/casan-harness/scripts/bash/local-full.sh index 59792ec..1596a97 100755 --- a/packages/casan-harness/scripts/bash/local-full.sh +++ b/packages/casan-harness/scripts/bash/local-full.sh @@ -18,6 +18,7 @@ AUTH_BRIDGE_DIR="$ROOT/tmp/control-panel-local/auth-bridge" AUTH_BRIDGE_TOKEN_FILE="$AUTH_BRIDGE_DIR/token" AUTH_BRIDGE_PID_FILE="$AUTH_BRIDGE_DIR/bridge.pid" AUTH_BRIDGE_LOG="$AUTH_BRIDGE_DIR/bridge.log" +AUTH_BRIDGE_AUDIT="$AUTH_BRIDGE_DIR/model-audit.jsonl" AUTH_BRIDGE="$ROOT/packages/casan-control-panel/scripts/provider-auth-bridge.py" CMD="${1:-status}" @@ -43,9 +44,9 @@ start_auth_bridge() { return 0 fi [[ -f "$AUTH_BRIDGE" ]] || { echo "CASAN_AUTH_BRIDGE_MISSING" >&2; return 1; } - nohup python3 "$AUTH_BRIDGE" --bind 0.0.0.0 --port 20130 --token-file "$AUTH_BRIDGE_TOKEN_FILE" > "$AUTH_BRIDGE_LOG" 2>&1 & + nohup python3 "$AUTH_BRIDGE" --bind 0.0.0.0 --port 20130 --token-file "$AUTH_BRIDGE_TOKEN_FILE" --audit-log "$AUTH_BRIDGE_AUDIT" > "$AUTH_BRIDGE_LOG" 2>&1 & echo "$!" > "$AUTH_BRIDGE_PID_FILE" - chmod 600 "$AUTH_BRIDGE_PID_FILE" "$AUTH_BRIDGE_LOG" 2>/dev/null || true + chmod 600 "$AUTH_BRIDGE_PID_FILE" "$AUTH_BRIDGE_LOG" "$AUTH_BRIDGE_AUDIT" 2>/dev/null || true wait_url "http://127.0.0.1:20130/healthz" } @@ -56,6 +57,7 @@ stop_auth_bridge() { kill "$pid" 2>/dev/null || true rm -f "$AUTH_BRIDGE_PID_FILE" fi + rm -f "$AUTH_BRIDGE_TOKEN_FILE" } need_docker() {