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
@@ -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');
});
});