feat: prepare CASAN paid PoC release package

This commit is contained in:
thanhnv
2026-07-18 00:07:04 +07:00
parent c818eaf8b0
commit 881ee01691
55 changed files with 5992 additions and 44 deletions
@@ -2,6 +2,7 @@
"name": "@casan/control-panel-backend",
"version": "1.0.0",
"private": true,
"license": "UNLICENSED",
"type": "module",
"description": "CASAN Ops Console — NestJS API over harness telemetry, governed settings, approval inbox, kill-switch, and FinOps/SLO (Plan-13 Track 1/2/3/4 partial).",
"scripts": {
@@ -7,9 +7,10 @@ import { ApprovalsModule } from './approvals/approvals.module.js';
import { ChatModule } from './chat/chat.module.js';
import { ProviderAuthModule } from './provider-auth/provider-auth.module.js';
import { GoalsModule } from './goals/goals.module.js';
import { EvidenceModule } from './evidence/evidence.module.js';
@Module({
imports: [TelemetryModule, SettingsModule, KillSwitchModule, ApprovalsModule, ChatModule, ProviderAuthModule, GoalsModule],
imports: [TelemetryModule, SettingsModule, KillSwitchModule, ApprovalsModule, ChatModule, ProviderAuthModule, GoalsModule, EvidenceModule],
controllers: [HealthController],
})
export class AppModule {}
@@ -23,8 +23,8 @@ export class ChatController {
}
@Get('audit/verify')
verifyAudit() {
return ok(this.svc.verifyAudit());
verifyAudit(@Headers() headers: Record<string, string | string[] | undefined>) {
return ok(this.svc.verifyAudit(actorFromHeaders(headers)));
}
@Get('history')
@@ -37,8 +37,12 @@ export class ChatController {
}
@Get('replay')
replay(@Query('chatId') chatId?: string, @Query('turnId') turnId?: string, @Query('tenant') tenant?: string) {
return ok(this.svc.replay(chatId || '', turnId || '', tenant || ''));
replay(
@Headers() headers: Record<string, string | string[] | undefined>,
@Query('chatId') chatId?: string,
@Query('turnId') turnId?: string,
) {
return ok(this.svc.replay(actorFromHeaders(headers), chatId || '', turnId || ''));
}
@Get('actions')
@@ -107,7 +107,7 @@ export class ChatService {
const res = runPython(CHAT_CLI, args, runtime.env);
const parsed = parseJson<Record<string, any>>(res.stdout);
if (parsed) {
return { ...parsed, actor, audit_verify: this.verifyAudit() };
return { ...parsed, actor, audit_verify: this.verifyAudit(actor) };
}
if (res.status !== 0) {
throw new InternalServerErrorException(res.stderr || res.stdout || 'CHAT_CLI_FAILED');
@@ -115,8 +115,9 @@ export class ChatService {
throw new InternalServerErrorException('CHAT_CLI_EMPTY_RESPONSE');
}
verifyAudit() {
const res = runPython(CHAT_CLI, ['verify-audit']);
verifyAudit(actor: SettingsActor) {
this.requireRead(actor);
const res = runPython(CHAT_CLI, ['verify-audit'], this.tenantEnv(actor));
return { ok: res.status === 0, output: res.stdout || res.stderr };
}
@@ -125,7 +126,7 @@ export class ChatService {
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 res = runPython(CHAT_CLI, args, this.tenantEnv(actor));
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');
@@ -176,12 +177,12 @@ export class ChatService {
child.on('close', () => res.end());
}
replay(chatId = '', turnId = '', tenant = '') {
replay(actor: SettingsActor, chatId = '', turnId = '') {
this.requireRead(actor);
const args = ['replay'];
if (chatId) args.push('--chat-id', chatId);
if (turnId) args.push('--turn-id', turnId);
const env = tenant && tenant !== 'default' ? { CASAN_TENANT_ID: tenant } : {};
const res = runPython(REPLAY_CLI, args, env);
const res = runPython(REPLAY_CLI, args, this.tenantEnv(actor));
const parsed = parseJson<Record<string, any>>(res.stdout);
if (parsed) return { ok: res.status === 0, ...parsed };
throw new InternalServerErrorException(res.stderr || res.stdout || 'CHAT_REPLAY_FAILED');
@@ -272,6 +273,10 @@ export class ChatService {
return { policyProvider, env: { CASAN_TENANT_ID: actor.tenant || 'default', ...parsed.env } };
}
private tenantEnv(actor: SettingsActor): NodeJS.ProcessEnv {
return { CASAN_TENANT_ID: actor.tenant || 'default' };
}
private requireConnectionAdmin(actor: SettingsActor) {
if (!['project-admin', 'org-admin'].includes(actor.role)) {
throw new ForbiddenException('MODEL_CONNECTION_ADMIN_REQUIRED');
@@ -52,6 +52,7 @@ export const PATHS = {
chatAudit: env('CASAN_CP_CHAT_AUDIT', '.specify/logs/chat/chat-turns.jsonl'),
delegationPolicy: env('CASAN_CP_DELEGATION_POLICY', 'packages/casan-harness/config/delegation-policy.yaml'),
selfImprove: env('CASAN_CP_SELF_IMPROVE', 'packages/casan-harness/scripts/bash/self-improve.py'),
evidencePacks: env('CASAN_CP_EVIDENCE_PACKS', 'docs/output/casan/evidence-packs'),
};
export const STALE_AFTER_S = Number(process.env.CASAN_DASHBOARD_STALE_S ?? 3600);
@@ -0,0 +1,14 @@
import { Controller, Get, Inject, Param } from '@nestjs/common';
import { ok } from '../common/api-response.js';
import { EvidenceService } from './evidence.service.js';
@Controller('api/v1/evidence-packs')
export class EvidenceController {
constructor(@Inject(EvidenceService) private readonly service: EvidenceService) {}
@Get()
list() { return ok(this.service.list()); }
@Get(':id')
get(@Param('id') id: string) { return ok(this.service.get(id)); }
}
@@ -0,0 +1,6 @@
import { Module } from '@nestjs/common';
import { EvidenceController } from './evidence.controller.js';
import { EvidenceService } from './evidence.service.js';
@Module({ controllers: [EvidenceController], providers: [EvidenceService] })
export class EvidenceModule {}
@@ -0,0 +1,107 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { basename, join, relative, resolve } from 'node:path';
import { execFileSync } from 'node:child_process';
import { APP_ROOT, PATHS } from '../common/app-root.js';
const PACK_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
const JSON_LIMIT = 1024 * 1024;
const TEXT_LIMIT = 256 * 1024;
const REPORTS = [
'h1-context-report.json', 'h2-tool-audit.json', 'h3-eval-scorecard.json',
'h4-security-report.json', 'h5-audit-chain-proof.json', 'h6-cost-telemetry.json',
'h7-orchestration-report.json', 'redteam-result.json', 'benign-fp-report.json',
];
function isInside(root: string, path: string) {
const rel = relative(root, path);
return rel === '' || (!rel.startsWith('..') && !rel.includes(`..${String.fromCharCode(47)}`));
}
function readBounded(path: string, max: number): string | null {
try {
if (statSync(path).size > max) return null;
return readFileSync(path, 'utf8');
} catch { return null; }
}
function readObject(path: string): Record<string, unknown> | null {
const source = readBounded(path, JSON_LIMIT);
if (!source) return null;
try {
const value: unknown = JSON.parse(source);
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null;
} catch { return null; }
}
@Injectable()
export class EvidenceService {
private readonly root = resolve(PATHS.evidencePacks);
private pack(id: string): string {
if (!PACK_ID.test(id)) throw new NotFoundException('Evidence pack not found');
const path = resolve(this.root, id);
if (!isInside(this.root, path) || basename(path) !== id || !existsSync(path)) {
throw new NotFoundException('Evidence pack not found');
}
return path;
}
private integrity(dir: string) {
const script = join(APP_ROOT, 'packages', 'casan-harness', 'scripts', 'bash', 'evidence-pack-verify.py');
if (!existsSync(script)) return { ok: false, status: 'verifier_unavailable', output: 'Evidence verifier is unavailable.' };
try {
const output = execFileSync('python3', [script, dir], { cwd: APP_ROOT, encoding: 'utf8', timeout: 15_000, stdio: ['ignore', 'pipe', 'pipe'] }).trim();
return { ok: true, status: 'verified', output };
} catch (error: unknown) {
const detail = error && typeof error === 'object' && 'stderr' in error ? String((error as { stderr?: unknown }).stderr ?? '') : '';
return { ok: false, status: 'failed', output: detail.trim().slice(0, 500) || 'Evidence manifest verification failed.' };
}
}
private summary(id: string, dir: string) {
const run = readObject(join(dir, 'run-summary.json')) ?? {};
const manifest = readObject(join(dir, 'artifact-manifest.json')) ?? {};
const signature = existsSync(join(dir, 'evidence-pack.sig')) ? 'present' : 'unsigned';
return {
run_id: String(run.run_id ?? id),
certified: run.certified === true,
certification_reasons: Array.isArray(run.certification_reasons) ? run.certification_reasons.map(String) : [],
pack_version: String(run.pack_version ?? 'unknown'),
created_at: statSync(dir).mtime.toISOString(),
file_count: manifest.files && typeof manifest.files === 'object' && !Array.isArray(manifest.files) ? Object.keys(manifest.files as Record<string, unknown>).length : 0,
signature,
integrity: this.integrity(dir),
};
}
list() {
if (!existsSync(this.root)) return { root: this.root, packs: [] };
const packs = readdirSync(this.root, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && PACK_ID.test(entry.name))
.map((entry) => this.summary(entry.name, join(this.root, entry.name)))
.sort((a, b) => b.created_at.localeCompare(a.created_at));
return { root: this.root, packs };
}
get(id: string) {
const dir = this.pack(id);
const manifest = readObject(join(dir, 'artifact-manifest.json')) ?? {};
const hashes = manifest.files && typeof manifest.files === 'object' && !Array.isArray(manifest.files)
? manifest.files as Record<string, unknown> : {};
const files = Object.entries(hashes).map(([path, sha256]) => ({
path,
sha256: String(sha256),
bytes: (() => { try { return statSync(join(dir, path)).size; } catch { return 0; } })(),
}));
const reports = Object.fromEntries(REPORTS.map((name) => [name, readObject(join(dir, name))]).filter(([, value]) => value));
return {
...this.summary(id, dir),
manifest_head: readBounded(join(dir, 'manifest-head.txt'), 512)?.trim() || String(manifest.manifest_head ?? '') || null,
run_summary: readObject(join(dir, 'run-summary.json')),
decision_log: readBounded(join(dir, 'decision-log.md'), TEXT_LIMIT),
reports,
files,
};
}
}
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync } from 'node:fs';
import { mkdirSync, mkdtempSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { ChatService } from '../src/chat/chat.service.js';
@@ -20,7 +20,9 @@ function withTempChatState(fn: () => void) {
const state = mkdtempSync(join(tmpdir(), 'cp-chat-'));
process.env.CASAN_STATE_ROOT = state;
process.env.CASAN_TENANT_STATE_ROOT = join(state, 'tenants');
process.env.CASAN_APPROVAL_INBOX_FILE = join(state, 'approval-inbox.json');
const defaultApprovalDir = join(state, 'tenants', 'default', 'approvals');
mkdirSync(defaultApprovalDir, { recursive: true });
process.env.CASAN_APPROVAL_INBOX_FILE = join(defaultApprovalDir, 'approval-inbox.json');
delete process.env.CASAN_CHAT_AUDIT_LOG;
delete process.env.CASAN_CHAT_AUDIT_HEAD;
delete process.env.CASAN_CHAT_METRICS_LOG;
@@ -111,7 +113,7 @@ test('chat ask executes registered operator action through action-gate', () => {
assert.equal(res.loop_run.side_effect_released, true);
assert.equal(res.loop_run.trace_verify.ok, true);
assert.equal(res.loop_run.replay.ok, true);
const replay = svc.replay('operator-chat') as any;
const replay = svc.replay(operator, 'operator-chat') as any;
assert.equal(replay.ok, true);
assert.equal(replay.decision, 'MATCH');
assert.equal(replay.loop_replayed, 1);
@@ -163,12 +165,12 @@ test('chat replay is partitioned by non-default tenant', () => {
assert.equal(res.success, true);
assert.equal(res.audit_verify.ok, true);
const alphaReplay = svc.replay('tenant-chat', '', 'alpha') as any;
const alphaReplay = svc.replay(alpha, 'tenant-chat') as any;
assert.equal(alphaReplay.ok, true);
assert.equal(alphaReplay.decision, 'MATCH');
assert.equal(alphaReplay.records, 1);
const betaReplay = svc.replay('tenant-chat', '', 'beta') as any;
const betaReplay = svc.replay({ ...viewer, actor: 'tenant-beta', tenant: 'beta' }, 'tenant-chat') as any;
assert.equal(betaReplay.ok, true);
assert.equal(betaReplay.records, 0);
});
@@ -2,6 +2,7 @@
"name": "@casan/control-panel-frontend",
"version": "1.0.0",
"private": true,
"license": "UNLICENSED",
"type": "module",
"description": "CASAN Ops Console \u2014 read-only React UI over the harness telemetry API.",
"scripts": {
@@ -12,6 +12,7 @@ import { Approvals } from './pages/Approvals';
import { CommandCenter } from './pages/CommandCenter';
import { Chat } from './pages/Chat';
import { Goals } from './pages/Goals';
import { EvidencePacks } from './pages/EvidencePacks';
export default function App() {
return (
@@ -29,6 +30,7 @@ export default function App() {
<Route path="/command" element={<CommandCenter />} />
<Route path="/chat" element={<Chat />} />
<Route path="/goals" element={<Goals />} />
<Route path="/evidence-packs" element={<EvidencePacks />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</AppLayout>
@@ -1,7 +1,7 @@
import type { ReactNode } from 'react';
import { NavLink } from 'react-router-dom';
type IconName = 'grid' | 'command' | 'chat' | 'goal' | 'runs' | 'shield' | 'governance' | 'incident' | 'trace' | 'coins' | 'approval' | 'settings';
type IconName = 'grid' | 'command' | 'chat' | 'goal' | 'runs' | 'shield' | 'governance' | 'incident' | 'trace' | 'evidence' | 'coins' | 'approval' | 'settings';
interface NavItem { to: string; label: string; icon: IconName; }
@@ -18,6 +18,7 @@ const NAVIGATION: Array<{ label: string; items: NavItem[] }> = [
{ to: '/security', label: 'Security', icon: 'shield' },
{ to: '/incidents', label: 'Incidents', icon: 'incident' },
{ to: '/traceability', label: 'Traceability', icon: 'trace' },
{ to: '/evidence-packs', label: 'Evidence packs', icon: 'evidence' },
] },
{ label: 'Control', items: [
{ to: '/finops', label: 'FinOps & SLO', icon: 'coins' },
@@ -37,6 +38,7 @@ function Icon({ name }: { name: IconName }) {
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" /></>,
evidence: <><path d="M7 3h7l3 3v15H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z" /><path d="M14 3v4h4M8.5 12h7M8.5 16h5" /><path d="m9 8.5 1 1 2-2" /></>,
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" /></>,
@@ -34,6 +34,25 @@ export interface HarnessRunRecord {
cost_estimate?: number;
}
export interface EvidenceIntegrity { ok: boolean; status: string; output: string }
export interface EvidencePackSummary {
run_id: string;
certified: boolean;
certification_reasons: string[];
pack_version: string;
created_at: string;
file_count: number;
signature: 'present' | 'unsigned';
integrity: EvidenceIntegrity;
}
export interface EvidencePackDetail extends EvidencePackSummary {
manifest_head: string | null;
run_summary: Record<string, unknown> | null;
decision_log: string | null;
reports: Record<string, Record<string, unknown>>;
files: Array<{ path: string; sha256: string; bytes: number }>;
}
export interface Overview extends Freshness {
totals: {
runs: number; total_cost: number; avg_latency_ms: number; failures: number;
@@ -419,6 +438,8 @@ export const api = {
traceability: () => get<Freshness & { matrix: any }>('traceability'),
cost: () => get<Freshness & { provider_tokens: number; provider_cost: number; by_provider: any[]; business_kpi: any }>('cost'),
command: () => get<CommandCenterState>('command'),
evidencePacks: () => get<{ root: string; packs: EvidencePackSummary[] }>('evidence-packs'),
evidencePack: (id: string) => get<EvidencePackDetail>(`evidence-packs/${encodeURIComponent(id)}`),
settings: (actor: SettingsActor) => getWithHeaders<SettingsState>('settings', actorHeaders(actor)),
setSetting: (actor: SettingsActor, body: { key: string; value: unknown; reason: string; approval?: string }) =>
post<{ key: string; setting: any; audit_verify: { ok: boolean; output: string } }>('settings', body, actorHeaders(actor)),
@@ -0,0 +1,62 @@
import { useQuery } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { api, type EvidencePackDetail, type EvidencePackSummary } from '../lib/api';
import { Card, StatusBadge } from '../components/ui/Card';
const HARNESSES = [
['H1', 'Context', 'h1-context-report.json'], ['H2', 'Tool', 'h2-tool-audit.json'],
['H3', 'Evaluation', 'h3-eval-scorecard.json'], ['H4', 'Security', 'h4-security-report.json'],
['H5', 'Governance', 'h5-audit-chain-proof.json'], ['H6', 'AgentOps', 'h6-cost-telemetry.json'],
['H7', 'Orchestration', 'h7-orchestration-report.json'],
] as const;
function shortHash(value: string | null) { return value ? `${value.slice(0, 12)}…${value.slice(-8)}` : 'not recorded'; }
function label(reason: string) { return reason.replaceAll('_', ' '); }
function statusFor(report: Record<string, unknown> | undefined, detail: EvidencePackDetail) {
if (!report) return 'not recorded';
if (report.chain_ok === false || report.audit_chain_ok === false || report.telemetry_ok === false) return 'failed';
if (Number(report.blocked ?? 0) > 0 || report.cost_spike_status === 'spike_detected') return 'attention';
return detail.integrity.ok ? 'recorded' : 'unverified';
}
function PackRow({ pack, selected, onSelect }: { pack: EvidencePackSummary; selected: boolean; onSelect: () => void }) {
return <button type="button" onClick={onSelect} className={`w-full rounded-xl border p-3.5 text-left transition ${selected ? 'border-indigo-300 bg-indigo-50/70 shadow-sm' : 'border-transparent hover:border-slate-200 hover:bg-slate-50'}`}>
<div className="flex items-start justify-between gap-2"><span className="font-mono text-xs font-semibold text-slate-700">{pack.run_id}</span><StatusBadge value={pack.certified ? 'certified' : 'uncertified'} /></div>
<div className="mt-2 flex items-center justify-between text-[11px] text-slate-500"><span>{new Date(pack.created_at).toLocaleString()}</span><span>{pack.file_count} files</span></div>
</button>;
}
function Detail({ pack }: { pack: EvidencePackDetail }) {
const reportEntries = Object.entries(pack.reports);
return <div className="space-y-5">
<section className={`overflow-hidden rounded-2xl border ${pack.certified ? 'border-emerald-200 bg-gradient-to-br from-emerald-50 to-white' : 'border-amber-200 bg-gradient-to-br from-amber-50 to-white'} p-5 sm:p-6`}>
<div className="flex flex-col justify-between gap-5 sm:flex-row sm:items-start">
<div><div className="text-[11px] font-bold uppercase tracking-[0.16em] text-slate-500">Evidence verdict</div><h1 className="mt-2 text-2xl font-semibold tracking-tight text-slate-900">{pack.certified ? 'Certified evidence pack' : 'Evidence captured — certification withheld'}</h1><p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600">Run <span className="font-mono text-xs">{pack.run_id}</span> is bound to a file manifest and verified independently before it is shown here.</p></div>
<div className="flex flex-wrap gap-2"><StatusBadge value={pack.integrity.ok ? 'manifest verified' : 'integrity failed'} /><StatusBadge value={pack.signature === 'present' ? 'signature present' : 'unsigned'} /></div>
</div>
{pack.certification_reasons.length > 0 && <div className="mt-5 rounded-xl border border-amber-200 bg-white/80 p-3.5"><div className="text-[11px] font-bold uppercase tracking-[0.13em] text-amber-700">Why certification is withheld</div><div className="mt-2 flex flex-wrap gap-2">{pack.certification_reasons.map((reason) => <span key={reason} className="rounded-full bg-amber-100 px-2.5 py-1 text-xs font-medium text-amber-800">{label(reason)}</span>)}</div></div>}
</section>
<Card title="H1–H7 chain of custody" right={<span className="font-mono text-[11px] text-slate-400">manifest {shortHash(pack.manifest_head)}</span>}>
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-7">{HARNESSES.map(([id, name, file]) => { const report = pack.reports[file]; const state = statusFor(report, pack); return <div key={id} className="relative rounded-xl border border-slate-200 bg-slate-50/70 p-3"><div className="flex items-center justify-between"><span className="font-mono text-xs font-bold text-indigo-700">{id}</span><span className={`h-2 w-2 rounded-full ${state === 'recorded' ? 'bg-emerald-500' : state === 'failed' ? 'bg-rose-500' : state === 'attention' ? 'bg-amber-500' : 'bg-slate-300'}`} /></div><div className="mt-2 text-sm font-semibold text-slate-800">{name}</div><div className="mt-1 text-[11px] text-slate-500">{state}</div></div>; })}</div>
</Card>
<div className="grid gap-5 xl:grid-cols-[1.1fr_0.9fr]">
<Card title="Gate reports"><div className="space-y-3">{reportEntries.map(([name, report]) => <details key={name} className="rounded-xl border border-slate-200 bg-slate-50/60 p-3.5"><summary className="cursor-pointer list-none text-sm font-semibold text-slate-700"><span className="font-mono text-xs text-indigo-700">{name.replace('-report.json', '').replace('.json', '')}</span></summary><dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 border-t border-slate-200 pt-3 text-xs">{Object.entries(report).filter(([, value]) => typeof value !== 'object').map(([key, value]) => <div key={key}><dt className="text-slate-400">{key.replaceAll('_', ' ')}</dt><dd className="mt-0.5 break-words font-medium text-slate-700">{String(value)}</dd></div>)}</dl></details>)}</div></Card>
<Card title="Evidence manifest" right={<span className="text-xs text-slate-400">{pack.files.length} bound files</span>}><div className="max-h-[430px] space-y-2 overflow-y-auto pr-1">{pack.files.map((file) => <div key={file.path} className="rounded-xl border border-slate-100 bg-slate-50/70 p-3"><div className="break-all font-mono text-xs font-medium text-slate-700">{file.path}</div><div className="mt-1.5 flex justify-between gap-3 font-mono text-[10px] text-slate-400"><span>{shortHash(file.sha256)}</span><span>{file.bytes.toLocaleString()} B</span></div></div>)}</div></Card>
</div>
<Card title="Decision log"><pre className="max-h-80 overflow-auto whitespace-pre-wrap rounded-xl bg-slate-950 p-4 font-mono text-xs leading-5 text-slate-200">{pack.decision_log ?? 'No decision log was recorded.'}</pre></Card>
</div>;
}
export function EvidencePacks() {
const { data, isLoading, isError } = useQuery({ queryKey: ['evidence-packs'], queryFn: api.evidencePacks });
const [selected, setSelected] = useState('');
const activeId = selected || data?.packs[0]?.run_id || '';
const detail = useQuery({ queryKey: ['evidence-pack', activeId], queryFn: () => api.evidencePack(activeId), enabled: Boolean(activeId) });
const heading = useMemo(() => data?.packs.length ?? 0, [data]);
if (isLoading) return <div className="text-slate-500">Loading evidence inventory…</div>;
if (isError || !data) return <div className="rounded-2xl border border-rose-200 bg-rose-50 p-5 text-rose-700">Evidence inventory could not be loaded.</div>;
if (!data.packs.length) return <Card title="Evidence packs"><div className="py-12 text-center"><div className="text-lg font-semibold text-slate-800">No evidence pack has been created yet</div><p className="mx-auto mt-2 max-w-lg text-sm leading-6 text-slate-500">Create one with <code className="rounded bg-slate-100 px-1.5 py-0.5 font-mono text-xs">casan pack &lt;run-id&gt;</code>; it will appear here only after the on-disk manifest is available.</p></div></Card>;
return <div className="space-y-5"><div><div className="text-[11px] font-bold uppercase tracking-[0.16em] text-indigo-600">Assurance workspace</div><h1 className="mt-1 text-2xl font-semibold tracking-tight text-slate-900">Evidence packs <span className="text-slate-400">({heading})</span></h1><p className="mt-1 text-sm text-slate-500">Inspect certification, provenance, gates and immutable file hashes without opening raw logs.</p></div><div className="grid gap-5 xl:grid-cols-[288px_minmax(0,1fr)]"><aside className="rounded-2xl border border-slate-200 bg-white p-2 shadow-sm"><div className="px-3 py-2 text-[11px] font-bold uppercase tracking-[0.13em] text-slate-400">Available packs</div><div className="space-y-1.5">{data.packs.map((pack) => <PackRow key={pack.run_id} pack={pack} selected={pack.run_id === activeId} onSelect={() => setSelected(pack.run_id)} />)}</div></aside><main>{detail.isLoading && <div className="text-slate-500">Verifying manifest…</div>}{detail.isError && <div className="rounded-xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-700">The selected evidence pack could not be read safely.</div>}{detail.data && <Detail pack={detail.data} />}</main></div></div>;
}
+12 -5
View File
@@ -19,8 +19,14 @@ Enterprise governed AI-SDLC console. Packages: `casan-enterprise`, `casan-govern
| Loop governance | Plan-17 loop primitives (`loop-*.py`) |
## Components still to build (NOT in this task)
- Governed Chat Console (Plan-18) · Prompt Mode Router · Model Provider Management
- Operator mode · Codegen mode · Agent/Skill Registry · policy-versioning UI
- Managed enterprise deployment with enterprise OIDC, CA, network policy and
production secret/KMS enforcement
- HA/DR, RPO/RTO, lifecycle compatibility, support/SLA and independent security review
- Production immutable object storage and external evidence verification operations
The Platform Preview already contains the Governed Chat/Prompt Router/Operator/
Codegen and agent/skill/model-selection MVPs, plus the Evidence Pack Viewer.
They are not evidence that an Enterprise edition has shipped.
## Why it refuses to package
Per the packaging principle, a level that isn't implemented must **fail clearly** rather
@@ -29,6 +35,7 @@ than emit a fake-complete artifact. Enterprise is `status: future` in
explanation. When the console is built, flip its status to `preview`/`implemented`.
## To implement later
Sequence: Plan-14 (RBAC console) → Plan-13 (Control Plane) → Plan-18 (Governed Chat
Console: read-only → operator → chat-as-loop → multi-tenant). Reuse the existing blocks
above instead of re-writing them.
Sequence: finish the paid-PoC evidence/Gitea flow → enterprise deployment and
operations controls → independent review → production readiness. Reuse the existing
blocks above instead of re-writing them. See
[`EDITION_FEATURE_LIMITATION_MATRIX.md`](../../docs/packaging/EDITION_FEATURE_LIMITATION_MATRIX.md).
@@ -7,6 +7,7 @@ set -uo pipefail
#
# Env:
# CASAN_CI_RUN_FRONTEND=0|1 default 1
# CASAN_CI_RUN_CONTROL_PANEL=0|1 default 1
# CASAN_CI_RUN_INFRA_LAB=0|1 default 0 (Docker Compose lab is optional in CI)
# CASAN_CI_STEP_TIMEOUT_SEC default 600
# CASAN_CI_SUITE_FILTER optional regex; run matching suite names only
@@ -188,6 +189,17 @@ else
skip "frontend-vitest (CASAN_CI_RUN_FRONTEND=0)"
fi
if [[ "${CASAN_CI_RUN_CONTROL_PANEL:-1}" == "1" ]]; then
if command -v npm >/dev/null 2>&1; then
run "control-panel-tests" npm run console:test
run "control-panel-build" npm run console:build
else
skip "control-panel-tests/build (npm unavailable)"
fi
else
skip "control-panel-tests/build (CASAN_CI_RUN_CONTROL_PANEL=0)"
fi
if [[ "${CASAN_CI_RUN_INFRA_LAB:-0}" == "1" ]]; then
if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
run "local-prod-infra-lab" bash "$TESTS/phase-prod-infra-lab-tests.sh"
@@ -267,6 +267,16 @@ def extract_patch(text: str) -> str:
return patch
def validate_write_output(text: str) -> str:
"""Fail at the producing harness when a write-intent reply is not a diff.
Keeping this separate from storage avoids creating an artifact before the
reviewer has completed, while ensuring H2/H3 accurately identify a model
that violated the required output contract.
"""
return extract_patch(text)
def validate_and_store_patch(job_path: str, job: dict, patch: str) -> dict:
roots = [str(value).strip("/") for value in job.get("workspace", {}).get("context_roots", [])]
changed = []
@@ -640,6 +650,14 @@ def run(job_path: str) -> int:
if not allowed:
emit(goal_id, "H4-security", "blocked", "Local worker output rejected")
raise ValueError("local_output_security_blocked")
if write_intent:
try:
validate_write_output(safe_local)
except ValueError as error:
reason = str(error)
stage(job_path, "local-worker", "error", reason, job.get("local_provider", ""), local_model)
emit(goal_id, "H2-tool", "error", "Local worker violated patch output contract", {"reason": reason})
raise
stage(job_path, "local-worker", "pass", "Primary solution prepared", job.get("local_provider", ""), local_model)
update_job(job_path, local_draft=safe_local, local_usage=local_meta)
emit(goal_id, "H2-tool", "pass", "Local solution prepared", {"provider": job.get("local_provider", ""), "model": local_model, **local_meta})
@@ -670,10 +688,23 @@ def run(job_path: str) -> int:
if not allowed:
emit(goal_id, "H4-security", "blocked", "Cloud reviewer output rejected")
raise ValueError("cloud_output_security_blocked")
stage(job_path, "cloud-reviewer", "pass", "Independent review incorporated", reviewer_provider, reviewer_model)
emit(goal_id, "H3-eval", "pass", "Independent review incorporated", {"provider": reviewer_provider, "model": reviewer_model, **cloud_meta})
final_status = "completed"
metric_status = "success"
if write_intent:
try:
validate_write_output(safe_result)
except ValueError as error:
cloud_ok = False
cloud_reason = f"reviewer_output_contract_invalid:{error}"
if cloud_ok:
stage(job_path, "cloud-reviewer", "pass", "Independent review incorporated", reviewer_provider, reviewer_model)
emit(goal_id, "H3-eval", "pass", "Independent review incorporated", {"provider": reviewer_provider, "model": reviewer_model, **cloud_meta})
final_status = "completed"
metric_status = "success"
else:
safe_result = safe_local
stage(job_path, "cloud-reviewer", "warning", cloud_reason, reviewer_provider, reviewer_model)
emit(goal_id, "H3-eval", "warning", "Cloud reviewer output contract rejected; local solution retained", {"reason": cloud_reason})
final_status = "degraded"
metric_status = "degraded"
else:
safe_result = safe_local
stage(job_path, "cloud-reviewer", "warning", cloud_reason, reviewer_provider, reviewer_model)
+6 -2
View File
@@ -3,7 +3,8 @@
> Status: **PREVIEW.** The AgentOps dashboard and Plan-13 Control Panel, including
> Command Center baseline and Plan-18 MVP-0/1 Chat Console plus MVP-2 agent
> selection and Operator loop/draft-hold foundation, exist today.
> Evidence/attack viewers, Gitea integration, and managed production rollout remain
> The Evidence Pack Viewer is now included. Attack Battery Viewer, Gitea evidence
> publishing, and managed production rollout remain
> planned. `package-release.sh platform` builds a clearly-stamped
> `casan-platform-preview-*` bundle containing only what exists.
@@ -17,11 +18,14 @@ Optional layer for teams that want UI / dashboard / visibility. Packages: `casan
| **Ops Console (Control Panel)** | ✅ **monitoring + governed settings + HITL + kill-switch + FinOps/SLO + Command Center + Chat MVP-0/1 + agent selection foundation + local-prod TLS/OIDC smoke** | `packages/casan-control-panel/` — NestJS API + React UI over harness telemetry, settings management, approval inbox, kill-switch, FinOps/SLO, Command Center, Ask CASAN read-only, registered Operator actions, and governed agent/skill selection (`npm run console:api` + `console:ui`). Includes Run History + verdicts + governance + security + incidents + traceability + FinOps + Approvals + role-aware Settings page + `/command` evidence-backed executive view + `/chat` governed read-only/operator console. |
| Management / settings writes | ✅ done+test | Plan-13 Track 2: wraps `control-plane-settings.py`, calls `rbac-check.py`, supports set/rollback/audit verify. |
| RBAC + approval inbox | ✅ local-prod done | Settings + kill-switch API RBAC enforcement, approval inbox/delegation/oversight, SoD, governed setting proposal apply, and local OIDC claim→role mapping smoke are done. Enterprise IdP rollout remains production follow-up. |
| Evidence Pack Viewer | 📋 planned | reads `docs/output/casan/evidence-packs/` |
| Evidence Pack Viewer | ✅ included | `/evidence-packs` verifies and presents the on-disk manifest, certification reasons, H1–H7 reports, hashes and decision log. |
| Attack Battery Viewer | 📋 planned | reads red-team corpus + H4 recall results |
| Read-only Ask CASAN + Operator/CODEGEN + agent selection + loop-hold + replay/widget/approvals + tenant hardening | ✅ MVP-0/1/2/3 done+test | Plan-18: `POST /api/v1/chat/ask`, `GET /api/v1/chat/actions`, `GET /api/v1/chat/agents`, `GET /api/v1/chat/replay`, Command Center `chat_loop` widget, chat escalation into `/approvals`, tenant-scoped replay, `/chat` + `/command`, backed by harness `chat-turn.py`, `chat-agent-resolver.py`, `chat-replay.py`, `approval-inbox.py`, `artifact-scan.sh`, `tenant-store.sh`, `tenant-crypt.sh`, and Plan-17 `loop-run.sh` |
| Gitea webhook integration | 📋 planned | trigger gate / publish evidence on push |
The authoritative customer-claim boundary is the
[`EDITION_FEATURE_LIMITATION_MATRIX.md`](../../docs/packaging/EDITION_FEATURE_LIMITATION_MATRIX.md).
## Build (preview)
```bash
scripts/package-release.sh platform # → dist/casan-platform-preview-vX.Y.Z.tar.gz