feat: prepare CASAN paid PoC release package
This commit is contained in:
@@ -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 <run-id></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>;
|
||||
}
|
||||
Reference in New Issue
Block a user