feat: escalate chat approvals through inbox
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import { ForbiddenException, Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { APP_ROOT } from '../common/app-root.js';
|
||||
import type { SettingsActor } from '../settings/settings.service.js';
|
||||
@@ -17,10 +19,12 @@ export interface ApprovalDecision {
|
||||
id: string;
|
||||
decision: 'approve' | 'reject';
|
||||
reason: string;
|
||||
approvalJwt?: string;
|
||||
}
|
||||
|
||||
const HARNESS_BIN = join(APP_ROOT, 'packages', 'casan-harness', 'scripts', 'bash');
|
||||
const INBOX_CLI = join(HARNESS_BIN, 'approval-inbox.py');
|
||||
const APPROVAL_VERIFY = join(HARNESS_BIN, 'approval-verify.sh');
|
||||
const RBAC_CLI = join(HARNESS_BIN, 'rbac-check.py');
|
||||
const CP_CLI = join(HARNESS_BIN, 'control-plane-settings.py');
|
||||
|
||||
@@ -59,6 +63,16 @@ function parseJson<T>(raw: string, fallback: T): T {
|
||||
}
|
||||
}
|
||||
|
||||
function stableJson(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
|
||||
if (value && typeof value === 'object') {
|
||||
const obj = value as Record<string, unknown>;
|
||||
return `{${Object.keys(obj).sort().map((k) => `${JSON.stringify(k)}:${stableJson(obj[k])}`).join(',')}}`;
|
||||
}
|
||||
if (value === undefined) return 'null';
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ApprovalsService {
|
||||
list(actor: SettingsActor, status = 'pending') {
|
||||
@@ -105,6 +119,8 @@ export class ApprovalsService {
|
||||
}
|
||||
this.requireRbac(actor, 'approval', 'grant');
|
||||
try {
|
||||
const pending = this.findProposal(input.id);
|
||||
this.verifyApprovalIdentity(input, actor, pending);
|
||||
const res = runFile('python3', [
|
||||
INBOX_CLI,
|
||||
'decide',
|
||||
@@ -121,6 +137,7 @@ export class ApprovalsService {
|
||||
const applied = input.decision === 'approve' ? this.applyApprovedProposal(proposal, actor) : null;
|
||||
return { proposal, applied, audit_verify: this.verifyAudit() };
|
||||
} catch (err: any) {
|
||||
if (err instanceof ForbiddenException) throw err;
|
||||
if (Number(err.status) === 3 || Number(err.status) === 1) {
|
||||
throw new ForbiddenException(err.stderr || err.message);
|
||||
}
|
||||
@@ -128,6 +145,42 @@ export class ApprovalsService {
|
||||
}
|
||||
}
|
||||
|
||||
private findProposal(id: string) {
|
||||
const res = runFile('python3', [INBOX_CLI, 'list', '--status', 'all']);
|
||||
const store = parseJson<Record<string, any>>(res.stdout, { proposals: [] });
|
||||
const proposal = (store.proposals ?? []).find((p: Record<string, any>) => p.id === id);
|
||||
if (!proposal) throw new ForbiddenException(`APPROVAL_DECIDE_DENY unknown_id ${id}`);
|
||||
return proposal;
|
||||
}
|
||||
|
||||
private verifyApprovalIdentity(input: ApprovalDecision, actor: SettingsActor, proposal: Record<string, any>) {
|
||||
const strict = process.env.CASAN_APPROVAL_STRICT === '1' || process.env.CASAN_PROFILE === 'prod' || Boolean(input.approvalJwt);
|
||||
if (!strict) return;
|
||||
const work = mkdtempSync(join(tmpdir(), 'cp-approval-verify-'));
|
||||
const inputPath = join(work, 'approval-input.json');
|
||||
try {
|
||||
writeFileSync(inputPath, stableJson({
|
||||
id: proposal.id,
|
||||
action: proposal.action,
|
||||
target: proposal.target,
|
||||
proposer: proposal.proposer,
|
||||
payload: proposal.payload ?? {},
|
||||
}));
|
||||
runFile('bash', [
|
||||
APPROVAL_VERIFY,
|
||||
String(proposal.action ?? 'default'),
|
||||
String(proposal.proposer ?? ''),
|
||||
inputPath,
|
||||
actor.actor,
|
||||
'-',
|
||||
], input.approvalJwt ? { CASAN_APPROVAL_JWT: input.approvalJwt } : undefined);
|
||||
} catch (err: any) {
|
||||
throw new ForbiddenException(err.stderr || err.message || 'APPROVAL_DECIDE_DENY approval identity failed');
|
||||
} finally {
|
||||
rmSync(work, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
private applyApprovedProposal(proposal: Record<string, any>, actor: SettingsActor) {
|
||||
if (proposal.action !== 'settings.write') return null;
|
||||
const key = proposal.payload?.key;
|
||||
|
||||
Reference in New Issue
Block a user