feat: add control panel

This commit is contained in:
thanhnv
2026-07-08 19:07:35 +09:00
parent a07b15e489
commit 3be9970c15
104 changed files with 3639 additions and 461 deletions
@@ -1,9 +1,12 @@
import { Module } from '@nestjs/common';
import { TelemetryModule } from './telemetry/telemetry.module.js';
import { HealthController } from './health/health.controller.js';
import { SettingsModule } from './settings/settings.module.js';
import { KillSwitchModule } from './kill-switch/kill-switch.module.js';
import { ApprovalsModule } from './approvals/approvals.module.js';
@Module({
imports: [TelemetryModule],
imports: [TelemetryModule, SettingsModule, KillSwitchModule, ApprovalsModule],
controllers: [HealthController],
})
export class AppModule {}
@@ -0,0 +1,24 @@
import { Body, Controller, Get, Headers, Inject, Post, Query } from '@nestjs/common';
import { ok } from '../common/api-response.js';
import { actorFromHeaders } from '../common/auth-context.js';
import { ApprovalDecision, ApprovalSubmit, ApprovalsService } from './approvals.service.js';
@Controller('api/v1/approvals')
export class ApprovalsController {
constructor(@Inject(ApprovalsService) private readonly svc: ApprovalsService) {}
@Get()
list(@Headers() headers: Record<string, string | string[] | undefined>, @Query('status') status?: string) {
return ok(this.svc.list(actorFromHeaders(headers), status || 'pending'));
}
@Post('submit')
submit(@Headers() headers: Record<string, string | string[] | undefined>, @Body() body: ApprovalSubmit) {
return ok(this.svc.submit(body, actorFromHeaders(headers)));
}
@Post('decide')
decide(@Headers() headers: Record<string, string | string[] | undefined>, @Body() body: ApprovalDecision) {
return ok(this.svc.decide(body, actorFromHeaders(headers)));
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ApprovalsController } from './approvals.controller.js';
import { ApprovalsService } from './approvals.service.js';
@Module({
controllers: [ApprovalsController],
providers: [ApprovalsService],
})
export class ApprovalsModule {}
@@ -0,0 +1,192 @@
import { ForbiddenException, Injectable, InternalServerErrorException } from '@nestjs/common';
import { execFileSync } from 'node:child_process';
import { join } from 'node:path';
import { APP_ROOT } from '../common/app-root.js';
import type { SettingsActor } from '../settings/settings.service.js';
export interface ApprovalSubmit {
action: string;
target: string;
risk?: string;
sensitive?: boolean;
reason: string;
payload?: Record<string, unknown>;
}
export interface ApprovalDecision {
id: string;
decision: 'approve' | 'reject';
reason: string;
}
const HARNESS_BIN = join(APP_ROOT, 'packages', 'casan-harness', 'scripts', 'bash');
const INBOX_CLI = join(HARNESS_BIN, 'approval-inbox.py');
const RBAC_CLI = join(HARNESS_BIN, 'rbac-check.py');
const CP_CLI = join(HARNESS_BIN, 'control-plane-settings.py');
interface CommandResult {
stdout: string;
stderr: string;
}
function runFile(command: string, args: string[], env?: NodeJS.ProcessEnv): CommandResult {
try {
const stdout = execFileSync(command, args, {
cwd: APP_ROOT,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, ...env },
});
return { stdout: stdout.trim(), stderr: '' };
} catch (err: any) {
const stderr = String(err?.stderr ?? '').trim();
const stdout = String(err?.stdout ?? '').trim();
const status = Number(err?.status ?? 1);
const e = new Error(stderr || stdout || `command failed: ${command}`);
(e as any).status = status;
(e as any).stderr = stderr;
(e as any).stdout = stdout;
throw e;
}
}
function parseJson<T>(raw: string, fallback: T): T {
if (!raw) return fallback;
try {
return JSON.parse(raw) as T;
} catch {
return fallback;
}
}
@Injectable()
export class ApprovalsService {
list(actor: SettingsActor, status = 'pending') {
this.requireRbac(actor, 'monitoring', 'read');
const res = runFile('python3', [INBOX_CLI, 'list', '--status', status]);
return { ...parseJson<Record<string, any>>(res.stdout, { count: 0, proposals: [], oversight: [] }), audit_verify: this.verifyAudit() };
}
submit(input: ApprovalSubmit, actor: SettingsActor) {
if (!input.action || !input.target || !input.reason) {
throw new ForbiddenException('APPROVAL_SUBMIT_DENY action/target/reason required');
}
if (input.action.startsWith('settings.')) {
this.requireRbac(actor, 'settings', 'write');
} else {
this.requireRbac(actor, 'monitoring', 'read');
}
const args = [
INBOX_CLI,
'submit',
'--project',
actor.project,
'--action',
input.action,
'--target',
input.target,
'--risk',
input.risk ?? 'standard',
'--proposer',
actor.actor,
'--reason',
input.reason,
'--payload',
JSON.stringify(input.payload ?? {}),
];
if (input.sensitive) args.push('--sensitive');
const res = runFile('python3', args);
return { proposal: parseJson<Record<string, any>>(res.stdout, {}), audit_verify: this.verifyAudit() };
}
decide(input: ApprovalDecision, actor: SettingsActor) {
if (!input.id || !input.decision || !input.reason) {
throw new ForbiddenException('APPROVAL_DECIDE_DENY id/decision/reason required');
}
this.requireRbac(actor, 'approval', 'grant');
try {
const res = runFile('python3', [
INBOX_CLI,
'decide',
'--id',
input.id,
'--decision',
input.decision,
'--approver',
actor.actor,
'--reason',
input.reason,
]);
const proposal = parseJson<Record<string, any>>(res.stdout, {});
const applied = input.decision === 'approve' ? this.applyApprovedProposal(proposal, actor) : null;
return { proposal, applied, audit_verify: this.verifyAudit() };
} catch (err: any) {
if (Number(err.status) === 3 || Number(err.status) === 1) {
throw new ForbiddenException(err.stderr || err.message);
}
throw new InternalServerErrorException(err.stderr || err.message);
}
}
private applyApprovedProposal(proposal: Record<string, any>, actor: SettingsActor) {
if (proposal.action !== 'settings.write') return null;
const key = proposal.payload?.key;
if (!key || proposal.payload?.value === undefined) {
throw new ForbiddenException('APPROVAL_APPLY_DENY settings proposal missing key/value');
}
try {
const res = runFile('python3', [
CP_CLI,
'set',
String(key),
JSON.stringify(proposal.payload.value),
'--actor',
String(proposal.proposer ?? actor.actor),
'--reason',
`approved:${proposal.id}:${proposal.decision_reason ?? ''}`,
'--approval',
`inbox:${proposal.id}:${actor.actor}`,
]);
return parseJson<Record<string, any>>(res.stdout, {});
} catch (err: any) {
if (Number(err.status) === 2 || Number(err.status) === 3) {
throw new ForbiddenException(err.stderr || err.message);
}
throw new InternalServerErrorException(err.stderr || err.message);
}
}
private requireRbac(actor: SettingsActor, resource: string, action: string) {
try {
runFile('python3', [
RBAC_CLI,
'check',
'--role',
actor.role,
'--resource',
resource,
'--action',
action,
'--role-project',
actor.project,
'--target-project',
actor.project,
'--role-tenant',
actor.tenant,
'--target-tenant',
actor.tenant,
]);
} catch (err: any) {
throw new ForbiddenException(err.stderr || err.message || 'RBAC_DENY');
}
}
private verifyAudit() {
try {
const res = runFile('python3', [INBOX_CLI, 'verify-audit']);
return { ok: true, output: res.stdout };
} catch (err: any) {
return { ok: false, output: err.stderr || err.stdout || err.message };
}
}
}
@@ -46,6 +46,10 @@ export const PATHS = {
drift: env('CASAN_CP_DRIFT', 'docs/output/casan/level5-evidence/09-drift-report.json'),
businessKpi: env('CASAN_CP_KPI', 'docs/output/casan/level5-evidence/14-business-kpi-report.json'),
benignFp: env('CASAN_CP_BENIGN_FP', 'docs/output/casan/benign-fp-report.json'),
scoringReport: env('CASAN_CP_SCORING_REPORT', 'docs/output/casan/phase3-real-run-scoring.md'),
approvalInbox: env('CASAN_CP_APPROVAL_INBOX', '.specify/level5/approval-inbox.json'),
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'),
};
export const STALE_AFTER_S = Number(process.env.CASAN_DASHBOARD_STALE_S ?? 3600);
@@ -0,0 +1,51 @@
import { execFileSync } from 'node:child_process';
import { join } from 'node:path';
import { APP_ROOT } from './app-root.js';
import type { SettingsActor } from '../settings/settings.service.js';
const RBAC_CLI = join(APP_ROOT, 'packages', 'casan-harness', 'scripts', 'bash', 'rbac-check.py');
const LOCAL_ROLES = new Set(['org-admin', 'project-admin', 'approver', 'operator', 'viewer', 'auditor']);
function firstHeader(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
function mapClaim(claim: string): string | null {
if (LOCAL_ROLES.has(claim)) return claim;
try {
return execFileSync('python3', [RBAC_CLI, 'map-claim', '--claim', claim], {
cwd: APP_ROOT,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
} catch {
return null;
}
}
function roleFromClaim(raw: string | undefined): string {
if (!raw) return 'viewer';
const claims = raw.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
for (const claim of claims) {
const mapped = mapClaim(claim);
if (mapped) return mapped;
}
return 'viewer';
}
export function actorFromHeaders(headers: Record<string, string | string[] | undefined>): SettingsActor {
const actor = firstHeader(headers['x-casan-actor'])
|| firstHeader(headers['x-auth-request-user'])
|| firstHeader(headers['x-forwarded-user'])
|| 'local-operator';
const roleClaim = firstHeader(headers['x-casan-role'])
|| firstHeader(headers['x-casan-groups'])
|| firstHeader(headers['x-auth-request-groups'])
|| firstHeader(headers['x-forwarded-groups']);
return {
actor,
role: roleFromClaim(roleClaim),
project: firstHeader(headers['x-casan-project']) || 'default',
tenant: firstHeader(headers['x-casan-tenant']) || 'default',
};
}
@@ -0,0 +1,24 @@
import { Body, Controller, Get, Headers, Inject, Post } from '@nestjs/common';
import { ok } from '../common/api-response.js';
import { actorFromHeaders } from '../common/auth-context.js';
import { KillSwitchMutation, KillSwitchService } from './kill-switch.service.js';
@Controller('api/v1/kill-switch')
export class KillSwitchController {
constructor(@Inject(KillSwitchService) private readonly svc: KillSwitchService) {}
@Get()
status(@Headers() headers: Record<string, string | string[] | undefined>) {
return ok(this.svc.status(actorFromHeaders(headers)));
}
@Post('engage')
engage(@Headers() headers: Record<string, string | string[] | undefined>, @Body() body: KillSwitchMutation) {
return ok(this.svc.engage(body, actorFromHeaders(headers)));
}
@Post('clear')
clear(@Headers() headers: Record<string, string | string[] | undefined>, @Body() body: KillSwitchMutation) {
return ok(this.svc.clear(body, actorFromHeaders(headers)));
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { KillSwitchController } from './kill-switch.controller.js';
import { KillSwitchService } from './kill-switch.service.js';
@Module({
controllers: [KillSwitchController],
providers: [KillSwitchService],
})
export class KillSwitchModule {}
@@ -0,0 +1,122 @@
import { ForbiddenException, Injectable, InternalServerErrorException } from '@nestjs/common';
import { execFileSync } from 'node:child_process';
import { join } from 'node:path';
import { APP_ROOT } from '../common/app-root.js';
import type { SettingsActor } from '../settings/settings.service.js';
export interface KillSwitchMutation {
scope: string;
id: string;
reason: string;
}
const HARNESS_BIN = join(APP_ROOT, 'packages', 'casan-harness', 'scripts', 'bash');
const KS_CLI = join(HARNESS_BIN, 'kill-switch.sh');
const RBAC_CLI = join(HARNESS_BIN, 'rbac-check.py');
const SCOPES = new Set(['project', 'model', 'provider', 'tenant', 'global']);
interface CommandResult {
stdout: string;
stderr: string;
}
function runFile(command: string, args: string[], env?: NodeJS.ProcessEnv): CommandResult {
try {
const stdout = execFileSync(command, args, {
cwd: APP_ROOT,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, ...env },
});
return { stdout: stdout.trim(), stderr: '' };
} catch (err: any) {
const stderr = String(err?.stderr ?? '').trim();
const stdout = String(err?.stdout ?? '').trim();
const status = Number(err?.status ?? 1);
const e = new Error(stderr || stdout || `command failed: ${command}`);
(e as any).status = status;
(e as any).stderr = stderr;
(e as any).stdout = stdout;
throw e;
}
}
function parseStatus(raw: string) {
const engaged: any[] = [];
let count = 0;
for (const line of raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean)) {
if (line.startsWith('{')) {
try {
engaged.push(JSON.parse(line));
} catch {
engaged.push({ raw: line });
}
continue;
}
const m = line.match(/^KILL_SWITCH_STATUS engaged=(\d+)/);
if (m) count = Number(m[1]);
}
return { count, engaged };
}
@Injectable()
export class KillSwitchService {
status(actor: SettingsActor) {
this.requireRbac(actor, 'monitoring', 'read');
const res = runFile('bash', [KS_CLI, 'status'], { CASAN_ACTOR: actor.actor });
return parseStatus(res.stdout);
}
engage(input: KillSwitchMutation, actor: SettingsActor) {
this.validate(input);
this.requireRbac(actor, 'kill_switch', 'engage');
const res = runFile('bash', [KS_CLI, 'engage', input.scope, input.id, input.reason], { CASAN_ACTOR: actor.actor });
return { output: res.stdout, status: this.status(actor) };
}
clear(input: KillSwitchMutation, actor: SettingsActor) {
this.validate(input);
this.requireRbac(actor, 'kill_switch', 'clear');
try {
const res = runFile('bash', [KS_CLI, 'clear', input.scope, input.id, input.reason], { CASAN_ACTOR: actor.actor });
return { output: res.stdout, status: this.status(actor) };
} catch (err: any) {
if (Number(err.status) === 3) throw new ForbiddenException(err.stderr || 'KILL_SWITCH_CLEAR_APPROVAL_REQUIRED');
throw new InternalServerErrorException(err.stderr || err.message);
}
}
private validate(input: KillSwitchMutation) {
if (!input.scope || !input.id || !input.reason) {
throw new ForbiddenException('KILL_SWITCH_DENY scope/id/reason required');
}
if (!SCOPES.has(input.scope)) {
throw new ForbiddenException(`KILL_SWITCH_DENY invalid scope ${input.scope}`);
}
}
private requireRbac(actor: SettingsActor, resource: string, action: string) {
try {
runFile('python3', [
RBAC_CLI,
'check',
'--role',
actor.role,
'--resource',
resource,
'--action',
action,
'--role-project',
actor.project,
'--target-project',
actor.project,
'--role-tenant',
actor.tenant,
'--target-tenant',
actor.tenant,
]);
} catch (err: any) {
throw new ForbiddenException(err.stderr || err.message || 'RBAC_DENY');
}
}
}
@@ -4,9 +4,9 @@ import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module.js';
import { APP_ROOT } from './common/app-root.js';
// Read-only Ops Console API (Plan-13 Track 1). Binds loopback by default and refuses a
// non-loopback bind under CASAN_PROFILE=prod / CASAN_CP_STRICT=1 — same posture as
// dashboard-server.py. Management/auth are out of scope (future tracks).
// Ops Console API (Plan-13). Binds loopback by default and refuses a non-loopback
// bind under CASAN_PROFILE=prod / CASAN_CP_STRICT=1 unless an authenticated reverse
// proxy is explicitly configured to overwrite identity headers.
async function bootstrap() {
const app = await NestFactory.create(AppModule, { cors: true });
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
@@ -14,9 +14,10 @@ async function bootstrap() {
const port = Number(process.env.CP_PORT ?? 3010);
let host = process.env.CP_BIND ?? '127.0.0.1';
const strict = process.env.CASAN_PROFILE === 'prod' || process.env.CASAN_CP_STRICT === '1';
if (strict && host !== '127.0.0.1' && host !== 'localhost') {
// read-only console must not expose telemetry off-loopback without the prod hardening
// (TLS/OIDC) that is Track 4 — fail closed.
const authProxy = process.env.CASAN_CP_TRUST_AUTH_PROXY === '1';
if (strict && host !== '127.0.0.1' && host !== 'localhost' && !authProxy) {
// The console must not expose telemetry/management off-loopback without TLS/OIDC
// at the reverse proxy, which must overwrite X-CASAN-* identity headers.
// eslint-disable-next-line no-console
console.error(`CP_REFUSE_NONLOOPBACK host=${host} (set up TLS/OIDC per Plan-13 Track 4 first)`);
process.exit(2);
@@ -24,6 +25,6 @@ async function bootstrap() {
await app.listen(port, host);
// eslint-disable-next-line no-console
console.log(`CASAN Ops Console API (read-only) http://${host}:${port}/api/v1 app_root=${APP_ROOT}`);
console.log(`CASAN Ops Console API http://${host}:${port}/api/v1 app_root=${APP_ROOT}`);
}
bootstrap();
@@ -0,0 +1,24 @@
import { Body, Controller, Get, Headers, Inject, Post } from '@nestjs/common';
import { ok } from '../common/api-response.js';
import { actorFromHeaders } from '../common/auth-context.js';
import { SettingMutation, SettingsService } from './settings.service.js';
@Controller('api/v1/settings')
export class SettingsController {
constructor(@Inject(SettingsService) private readonly svc: SettingsService) {}
@Get()
list(@Headers() headers: Record<string, string | string[] | undefined>) {
return ok(this.svc.list(actorFromHeaders(headers)));
}
@Post()
set(@Headers() headers: Record<string, string | string[] | undefined>, @Body() body: SettingMutation) {
return ok(this.svc.set(body, actorFromHeaders(headers)));
}
@Post('rollback')
rollback(@Headers() headers: Record<string, string | string[] | undefined>, @Body() body: SettingMutation) {
return ok(this.svc.rollback(body, actorFromHeaders(headers)));
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { SettingsController } from './settings.controller.js';
import { SettingsService } from './settings.service.js';
@Module({
controllers: [SettingsController],
providers: [SettingsService],
})
export class SettingsModule {}
@@ -0,0 +1,175 @@
import { ForbiddenException, Injectable, InternalServerErrorException } from '@nestjs/common';
import { execFileSync } from 'node:child_process';
import { join } from 'node:path';
import { APP_ROOT } from '../common/app-root.js';
export interface SettingsActor {
actor: string;
role: string;
project: string;
tenant: string;
}
export interface SettingMutation {
key: string;
value?: unknown;
reason: string;
approval?: string;
}
interface CommandResult {
stdout: string;
stderr: string;
}
const HARNESS_BIN = join(APP_ROOT, 'packages', 'casan-harness', 'scripts', 'bash');
const CP_CLI = join(HARNESS_BIN, 'control-plane-settings.py');
const RBAC_CLI = join(HARNESS_BIN, 'rbac-check.py');
function runPython(script: string, args: string[], env?: NodeJS.ProcessEnv): CommandResult {
try {
const stdout = execFileSync('python3', [script, ...args], {
cwd: APP_ROOT,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, ...env },
});
return { stdout: stdout.trim(), stderr: '' };
} catch (err: any) {
const stderr = String(err?.stderr ?? '').trim();
const stdout = String(err?.stdout ?? '').trim();
const status = Number(err?.status ?? 1);
const e = new Error(stderr || stdout || `command failed: ${script}`);
(e as any).status = status;
(e as any).stderr = stderr;
(e as any).stdout = stdout;
throw e;
}
}
function parseJson<T>(raw: string, fallback: T): T {
if (!raw) return fallback;
try {
return JSON.parse(raw) as T;
} catch {
return fallback;
}
}
@Injectable()
export class SettingsService {
list(actor: SettingsActor) {
this.requireRbac(actor, 'read', false);
const policy = parseJson<Record<string, any>>(runPython(CP_CLI, ['list-policy']).stdout, {});
const settings = parseJson<Record<string, any>>(runPython(CP_CLI, ['get-all']).stdout, {});
const audit = parseJson<any[]>(runPython(CP_CLI, ['get-audit']).stdout, []);
const auditVerify = this.verifyAudit();
return {
actor,
capabilities: {
can_write_standard: this.canRbac(actor, 'write', false),
can_write_sensitive: this.canRbac(actor, 'write', true),
can_rollback: this.canRbac(actor, 'write', false),
},
policy,
settings,
audit: audit.slice(-20).reverse(),
audit_verify: auditVerify,
};
}
set(input: SettingMutation, actor: SettingsActor) {
if (!input.key || input.value === undefined || !input.reason) {
throw new ForbiddenException('SETTINGS_DENY key/value/reason required');
}
const sensitive = this.isSensitive(input.key);
this.requireRbac(actor, 'write', sensitive);
try {
const res = runPython(CP_CLI, [
'set',
input.key,
JSON.stringify(input.value),
'--actor',
actor.actor,
'--reason',
input.reason,
'--approval',
input.approval ?? '',
]);
return { key: input.key, setting: parseJson<Record<string, any>>(res.stdout, {}), audit_verify: this.verifyAudit() };
} catch (err: any) {
if (Number(err.status) === 3) throw new ForbiddenException(err.stderr || 'APPROVAL_REQUIRED');
if (Number(err.status) === 2) throw new ForbiddenException(err.stderr || 'SETTING_NOT_ALLOWED');
throw new InternalServerErrorException(err.stderr || err.message);
}
}
rollback(input: SettingMutation, actor: SettingsActor) {
if (!input.key || !input.reason) {
throw new ForbiddenException('SETTINGS_DENY key/reason required');
}
const sensitive = this.isSensitive(input.key);
this.requireRbac(actor, 'write', sensitive);
try {
const res = runPython(CP_CLI, ['rollback', input.key, '--actor', actor.actor, '--reason', input.reason]);
return { key: input.key, setting: parseJson<Record<string, any>>(res.stdout, {}), audit_verify: this.verifyAudit() };
} catch (err: any) {
if (Number(err.status) === 4) throw new ForbiddenException(err.stderr || 'NO_PRIOR_VERSION');
throw new InternalServerErrorException(err.stderr || err.message);
}
}
private isSensitive(key: string): boolean {
const policy = parseJson<Record<string, any>>(runPython(CP_CLI, ['list-policy']).stdout, {});
return Boolean(policy[key]?.securitySensitive);
}
private canRbac(actor: SettingsActor, action: 'read' | 'write', sensitive: boolean): boolean {
try {
this.checkRbac(actor, action, sensitive);
return true;
} catch {
return false;
}
}
private requireRbac(actor: SettingsActor, action: 'read' | 'write', sensitive: boolean) {
try {
this.checkRbac(actor, action, sensitive);
} catch (err: any) {
throw new ForbiddenException(err.stderr || err.message || 'RBAC_DENY');
}
}
private checkRbac(actor: SettingsActor, action: 'read' | 'write', sensitive: boolean) {
const args = [
'check',
'--role',
actor.role,
'--resource',
'settings',
'--action',
action,
'--role-project',
actor.project,
'--target-project',
actor.project,
'--role-tenant',
actor.tenant,
'--target-tenant',
actor.tenant,
];
if (sensitive) args.push('--sensitive');
return runPython(RBAC_CLI, args);
}
private verifyAudit() {
try {
const res = runPython(CP_CLI, ['verify-audit']);
return { ok: true, output: res.stdout };
} catch (err: any) {
return { ok: false, output: err.stderr || err.stdout || err.message };
}
}
}
@@ -61,4 +61,9 @@ export class TelemetryController {
cost() {
return ok(this.svc.cost());
}
@Get('command')
command() {
return ok(this.svc.commandCenter());
}
}
@@ -1,8 +1,10 @@
// Read-only aggregations over CASAN harness telemetry. Formulas mirror
// packages/casan-harness/tests/generate-agentops-dashboard.py; all numbers come from real
// on-disk feeds. Missing feeds degrade to zero/empty + a `stale` flag — never fabricated.
import { existsSync, readFileSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
import { Injectable } from '@nestjs/common';
import { PATHS, isStale, metricsAgeSeconds, STALE_AFTER_S } from '../common/app-root.js';
import { APP_ROOT, PATHS, isStale, metricsAgeSeconds, STALE_AFTER_S } from '../common/app-root.js';
import { readJsonl, readJson, readHead, readTrace } from './telemetry.reader.js';
type Row = Record<string, any>;
@@ -10,6 +12,46 @@ const num = (v: any) => (typeof v === 'number' && isFinite(v) ? v : 0);
const sum = (rows: Row[], k: string) => rows.reduce((a, r) => a + num(r[k]), 0);
const count = (rows: Row[], pred: (r: Row) => boolean) => rows.reduce((a, r) => a + (pred(r) ? 1 : 0), 0);
const recent = (rows: Row[], n: number) => rows.slice(-n).reverse();
const arr = (v: any): any[] => (Array.isArray(v) ? v : []);
const pct = (part: number, total: number) => (total > 0 ? Math.round((part / total) * 1000) / 10 : 0);
function artifact(path: string, source: string, verifiedWhenPresent = true) {
const present = existsSync(path);
const runAt = present ? statSync(path).mtime.toISOString() : null;
return {
source,
artifact_path: relative(APP_ROOT, path) || '.',
commit: gitCommit(),
run_at: runAt,
verified: present && verifiedWhenPresent,
status: present ? (verifiedWhenPresent ? 'verified' : 'present_unverified') : 'missing',
};
}
function gitCommit(): string | null {
try {
const head = readFileSync(join(APP_ROOT, '.git', 'HEAD'), 'utf8').trim();
if (/^[0-9a-f]{40}$/i.test(head)) return head;
const ref = head.match(/^ref:\s+(.+)$/)?.[1];
if (!ref) return null;
const value = readFileSync(join(APP_ROOT, '.git', ref), 'utf8').trim();
return /^[0-9a-f]{40}$/i.test(value) ? value : null;
} catch {
return null;
}
}
function widget(
id: string,
title: string,
status: string,
summary: string,
metrics: Record<string, any>,
envelope: ReturnType<typeof artifact>,
evidence: Record<string, any> = {},
) {
return { id, title, status, summary, metrics, envelope, evidence };
}
@Injectable()
export class TelemetryService {
@@ -134,4 +176,163 @@ export class TelemetryService {
business_kpi: readJson(PATHS.businessKpi),
};
}
commandCenter() {
const metrics = readJsonl(PATHS.metrics);
const provider = readJsonl(PATHS.providerUsage);
const audit = readJsonl(PATHS.audit);
const security = readJsonl(PATHS.security);
const incidents = readJsonl(PATHS.incidents);
const actionGate = readJsonl(PATHS.actionGate);
const traceability = readJson(PATHS.traceability) as any;
const kpi = readJson(PATHS.businessKpi) as any;
const drift = readJson(PATHS.drift) as any;
const approvalStore = (readJson(PATHS.approvalInbox) as any) ?? { proposals: [], oversight: [] };
const proposals = arr(approvalStore.proposals);
const oversight = arr(approvalStore.oversight);
const head = readHead(PATHS.auditHead);
const passedReq = num(traceability?.summary?.passed);
const failedReq = num(traceability?.summary?.failed);
const totalReq = num(traceability?.summary?.requirements) || passedReq + failedReq;
const blockedSecurity = count(security, (s) => ['blocked', 'BLOCK', 'DENY'].includes(String(s.status ?? s.decision)));
const pendingApprovals = count(proposals, (p) => p.status === 'pending');
const approvedApprovals = count(proposals, (p) => p.status === 'approved' || p.status === 'auto_allowed');
const killSwitchScopes = [...new Set(incidents.filter((i) => i.action === 'kill_switch_engaged').map((i) => String(i.scope ?? 'unknown')))];
const kpis = arr(kpi?.kpis);
const kpisMet = count(kpis, (k) => k.target_met === true);
const auditVerified = audit.length > 0 && Boolean(head);
const selfImproveRelated = proposals.filter((p) => String(p.action ?? p.type ?? '').includes('improve'));
const widgets = {
maturity: widget(
'maturity',
'Maturity gauge + H1-H7 radar',
metrics.length || audit.length || security.length ? 'ok' : 'no_data',
`${metrics.length} runs, ${audit.length} governance records, ${security.length} security verdicts`,
{
runs: metrics.length,
governance_records: audit.length,
security_verdicts: security.length,
action_blocks: count(actionGate, (a) => a.outcome === 'BLOCK'),
drift_report: drift ? 'present' : 'missing',
},
artifact(PATHS.scoringReport, 'phase3-real-run-scoring.md'),
{ harness_signals: this.overview().harness_signals },
),
hitl: widget(
'hitl',
'Human-in-the-loop panel',
pendingApprovals > 0 ? 'warn' : (proposals.length > 0 ? 'ok' : 'no_data'),
`${pendingApprovals} pending, ${approvedApprovals} approved/auto-allowed`,
{
pending: pendingApprovals,
approved_or_auto: approvedApprovals,
rejected: count(proposals, (p) => p.status === 'rejected'),
oversight_events: oversight.length,
},
artifact(PATHS.approvalInbox, 'approval-inbox.json', oversight.length === 0 || Boolean(oversight[oversight.length - 1]?.hash)),
{ recent_oversight: recent(oversight, 5) },
),
kill_switch: widget(
'kill_switch',
'Kill-switch and guardrails',
killSwitchScopes.length > 0 ? 'fail' : 'ok',
killSwitchScopes.length ? `${killSwitchScopes.length} engaged scope(s)` : 'No active kill-switch incident in telemetry',
{
engaged_scopes: killSwitchScopes,
incidents: incidents.length,
critical: count(incidents, (i) => i.severity === 'CRIT'),
},
artifact(PATHS.incidents, 'incidents.jsonl'),
{ recent_incidents: recent(incidents, 5) },
),
traceability: widget(
'traceability',
'Traceability Sankey',
failedReq > 0 ? 'fail' : (totalReq > 0 ? 'ok' : 'no_data'),
totalReq > 0 ? `${passedReq}/${totalReq} requirements pass` : 'No traceability artifact found',
{
requirements: totalReq,
passed: passedReq,
failed: failedReq,
coverage_pct: pct(passedReq, totalReq),
},
artifact(PATHS.traceability, 'traceability-matrix.json'),
{ summary: traceability?.summary ?? null },
),
security: widget(
'security',
'Security posture',
blockedSecurity > 0 ? 'ok' : (security.length > 0 ? 'warn' : 'no_data'),
`${blockedSecurity}/${security.length} security verdicts blocked`,
{
verdicts: security.length,
blocked: blockedSecurity,
block_rate_pct: pct(blockedSecurity, security.length),
},
artifact(PATHS.security, 'security.jsonl'),
{ recent_security: recent(security, 5) },
),
finops: widget(
'finops',
'Token economy / FinOps',
provider.length > 0 || kpis.length > 0 ? 'ok' : 'no_data',
`$${sum(provider, 'cost_usd').toFixed(4)} provider cost, ${sum(provider, 'total_tokens')} tokens`,
{
provider_cost: sum(provider, 'cost_usd'),
provider_tokens: sum(provider, 'total_tokens'),
kpis: kpis.length,
kpis_met: kpisMet,
},
artifact(PATHS.providerUsage, 'provider-usage.jsonl'),
{ business_kpi: { status: kpi?.status ?? null, kpis_met: kpisMet, kpis: kpis.length } },
),
certification: widget(
'certification',
'Certified-run seal',
auditVerified ? 'ok' : 'warn',
auditVerified ? `Audit head present with ${audit.length} record(s)` : 'Audit chain head missing or empty',
{
audit_records: audit.length,
audit_head_present: Boolean(head),
last_decision: audit.length ? audit[audit.length - 1].decision ?? null : null,
},
artifact(PATHS.auditHead, 'audit-head.txt', auditVerified),
{ audit_head: head, recent_audit: recent(audit, 5) },
),
self_improve: widget(
'self_improve',
'Self-improve pipeline',
selfImproveRelated.length > 0 ? 'warn' : (existsSync(PATHS.selfImprove) ? 'ok' : 'no_data'),
selfImproveRelated.length ? `${selfImproveRelated.length} self-improve proposal(s) in inbox` : 'Core self-improve primitive is present; no proposal artifact queued',
{
inbox_related: selfImproveRelated.length,
primitive_present: existsSync(PATHS.selfImprove),
},
artifact(PATHS.selfImprove, 'self-improve.py'),
{ proposals: recent(selfImproveRelated, 5) },
),
};
const ticker = [
...recent(oversight, 10).map((e) => ({ at: e.at ?? e.created_at ?? null, kind: 'oversight', text: `${e.event ?? 'oversight'} ${e.proposal_id ?? ''}`.trim(), status: e.status ?? 'event' })),
...recent(incidents, 10).map((e) => ({ at: e.timestamp ?? null, kind: 'incident', text: `${e.event ?? 'incident'} ${e.scope ?? ''}`.trim(), status: e.severity ?? 'event' })),
...recent(audit, 10).map((e) => ({ at: e.timestamp ?? null, kind: 'audit', text: `${e.action ?? 'action'} ${e.decision ?? ''}`.trim(), status: e.decision ?? 'event' })),
].sort((a, b) => String(b.at ?? '').localeCompare(String(a.at ?? ''))).slice(0, 20);
return {
...this.freshness(),
generated_at: new Date().toISOString(),
widgets,
briefing: [
{ label: 'Maturity', value: widgets.maturity.summary, status: widgets.maturity.status, widget: 'maturity' },
{ label: 'Control', value: widgets.hitl.summary, status: widgets.hitl.status, widget: 'hitl' },
{ label: 'Safety', value: widgets.security.summary, status: widgets.security.status, widget: 'security' },
{ label: 'Economy', value: widgets.finops.summary, status: widgets.finops.status, widget: 'finops' },
{ label: 'Certification', value: widgets.certification.summary, status: widgets.certification.status, widget: 'certification' },
],
ticker,
};
}
}