feat: escalate chat approvals through inbox
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -30,6 +30,7 @@ ROUTER = os.path.join(BIN, "prompt-mode-router.py")
|
||||
AGENT_RESOLVER = os.path.join(BIN, "chat-agent-resolver.py")
|
||||
READONLY = os.path.join(BIN, "chat-readonly.py")
|
||||
OPERATOR = os.path.join(BIN, "chat-operator.py")
|
||||
APPROVAL_INBOX = os.path.join(BIN, "approval-inbox.py")
|
||||
LOOP_RUN = os.path.join(BIN, "loop-run.sh")
|
||||
LOOP_TRACE = os.path.join(BIN, "loop-trace.py")
|
||||
PREFLIGHT = os.path.join(BIN, "harness-preflight.sh")
|
||||
@@ -41,6 +42,14 @@ def state_root() -> str:
|
||||
return os.environ.get("CASAN_STATE_ROOT") or os.path.join(ROOT, ".specify")
|
||||
|
||||
|
||||
def audit_path() -> str:
|
||||
return os.environ.get("CASAN_CHAT_AUDIT_LOG") or os.path.join(state_root(), "logs", "chat", "chat-turns.jsonl")
|
||||
|
||||
|
||||
def head_path() -> str:
|
||||
return os.environ.get("CASAN_CHAT_AUDIT_HEAD") or os.path.join(state_root(), "logs", "chat", "chat-head.txt")
|
||||
|
||||
|
||||
def sha(text: str) -> str:
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
@@ -127,6 +136,32 @@ def loop_dir(run_id: str) -> str:
|
||||
return os.path.join(state_root(), "logs", "chat", "loop-runs", run_id)
|
||||
|
||||
|
||||
def load_chat_head() -> str:
|
||||
try:
|
||||
return open(head_path(), encoding="utf-8").read().strip() or ("0" * 64)
|
||||
except OSError:
|
||||
return "0" * 64
|
||||
|
||||
|
||||
def append_chat_turn(base):
|
||||
path = audit_path()
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
seq = 1
|
||||
if os.path.isfile(path):
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
seq = sum(1 for line in fh if line.strip()) + 1
|
||||
prev = load_chat_head()
|
||||
core = {"seq": seq, **base, "prev_hash": prev}
|
||||
record_hash = sha(json.dumps(core, sort_keys=True, ensure_ascii=False))
|
||||
rec = {**core, "record_hash": record_hash}
|
||||
with open(path, "a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
||||
os.makedirs(os.path.dirname(head_path()), exist_ok=True)
|
||||
with open(head_path(), "w", encoding="utf-8") as fh:
|
||||
fh.write(record_hash + "\n")
|
||||
return rec
|
||||
|
||||
|
||||
def run_preflight_and_context(run_id: str, draft_path: str):
|
||||
preflight_out = os.path.join(loop_dir(run_id), "preflight.json")
|
||||
r = subprocess.run(["bash", PREFLIGHT, draft_path, preflight_out, "--model", "local:chat-turn"], cwd=ROOT, capture_output=True, text=True)
|
||||
@@ -257,20 +292,85 @@ def bind_agent(args, router):
|
||||
for tool in tools:
|
||||
cmd += ["--tool", tool]
|
||||
r = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
sys.stdout.write((r.stdout or r.stderr).strip() + "\n")
|
||||
return r.returncode, None
|
||||
try:
|
||||
return 0, json.loads(r.stdout)
|
||||
return r.returncode, json.loads(r.stdout)
|
||||
except Exception:
|
||||
if r.returncode != 0 and (r.stdout or r.stderr):
|
||||
sys.stdout.write((r.stdout or r.stderr).strip() + "\n")
|
||||
return r.returncode, None
|
||||
print(json.dumps({"success": False, "decision": "DENIED", "reason": "agent_resolver_invalid_json"}, ensure_ascii=False))
|
||||
return 2, None
|
||||
|
||||
|
||||
def submit_escalation(args, router, binding):
|
||||
turn_id = args.turn_id or str(uuid.uuid4())
|
||||
payload = {
|
||||
"chat_id": args.chat_id or "chat-default",
|
||||
"turn_id": turn_id,
|
||||
"tenant_id": args.tenant,
|
||||
"actor": args.actor,
|
||||
"role": args.role,
|
||||
"message_ref": sha(args.message),
|
||||
"message_preview": args.message[:240],
|
||||
"router": router,
|
||||
"agent_binding": binding,
|
||||
}
|
||||
reason = f"chat turn requires approval: {binding.get('reason', 'requires_approval')}"
|
||||
r = subprocess.run([
|
||||
"python3", APPROVAL_INBOX, "submit",
|
||||
"--project", args.project,
|
||||
"--action", "chat.escalate",
|
||||
"--target", f"chat:{args.chat_id or 'chat-default'}:{turn_id}",
|
||||
"--risk", router.get("risk", "high"),
|
||||
"--sensitive",
|
||||
"--proposer", args.actor,
|
||||
"--reason", reason,
|
||||
"--payload", json.dumps(payload, ensure_ascii=False, sort_keys=True),
|
||||
], cwd=ROOT, capture_output=True, text=True)
|
||||
try:
|
||||
proposal = json.loads(r.stdout)
|
||||
except Exception:
|
||||
proposal = {"status": "failed", "error": (r.stdout + r.stderr).strip()}
|
||||
rec = append_chat_turn({
|
||||
"timestamp": proposal.get("created_at") or "",
|
||||
"trace_id": "chat-escalation-" + sha(f"{args.chat_id}|{turn_id}|{args.message}")[:12],
|
||||
"chat_id": args.chat_id or "chat-default",
|
||||
"turn_id": turn_id,
|
||||
"tenant_id": args.tenant,
|
||||
"actor": args.actor,
|
||||
"mode": router.get("mode", "READ_ONLY"),
|
||||
"risk": router.get("risk", "high"),
|
||||
"decision": "ESCALATED" if r.returncode == 0 else "DENIED",
|
||||
"answer": "Chat turn escalated to approval inbox." if r.returncode == 0 else "Chat escalation failed closed.",
|
||||
"sources": [],
|
||||
"router": router,
|
||||
"agent_binding": binding,
|
||||
"approval": {"proposal_id": proposal.get("id"), "status": proposal.get("status"), "target": proposal.get("target")},
|
||||
})
|
||||
print(json.dumps({
|
||||
"success": False,
|
||||
"mode": router.get("mode", "READ_ONLY"),
|
||||
"risk": router.get("risk", "high"),
|
||||
"decision": "ESCALATED" if r.returncode == 0 else "DENIED",
|
||||
"answer": "Chat turn escalated to approval inbox." if r.returncode == 0 else "Chat escalation failed closed.",
|
||||
"sources": [],
|
||||
"certified": False,
|
||||
"audit": {"seq": rec["seq"], "record_hash": rec["record_hash"], "head": rec["record_hash"], "path": audit_path()},
|
||||
"router": router,
|
||||
"agent_binding": binding,
|
||||
"approval": {"proposal": proposal, "submit_rc": r.returncode, "output": (r.stdout + r.stderr).strip()[:400]},
|
||||
}, ensure_ascii=False))
|
||||
return 3 if r.returncode == 0 else 2
|
||||
|
||||
|
||||
def ask(args) -> int:
|
||||
router = classify(args.message)
|
||||
bind_rc, binding = bind_agent(args, router)
|
||||
if bind_rc != 0:
|
||||
if binding and binding.get("decision") == "REQUIRES_APPROVAL":
|
||||
return submit_escalation(args, router, binding)
|
||||
if binding:
|
||||
print(json.dumps(binding, ensure_ascii=False))
|
||||
return bind_rc
|
||||
common = [
|
||||
"--message", args.message,
|
||||
|
||||
@@ -153,6 +153,7 @@ run "phase-chat-agent-select" bash "$TESTS/phase-chat-agent-select-tests.sh"
|
||||
run "phase-chat-pipeline" bash "$TESTS/phase-chat-pipeline-tests.sh"
|
||||
run "phase-chat-stream-hold" bash "$TESTS/phase-chat-stream-hold-tests.sh"
|
||||
run "phase-chat-replay" bash "$TESTS/phase-chat-replay-tests.sh"
|
||||
run "phase-chat-approval" bash "$TESTS/phase-chat-approval-tests.sh"
|
||||
|
||||
# ARCH-02: coverage cannot silently drop; ARCH-01: harness/policy cannot silently
|
||||
# drift. Both SKIP cleanly when no manifest is provisioned (non-strict dev/CI).
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
|
||||
# Plan-18 MVP-2 Track 8.4: chat turns that exceed delegated authority are
|
||||
# escalated into the Plan-13 approval inbox and remain tamper-verifiable.
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/../scripts/bash/casan-paths.sh"
|
||||
TURN="$CASAN_HARNESS_ROOT/scripts/bash/chat-turn.py"
|
||||
APPROVAL="$CASAN_HARNESS_ROOT/scripts/bash/approval-inbox.py"
|
||||
REPLAY="$CASAN_HARNESS_ROOT/scripts/bash/chat-replay.py"
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
export CASAN_STATE_ROOT="$WORK/state"
|
||||
export CASAN_APPROVAL_INBOX_FILE="$WORK/approval-inbox.json"
|
||||
|
||||
PASS=0; FAIL=0
|
||||
pass() { echo "PASS: $1"; PASS=$((PASS + 1)); }
|
||||
fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); }
|
||||
|
||||
echo "===== Plan-18 MVP-2 chat approvals escalation ====="
|
||||
|
||||
set +e
|
||||
python3 "$TURN" ask \
|
||||
--message "Summarize Plan 18 MVP-2 status" \
|
||||
--actor alice \
|
||||
--role viewer \
|
||||
--chat-id approval-chat \
|
||||
--tenant tenant-a \
|
||||
--delegation-level 1 > "$WORK/escalated.json"
|
||||
RC=$?
|
||||
set -e 2>/dev/null || true
|
||||
|
||||
python3 - "$WORK/escalated.json" "$RC" <<'PY' \
|
||||
&& pass "delegation escalation creates pending approval proposal" || fail "chat turn did not escalate to approval inbox"
|
||||
import json, sys
|
||||
d = json.load(open(sys.argv[1]))
|
||||
assert int(sys.argv[2]) == 3
|
||||
assert d["success"] is False
|
||||
assert d["decision"] == "ESCALATED"
|
||||
assert d["agent_binding"]["decision"] == "REQUIRES_APPROVAL"
|
||||
assert d["approval"]["proposal"]["status"] == "pending"
|
||||
assert d["approval"]["proposal"]["action"] == "chat.escalate"
|
||||
assert d["approval"]["proposal"]["payload"]["chat_id"] == "approval-chat"
|
||||
PY
|
||||
|
||||
python3 "$APPROVAL" list --status pending > "$WORK/pending.json"
|
||||
python3 - "$WORK/pending.json" <<'PY' \
|
||||
&& pass "approval inbox lists escalated chat proposal" || fail "pending chat proposal missing from inbox"
|
||||
import json, sys
|
||||
d = json.load(open(sys.argv[1]))
|
||||
assert d["count"] == 1
|
||||
p = d["proposals"][0]
|
||||
assert p["status"] == "pending"
|
||||
assert p["action"] == "chat.escalate"
|
||||
assert p["payload"]["agent_binding"]["requires_approval"] is True
|
||||
PY
|
||||
|
||||
python3 "$REPLAY" verify-chain > "$WORK/chat-chain.out" \
|
||||
&& grep -q '"decision": "OK"' "$WORK/chat-chain.out" \
|
||||
&& pass "escalated chat turn audit chain verifies" || fail "escalated chat audit chain invalid"
|
||||
|
||||
python3 "$APPROVAL" verify-audit > "$WORK/approval-audit.out" \
|
||||
&& grep -q "APPROVAL_INBOX_AUDIT ok=true" "$WORK/approval-audit.out" \
|
||||
&& pass "approval oversight audit chain verifies" || fail "approval oversight audit invalid"
|
||||
|
||||
echo ""
|
||||
echo "===== CHAT APPROVAL SUMMARY: PASS=$PASS FAIL=$FAIL ====="
|
||||
[[ "$FAIL" -eq 0 ]] || exit 1
|
||||
@@ -19,7 +19,7 @@ Optional layer for teams that want UI / dashboard / visibility. Packages: `casan
|
||||
| RBAC + approval inbox | ✅ local-prod done | Settings + kill-switch API RBAC enforcement, approval inbox/delegation/oversight, SoD, governed setting proposal apply, and local OIDC claim→role mapping smoke are done. Enterprise IdP rollout remains production follow-up. |
|
||||
| Evidence Pack Viewer | 📋 planned | reads `docs/output/casan/evidence-packs/` |
|
||||
| Attack Battery Viewer | 📋 planned | reads red-team corpus + H4 recall results |
|
||||
| Read-only Ask CASAN + Operator + agent selection + operator loop-hold + replay/widget | 🟡 MVP-0/1 done+test; MVP-2 Track 4 + OPERATOR Track 5/6 + Track 8.1/8.2/8.3 done | Plan-18: `POST /api/v1/chat/ask`, `GET /api/v1/chat/actions`, `GET /api/v1/chat/agents`, `GET /api/v1/chat/replay`, Command Center `chat_loop` widget + `/chat` + `/command`, backed by harness `chat-turn.py`, `chat-agent-resolver.py`, `chat-replay.py`, and Plan-17 `loop-run.sh` |
|
||||
| Read-only Ask CASAN + Operator + agent selection + operator loop-hold + replay/widget/approvals | 🟡 MVP-0/1 done+test; MVP-2 Track 4 + OPERATOR Track 5/6 + Track 8.1/8.2/8.3/8.4 done | Plan-18: `POST /api/v1/chat/ask`, `GET /api/v1/chat/actions`, `GET /api/v1/chat/agents`, `GET /api/v1/chat/replay`, Command Center `chat_loop` widget, chat escalation into `/approvals`, `/chat` + `/command`, backed by harness `chat-turn.py`, `chat-agent-resolver.py`, `chat-replay.py`, `approval-inbox.py`, and Plan-17 `loop-run.sh` |
|
||||
| Gitea webhook integration | 📋 planned | trigger gate / publish evidence on push |
|
||||
|
||||
## Build (preview)
|
||||
@@ -29,6 +29,6 @@ scripts/package-release.sh platform # → dist/casan-platform-preview-vX.Y.Z
|
||||
The bundle includes a `PREVIEW-INCOMPLETE.txt` marker. Do not treat it as a finished product.
|
||||
|
||||
## To implement later
|
||||
Start from Plan-15 RAI view, Plan-18 MVP-2 Track 8.4 approvals escalation, or CODEGEN-as-loop. Keep new numbers
|
||||
Start from Plan-15 RAI view, Plan-18 CODEGEN-as-loop, or Plan-18 Track 9 multi-tenant hardening. Keep new numbers
|
||||
evidence-backed with provenance; any write/governed action must continue to route through
|
||||
harness RBAC, approval, and audit primitives.
|
||||
|
||||
Reference in New Issue
Block a user