feat: add governed chat console

This commit is contained in:
thanhnv
2026-07-08 21:14:40 +09:00
parent 3be9970c15
commit 5561bcf864
28 changed files with 2023 additions and 43 deletions
@@ -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 { ChatAskInput, ChatService } from './chat.service.js';
@Controller('api/v1/chat')
export class ChatController {
constructor(@Inject(ChatService) private readonly svc: ChatService) {}
@Post('ask')
ask(@Headers() headers: Record<string, string | string[] | undefined>, @Body() body: ChatAskInput) {
return ok(this.svc.ask(body, actorFromHeaders(headers)));
}
@Get('audit/verify')
verifyAudit() {
return ok(this.svc.verifyAudit());
}
@Get('actions')
actions(@Headers() headers: Record<string, string | string[] | undefined>) {
return ok(this.svc.listActions(actorFromHeaders(headers)));
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ChatController } from './chat.controller.js';
import { ChatService } from './chat.service.js';
@Module({
controllers: [ChatController],
providers: [ChatService],
})
export class ChatModule {}
@@ -0,0 +1,114 @@
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 ChatAskInput {
message: string;
chatId?: string;
}
interface CommandResult {
status: number;
stdout: string;
stderr: string;
}
const HARNESS_BIN = join(APP_ROOT, 'packages', 'casan-harness', 'scripts', 'bash');
const CHAT_CLI = join(HARNESS_BIN, 'chat-turn.py');
const OPERATOR_CLI = join(HARNESS_BIN, 'chat-operator.py');
const RBAC_CLI = join(HARNESS_BIN, 'rbac-check.py');
function runPython(script: string, args: string[]): CommandResult {
try {
const stdout = execFileSync('python3', [script, ...args], {
cwd: APP_ROOT,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
env: process.env,
});
return { status: 0, stdout: stdout.trim(), stderr: '' };
} catch (err: any) {
return {
status: Number(err?.status ?? 1),
stdout: String(err?.stdout ?? '').trim(),
stderr: String(err?.stderr ?? '').trim(),
};
}
}
function parseJson<T>(raw: string): T | null {
if (!raw) return null;
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
@Injectable()
export class ChatService {
ask(input: ChatAskInput, actor: SettingsActor) {
if (!input.message || !input.message.trim()) {
throw new ForbiddenException('CHAT_DENY message required');
}
this.requireRead(actor);
const res = runPython(CHAT_CLI, [
'ask',
'--message',
input.message,
'--actor',
actor.actor,
'--chat-id',
input.chatId || 'chat-default',
'--tenant',
actor.tenant,
]);
const parsed = parseJson<Record<string, any>>(res.stdout);
if (parsed) {
return { ...parsed, actor, audit_verify: this.verifyAudit() };
}
if (res.status !== 0) {
throw new InternalServerErrorException(res.stderr || res.stdout || 'CHAT_CLI_FAILED');
}
throw new InternalServerErrorException('CHAT_CLI_EMPTY_RESPONSE');
}
verifyAudit() {
const res = runPython(CHAT_CLI, ['verify-audit']);
return { ok: res.status === 0, output: res.stdout || res.stderr };
}
listActions(actor: SettingsActor) {
this.requireRead(actor);
const res = runPython(OPERATOR_CLI, ['list-actions']);
const parsed = parseJson<Record<string, any>>(res.stdout);
if (parsed) return parsed;
throw new InternalServerErrorException(res.stderr || res.stdout || 'CHAT_OPERATOR_ACTIONS_FAILED');
}
private requireRead(actor: SettingsActor) {
const res = runPython(RBAC_CLI, [
'check',
'--role',
actor.role,
'--resource',
'monitoring',
'--action',
'read',
'--role-project',
actor.project,
'--target-project',
actor.project,
'--role-tenant',
actor.tenant,
'--target-tenant',
actor.tenant,
]);
if (res.status !== 0) {
throw new ForbiddenException(res.stderr || res.stdout || 'RBAC_DENY');
}
}
}