feat: escalate chat approvals through inbox

This commit is contained in:
thanhnv
2026-07-08 23:09:43 +09:00
parent a029a51e72
commit 780fe86273
15 changed files with 316 additions and 26 deletions
+6 -2
View File
@@ -53,10 +53,11 @@ Approval inbox / HITL:
- `GET /api/v1/approvals?status=pending` — list proposals and oversight tail.
- `POST /api/v1/approvals/submit` — submit a governed proposal; delegation is resolved
by harness `approval-inbox.py` + `delegation-policy.yaml`.
- `POST /api/v1/approvals/decide` — approve/reject with SoD and reason; approved
- `POST /api/v1/approvals/decide` — approve/reject with SoD and reason; strict mode
or supplied `approvalJwt` is verified by harness `approval-verify.sh`; approved
settings proposals apply through `control-plane-settings.py`.
Governed Chat (Plan-18 MVP-0/1 + MVP-2 Track 4, Operator Track 5/6, and Track 8.1-8.3):
Governed Chat (Plan-18 MVP-0/1 + MVP-2 Track 4, Operator Track 5/6, and Track 8.1-8.4):
- `POST /api/v1/chat/ask` — Ask CASAN endpoint. The API only wraps harness
`chat-turn.py`; router verdicts, H4 input/output scan, action-gate decisions,
@@ -73,12 +74,15 @@ Governed Chat (Plan-18 MVP-0/1 + MVP-2 Track 4, Operator Track 5/6, and Track 8.
`chat-replay.py`.
- `GET /api/v1/command` — includes the `chat_loop` Command Center widget backed
by chat audit/replay evidence, loop ticker rows, and token budget gauge.
- Delegation escalation returns `ESCALATED` and creates a pending `chat.escalate`
proposal in the approval inbox.
- `/chat` UI shows actor/role scope, `mode/risk/decision` badges, certified answer,
evidence sources, registered operator actions, agent binding, loop certification,
action-gate status, router details, and audit hash. Side-effect requests outside
registered actions return governed `BLOCK` or `NOT_SUPPORTED` responses; operator
side effects are held until the loop draft is certified.
- `/command` UI renders the Chat/Loop widget in the existing evidence drawer flow.
- `/approvals` UI accepts an approval JWT for strict reviewer identity checks.
FinOps/SLO:
@@ -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;
@@ -12,6 +12,7 @@ const orgAdmin = { actor: 'root', role: 'org-admin', project: 'default', tenant:
function withTempGovernance(fn: (paths: { inbox: string; store: string }) => void) {
const prevInbox = process.env.CASAN_APPROVAL_INBOX_FILE;
const prevStrict = process.env.CASAN_APPROVAL_STRICT;
const prevStore = process.env.CASAN_CP_STORE_FILE;
const prevKeyDir = process.env.CASAN_CP_KEY_DIR;
const prevPub = process.env.CASAN_CP_PUB;
@@ -25,6 +26,8 @@ function withTempGovernance(fn: (paths: { inbox: string; store: string }) => voi
} finally {
if (prevInbox === undefined) delete process.env.CASAN_APPROVAL_INBOX_FILE;
else process.env.CASAN_APPROVAL_INBOX_FILE = prevInbox;
if (prevStrict === undefined) delete process.env.CASAN_APPROVAL_STRICT;
else process.env.CASAN_APPROVAL_STRICT = prevStrict;
if (prevStore === undefined) delete process.env.CASAN_CP_STORE_FILE;
else process.env.CASAN_CP_STORE_FILE = prevStore;
if (prevKeyDir === undefined) delete process.env.CASAN_CP_KEY_DIR;
@@ -62,6 +65,28 @@ test('approval inbox submit -> approve applies governed setting and writes overs
});
});
test('approval inbox denies forged JWT in strict mode without deciding proposal', () => {
withTempGovernance(({ inbox }) => {
const svc = new ApprovalsService();
const submitted = svc.submit({
action: 'settings.write',
target: 'security.strict',
risk: 'high',
sensitive: true,
reason: 'strict approval required',
payload: { key: 'security.strict', value: true },
}, projectAdmin) as any;
process.env.CASAN_APPROVAL_STRICT = '1';
assert.throws(
() => svc.decide({ id: submitted.proposal.id, decision: 'approve', reason: 'forged jwt', approvalJwt: 'fake.jwt.token' }, approver),
ForbiddenException,
);
const inboxRaw = JSON.parse(readFileSync(inbox, 'utf8'));
assert.equal(inboxRaw.proposals[0].status, 'pending');
assert.equal(inboxRaw.oversight.length, 1);
});
});
test('approval inbox enforces separation of duties', () => {
withTempGovernance(() => {
const svc = new ApprovalsService();
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync } from 'node:fs';
import { mkdtempSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { ChatService } from '../src/chat/chat.service.js';
@@ -14,8 +14,11 @@ function withTempChatState(fn: () => void) {
CASAN_CHAT_AUDIT_LOG: process.env.CASAN_CHAT_AUDIT_LOG,
CASAN_CHAT_AUDIT_HEAD: process.env.CASAN_CHAT_AUDIT_HEAD,
CASAN_CHAT_METRICS_LOG: process.env.CASAN_CHAT_METRICS_LOG,
CASAN_APPROVAL_INBOX_FILE: process.env.CASAN_APPROVAL_INBOX_FILE,
};
process.env.CASAN_STATE_ROOT = mkdtempSync(join(tmpdir(), 'cp-chat-'));
const state = mkdtempSync(join(tmpdir(), 'cp-chat-'));
process.env.CASAN_STATE_ROOT = state;
process.env.CASAN_APPROVAL_INBOX_FILE = join(state, 'approval-inbox.json');
delete process.env.CASAN_CHAT_AUDIT_LOG;
delete process.env.CASAN_CHAT_AUDIT_HEAD;
delete process.env.CASAN_CHAT_METRICS_LOG;
@@ -110,3 +113,25 @@ test('chat ask denies selected agent outside actor role', () => {
assert.equal(res.reason, 'role_not_allowed_for_agent');
});
});
test('chat ask escalates delegation approval into approval inbox', () => {
withTempChatState(() => {
const svc = new ChatService();
const res = svc.ask({
message: 'Summarize Plan 18 MVP-2 status',
chatId: 'approval-chat',
delegationLevel: 1,
}, viewer) as any;
assert.equal(res.success, false);
assert.equal(res.decision, 'ESCALATED');
assert.equal(res.agent_binding.decision, 'REQUIRES_APPROVAL');
assert.equal(res.approval.proposal.status, 'pending');
assert.equal(res.approval.proposal.action, 'chat.escalate');
assert.equal(res.audit_verify.ok, true);
const inbox = JSON.parse(readFileSync(process.env.CASAN_APPROVAL_INBOX_FILE!, 'utf8'));
assert.equal(inbox.proposals.length, 1);
assert.equal(inbox.proposals[0].payload.chat_id, 'approval-chat');
assert.equal(inbox.proposals[0].payload.agent_binding.decision, 'REQUIRES_APPROVAL');
});
});
@@ -210,7 +210,7 @@ export const api = {
approvals: (actor: SettingsActor, status = 'pending') => getWithHeaders<ApprovalsState>(`approvals?status=${status}`, actorHeaders(actor)),
submitApproval: (actor: SettingsActor, body: { action: string; target: string; risk?: string; sensitive?: boolean; reason: string; payload?: Record<string, unknown> }) =>
post<{ proposal: any; audit_verify: { ok: boolean; output: string } }>('approvals/submit', body, actorHeaders(actor)),
decideApproval: (actor: SettingsActor, body: { id: string; decision: 'approve' | 'reject'; reason: string }) =>
decideApproval: (actor: SettingsActor, body: { id: string; decision: 'approve' | 'reject'; reason: string; approvalJwt?: string }) =>
post<{ proposal: any; applied: any; audit_verify: { ok: boolean; output: string } }>('approvals/decide', body, actorHeaders(actor)),
askChat: (actor: SettingsActor, body: { message: string; chatId?: string; agentId?: string; skillId?: string; delegationLevel?: number }) =>
post<ChatAnswer>('chat/ask', body, actorHeaders(actor)),
@@ -23,6 +23,7 @@ export function Approvals() {
const [sensitive, setSensitive] = useState(true);
const [reason, setReason] = useState('review requested from Control Panel');
const [decisionReason, setDecisionReason] = useState('reviewed in approval inbox');
const [approvalJwt, setApprovalJwt] = useState('');
const [message, setMessage] = useState<string | null>(null);
const inbox = useQuery({
@@ -49,7 +50,7 @@ export function Approvals() {
const decide = useMutation({
mutationFn: ({ id, decision }: { id: string; decision: 'approve' | 'reject' }) =>
api.decideApproval(actor, { id, decision, reason: decisionReason }),
api.decideApproval(actor, { id, decision, reason: decisionReason, approvalJwt: approvalJwt || undefined }),
onSuccess: (res) => {
setMessage(`${res.proposal.status} ${res.proposal.id}${res.applied ? ` applied v${res.applied.version}` : ''}`);
void queryClient.invalidateQueries({ queryKey: ['approvals'] });
@@ -129,6 +130,10 @@ export function Approvals() {
<span className="text-gray-500">Decision reason</span>
<input className="w-full rounded border border-gray-300 px-3 py-2" value={decisionReason} onChange={(e) => setDecisionReason(e.target.value)} />
</label>
<label className="block space-y-1 text-sm">
<span className="text-gray-500">Approval JWT</span>
<input className="w-full rounded border border-gray-300 px-3 py-2 font-mono text-xs" value={approvalJwt} onChange={(e) => setApprovalJwt(e.target.value)} />
</label>
{inbox.data.proposals.map((p) => (
<div key={p.id} className="rounded border border-gray-200 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">