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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user