feat: harden control panel authentication

This commit is contained in:
thanhnv
2026-07-10 23:48:12 +09:00
parent 48b1186439
commit b56f7d357e
11 changed files with 571 additions and 73 deletions
+1
View File
@@ -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/
+5
View File
@@ -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
+9 -1
View File
@@ -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:
+253
View File
@@ -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/<app-slug>/
├── 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/<app-slug>/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)
@@ -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.
+13
View File
@@ -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;
@@ -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<string, number[]>();
async start(input: GoalStartInput, actor: SettingsActor): Promise<GoalJob> {
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<GoalJob>(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], {
@@ -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<BridgeStatusResponse> {
this.requireRead(actor);
@@ -40,7 +41,7 @@ export class ProviderAuthService {
}
private async bridgeRequest<T>(path: string, method: 'GET' | 'POST'): Promise<T> {
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,
@@ -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,15 +148,7 @@ 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:
status = command_status(provider)
if not status["available"] or not status["loggedIn"]:
return {"success": False, "reason": "provider_not_logged_in"}
if not prompt or len(prompt) > 24000:
return {"success": False, "reason": "prompt_length_invalid"}
started = time.monotonic()
with MODEL_LOCKS[provider], tempfile.TemporaryDirectory(prefix="casan-account-model-") as directory:
try:
def run_account_command(provider: str, prompt: str, directory: str):
if provider == "codex":
output_path = os.path.join(directory, "last-message.txt")
command = [
@@ -136,9 +164,8 @@ def generate_with_account(provider: str, prompt: str) -> dict:
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:
return result, text, {}, "codex-account-default"
command = [
"claude", "--print", "--output-format", "json", "--permission-mode", "plan",
"--tools", "", "--safe-mode", "--no-session-persistence",
@@ -157,11 +184,37 @@ def generate_with_account(provider: str, prompt: str) -> dict:
"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")
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"}
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:
@@ -204,7 +204,10 @@ 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)
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:
@@ -227,6 +230,10 @@ def audit(job: dict, status: str) -> str:
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):
@@ -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() {